Compare commits

...

91 Commits
v2.6.1 ... main

Author SHA1 Message Date
482795dd9a
Merge pull request #181 from cupcakearmy/dependabot/cargo/packages/backend/cargo-362f336499
Bump ring from 0.16.20 to 0.17.12 in /packages/backend in the cargo group across 1 directory
2025-03-08 10:34:30 +01:00
dependabot[bot]
2907e7c002
Bump ring in /packages/backend in the cargo group across 1 directory
Bumps the cargo group with 1 update in the /packages/backend directory: [ring](https://github.com/briansmith/ring).


Updates `ring` from 0.16.20 to 0.17.12
- [Changelog](https://github.com/briansmith/ring/blob/main/RELEASES.md)
- [Commits](https://github.com/briansmith/ring/commits)

---
updated-dependencies:
- dependency-name: ring
  dependency-type: direct:production
  dependency-group: cargo
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-07 16:49:50 +00:00
4cc9d8a758
Merge pull request #179 from larsgerber/main
docs(compose): prevent anonymous volume creation
2025-03-02 21:50:52 +01:00
Lars Gerber
d652c4ee1e
docs(compose): prevent anonymous volume creation 2025-03-01 21:28:34 +01:00
096be03966
Merge pull request #178 from cupcakearmy/176-ram-only
update docs about ram only redis
2025-02-27 20:13:09 +01:00
c53cde6886
Merge branch 'main' into 176-ram-only 2025-02-27 20:06:56 +01:00
0fa5a35dae update some versions 2025-02-27 20:00:38 +01:00
ebbb4efa04 version bump 2025-02-27 19:40:45 +01:00
a248440bfd
Merge pull request #177 from cupcakearmy/password-eye-toggle
fix: password eye toggle not working
2025-02-27 19:38:37 +01:00
a1db60d159 update docs about ram only redis 2025-02-27 19:38:07 +01:00
c2653bee84 fix: password eye toggle not working 2025-02-27 19:28:16 +01:00
a2d2acc5de
Merge pull request #168 from cupcakearmy/dependabot/npm_and_yarn/npm_and_yarn-545022be4d
Bump vite from 6.0.7 to 6.0.9 in the npm_and_yarn group across 1 directory
2025-01-22 17:58:41 +01:00
dependabot[bot]
4cc821150d
Bump vite in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.0.7 to 6.0.9
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.0.9/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-01-21 21:00:55 +00:00
e7fb844f66
Merge pull request #167 from cupcakearmy/svelte-5
some frontend love
2025-01-18 14:33:46 +01:00
567a0bed68
Update README.md 2025-01-18 03:52:13 +01:00
c13e53404c add csp draft 2025-01-17 18:48:28 +01:00
82862f0e3e version bump 2025-01-17 18:13:49 +01:00
e20f4626e7 Merge remote-tracking branch 'origin/main' into svelte-5 2025-01-17 18:11:34 +01:00
e440e4b7e0 update to svelte 5 2025-01-17 18:11:26 +01:00
808d846737 remove proxy 2025-01-17 18:11:03 +01:00
63c16a797b
Merge pull request #166 from cupcakearmy/fix-race-condition
fix: introduce locks for delete endpoint to guarantee view counter
2025-01-17 18:01:30 +01:00
ea50590532 fix: introduce locks for delete endpoint to guarantee view counter 2025-01-17 17:34:32 +01:00
b22c3122d7
Merge pull request #163 from werewolfboy13/update-redis-link
Update Redis documentation link in Docker Compose file
2025-01-15 16:11:47 +01:00
Marek
18af2b2f45 Update Redis documentation link in Docker Compose file
Fixes #162

---

For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/cupcakearmy/cryptgeon/issues/162?shareId=XXXX-XXXX-XXXX-XXXX).
2025-01-09 04:08:29 -06:00
c2b557246b
Merge pull request #157 from Jerry-Shr/main
Add Chinese (zh-TW) translations
2025-01-02 09:29:22 +01:00
df9cd08473 version bump 2025-01-02 09:27:36 +01:00
JerryShr
0b8e1d1b2e
Add Chinese (zh-TW) translations 2024-12-05 22:23:44 +08:00
70481341b9
Update README.md 2024-10-07 10:58:57 +02:00
6271ec1ee9
add basic auth example 2024-10-07 10:58:41 +02:00
c7ec587a2d bump version 2024-09-27 19:59:39 +00:00
3e8e82f51c
Merge pull request #153 from scaedufax/imprint_html
Added Option to set a custom HTML Imprint
2024-09-27 21:57:44 +02:00
Uli
c314d4b485
Merge branch 'cupcakearmy:main' into imprint_html 2024-09-25 10:19:13 +02:00
Uli Roth
57ea5f0b28 added imprint_html option 2024-09-24 10:25:15 +02:00
Uli Roth
fca8761515 Added option to have an imprint
The environment Variable IMPRINT_URL simply adds a /imprint button in footer to the url
2024-09-24 10:15:22 +02:00
a47b8a482c
Merge pull request #151 from cupcakearmy/150-type-button
150 type button
2024-09-20 20:24:26 +02:00
847fc9677d bump version 2024-09-20 18:16:01 +00:00
6979be0c4a bump pnpm version 2024-09-20 18:15:56 +00:00
f61d3ece8b add type button to element 2024-09-20 18:15:38 +00:00
14d3e9eb03
add raycast links 2024-09-03 15:57:29 +02:00
7c6ba654f6 correct version bump 2024-09-02 10:28:08 +02:00
cb20224317 version bumps 2024-09-02 10:27:26 +02:00
085b1c20df
Merge pull request #148 from cupcakearmy/move-shared-package-into-cli
Move shared package into cli
2024-09-02 10:26:35 +02:00
4b1f939281 remove useless dependecies and use cli instead of package 2024-09-02 10:20:03 +02:00
7b919f2a53 move shared package to cli 2024-09-02 10:19:35 +02:00
868b49c1c3
add guide 2024-08-27 15:11:18 +02:00
6b5eea34a1
Merge pull request #147 from cupcakearmy/actix-2-axum
Actix 2 axum
2024-08-27 15:06:29 +02:00
d70bee14af version bump 2024-08-27 14:52:17 +02:00
4960260076 also disable icon in text 2024-08-27 14:49:08 +02:00
3247a0cfca skip expiration in safari 2024-08-27 14:35:39 +02:00
9527a499ed skip size 2024-08-27 13:26:04 +02:00
b5590bb5ef add retry 2024-08-27 11:45:43 +02:00
7691dc80f8 use pnpm command 2024-08-27 10:56:21 +02:00
f8d8fa05b0 new message 2024-08-27 10:55:59 +02:00
7aae690850 cleanup imports 2024-08-27 10:55:51 +02:00
e15d9eb537 upload artifacts 2024-08-27 00:42:21 +02:00
7fe70a6f3b revert 2024-08-27 00:38:56 +02:00
d86ef79abd use container 2024-08-27 00:36:48 +02:00
23446a4c74
Merge branch 'main' into actix-2-axum 2024-08-27 00:32:28 +02:00
ee92928d1f pipeline 2024-08-27 00:32:17 +02:00
b60a079bbe
Merge pull request #140 from elfakyn/main
Fix typos in english localization
2024-08-27 00:23:28 +02:00
ba474dff31 more robust config 2024-08-27 00:22:41 +02:00
3cb002ee33
Merge branch 'main' into main 2024-08-27 00:10:44 +02:00
a10d8735dd cleanup tests 2024-08-27 00:09:51 +02:00
dfa2401eea aria check 2024-08-25 22:22:45 +02:00
ea58d89f98 cleanup 2024-08-25 22:22:13 +02:00
eaca1a981d cleanup 2024-08-25 22:21:45 +02:00
199755d18e timeout 2024-08-25 22:20:46 +02:00
724d0709d3 .env.dev 2024-08-25 22:20:29 +02:00
bd5acca97a use axum body limit 2024-08-25 22:19:41 +02:00
a0a99cd3cc cleanup status variable 2024-08-25 22:18:31 +02:00
c3794fa2b6 port 3000 2024-08-23 14:27:59 +02:00
f9962c76c1 enable to big 2024-08-23 14:27:52 +02:00
c2b81bc04d refactor to use axum 2024-08-23 14:27:47 +02:00
a45f6a3772 use 3000 port 2024-08-23 14:27:17 +02:00
2006be0434
Merge pull request #145 from cupcakearmy/better-programmatic-access
better programmatic access
2024-08-23 11:19:57 +02:00
ca72e94e3c update node and playwright 2024-08-23 11:02:30 +02:00
dbcb3870aa fix tests 2024-08-23 11:01:57 +02:00
3ea176cc1f add build cli 2024-08-23 09:59:06 +02:00
145f9ef18f right package stuff 2024-08-22 20:21:05 +02:00
784c54236b better programmatic access 2024-08-22 20:01:14 +02:00
5648c76f78
Merge pull request #144 from cupcakearmy/update-rust
update rust
2024-08-22 19:57:39 +02:00
7761c795df update rust 2024-08-22 18:42:44 +02:00
4aadeb492a maintenance 2024-08-22 18:40:56 +02:00
0d9f3fe9c7
Merge pull request #130 from DDd-Devops/add-redis-tls-feature
Add redis tls feature 'rediss://'
2024-08-22 18:30:58 +02:00
Arya ';--bscriptalert(":)")/script
8ccfdd6e2e
fix typos in english localization 2024-07-04 18:00:06 -04:00
f790438104
add french blog post 2024-06-16 22:34:44 +02:00
5936f4588c
Merge pull request #133 from DDd-Devops/improve-french-translations
improve french translations wording
2024-06-16 22:31:34 +02:00
Thomas Chrétien
d3c04f8fda improve french translations wording 2024-06-14 14:26:02 +00:00
Matthieu Guegan
f8c17487bd Support dynamically-linked and/or native musl targets
See https://github.com/rust-lang/rust/pull/40113#issuecomment-323193341
2024-05-16 09:55:04 +02:00
Matthieu Guegan
ed3e5f48a0 Fix wrong type due to updated lib
See https://github.com/redis-rs/redis-rs/pull/589
2024-05-16 09:47:01 +02:00
Matthieu Guegan
e08c9d1871 Bump redis crate to 0.25.2
This will enable TLS feature
2024-05-16 09:45:26 +02:00
98 changed files with 5282 additions and 4533 deletions

View File

View File

@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
# Node
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: 'pnpm'
@ -27,13 +27,16 @@ jobs:
- name: Prepare
run: |
pnpm install --frozen-lockfile
pnpm install
pnpm exec playwright install --with-deps
pnpm run test:prepare
- name: Run your tests
run: pnpm test
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results
path: test-results
name: playwright-report
path: test-results/
retention-days: 7

1
.gitignore vendored
View File

@ -8,3 +8,4 @@ target
# Testing
test-results
tmp

2
.nvmrc
View File

@ -1 +1 @@
v20.11.1
v22.14.0

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
# FRONTEND
FROM node:20-alpine as client
FROM node:22-alpine as client
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@ -11,17 +11,17 @@ RUN pnpm run build
# BACKEND
FROM rust:1.76-alpine as backend
FROM rust:1.85-alpine as backend
WORKDIR /tmp
RUN apk add --no-cache libc-dev openssl-dev alpine-sdk
COPY ./packages/backend ./
RUN cargo build --release
RUN RUSTFLAGS="-Ctarget-feature=-crt-static" cargo build --release
# RUNNER
FROM alpine:3.19
WORKDIR /app
RUN apk add --no-cache curl
RUN apk add --no-cache curl libgcc
COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/packages/frontend/build ./frontend
ENV FRONTEND_PATH="./frontend"

View File

@ -12,9 +12,10 @@
<br/><br/>
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
<a href=""><img src="./.github/lokalise.png" height="50">
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
<br/><br/>
EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md)
EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md)
## About?
@ -39,6 +40,12 @@ npx cryptgeon send text "This is a secret note"
For more documentation about the CLI see the [readme](./packages/cli/README.md).
### Raycast Extension
There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cryptgeon).
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
## Features
- send text or files
@ -56,6 +63,8 @@ client side with the <code>key</code> and then sent to the server. data is store
never persisted to disk. the server never sees the encryption key and cannot decrypt the contents
of the notes even if it tried to.
> View counts are guaranteed with one running instance of cryptgeon. Multiple instances connected to the same Redis instance can run into race conditions, where a note might be retrieved more than the view count allows.
## Screenshot
![screenshot](./design/Screens.png)
@ -70,14 +79,15 @@ of the notes even if it tried to.
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. |
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
| `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. |
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
## Deployment
> `https` is required otherwise browsers will not support the cryptographic functions.
@ -96,9 +106,14 @@ version: '3.8'
services:
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
@ -141,12 +156,18 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
- English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
- German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
### Written Guides
- French by [zarevskaya](https://belginux.com/installer-cryptgeon-avec-docker/)
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
## Development
**Requirements**
- `pnpm`: `>=6`
- `node`: `>=18`
- `pnpm`: `>=9`
- `node`: `>=22`
- `rust`: edition `2021`
**Install**
@ -174,7 +195,7 @@ Running `pnpm run dev` in the root folder will start the following things:
- client
- cli
You can see the app under [localhost:1234](http://localhost:1234).
You can see the app under [localhost:3000](http://localhost:3000).
> There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json)

View File

@ -59,19 +59,19 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
## Variables de entorno
| Variable | Default | Descripción |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
| Variable | Default | Descripción |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Tamaño máximo. Valores aceptados según la [unidad byte](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> El frontend mostrará ese número, incluyendo el ~35% de sobrecarga de codificación. |
| `MAX_VIEWS` | `100` | Número máximo de vistas. |
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
| `MAX_VIEWS` | `100` | Número máximo de vistas. |
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
## Despliegue
@ -91,9 +91,14 @@ version: '3.8'
services:
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
@ -169,7 +174,7 @@ Ejecutando `pnpm run dev` en la carpeta raíz iniciará lo siguiente:
- client
- cli
Puedes ver la app en [localhost:1234](http://localhost:1234).
Puedes ver la app en [localhost:3000](http://localhost:3000).
> Existe una colección de Postman con algunas peticiones de ejemplo [disponible en el repo](./Cryptgeon.postman_collection.json)

View File

@ -14,7 +14,7 @@
<a href=""><img src="./.github/lokalise.png" height="50">
<br/>
[EN](README.md) | 简体中文 | [ES](README_ES.md)
[EN](README.md) | 简体中文 | [ES](README_ES.md)
## 关于本项目
@ -74,6 +74,14 @@ version: '3.8'
services:
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
@ -108,7 +116,14 @@ networks:
services:
redis:
image: redis:7-alpine
restart: unless-stopped
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
@ -158,7 +173,7 @@ pnpm run dev
- 无热重载的 rust 后端
- 可热重载的客户端
你可以通过 1234 端口进入该应用,即 [localhost:1234](http://localhost:1234).
你可以通过 3000 端口进入该应用,即 [localhost:3000](http://localhost:3000).
## 测试

View File

@ -11,9 +11,6 @@
},
{
"path": "packages/cli"
},
{
"path": "packages/shared"
}
],
"settings": {

View File

@ -1,22 +1,28 @@
# DEV Compose file.
# For a production file see: README.md
version: '3.8'
services:
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
ports:
- 6379:6379
app:
build: .
env_file: .dev.env
env_file: .env.dev
depends_on:
- redis
restart: unless-stopped
ports:
- 1234:8000
- 3000:8000
healthcheck:
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/']

View File

@ -1,11 +1,14 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
@ -16,7 +19,7 @@ services:
SIZE_LIMIT: 4 MiB
ports:
- 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]

View File

@ -3,12 +3,20 @@ version: '3.8'
services:
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
depends_on:
- redis
proxy:
image: nginx:alpine
depends_on:

View File

@ -109,7 +109,14 @@ networks:
services:
redis:
image: redis:7-alpine
restart: unless-stopped
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest

View File

@ -2,7 +2,7 @@
Assumptions:
- Traefik 2 installed.
- Traefik 2/3 installed.
- External proxy docker network `proxy`.
- A certificate resolver `le`.
- A https entrypoint `secure`.
@ -18,7 +18,14 @@ networks:
services:
redis:
image: redis:7-alpine
restart: unless-stopped
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
@ -34,3 +41,51 @@ services:
- traefik.http.routers.cryptgeon.entrypoints=secure
- traefik.http.routers.cryptgeon.tls.certresolver=le
```
## With basic auth
Some times it's useful to hide the service behind auth. This is easily achieved with traefik middleware. Many reverse proxies support similar features, so while traefik is used in this example, other reverse proxies can do the same.
```yaml
services:
traefik:
image: traefik:v3.0
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
cryptgeon:
image: cupcakearmy/cryptgeon
depends_on:
- redis
labels:
- "traefik.enable=true"
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
- "traefik.http.routers.cryptgeon.entrypoints=web"
- "traefik.http.routers.cryptgeon.middlewares=cryptgeon-auth"
- "traefik.http.middlewares.cryptgeon-auth.basicauth.users=user:$$2y$$05$$juUw0zgc5ebvJ00MFPVVLujF6P.rcEMbGZ99Jfq6ZWEa1dgetacEq"
```
```bash
docker compose up -d
```
1. Open http://cryptgeon.localhost
2. Log in with `user` and `secret`

View File

@ -1,21 +1,21 @@
{
"scripts": {
"dev:docker": "docker-compose -f docker-compose.dev.yaml up redis",
"dev:docker": "docker compose -f docker-compose.dev.yaml up redis",
"dev:packages": "pnpm --parallel run dev",
"dev": "run-p dev:*",
"docker:up": "docker compose -f docker-compose.dev.yaml up",
"docker:build": "docker compose -f docker-compose.dev.yaml build",
"test": "playwright test --project chrome firefox safari",
"test:local": "playwright test --project chrome",
"test": "playwright test --project=chrome --project=firefox --project=safari",
"test:local": "playwright test --project=chrome",
"test:server": "run-s docker:up",
"test:prepare": "run-p build docker:build",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
},
"devDependencies": {
"@playwright/test": "^1.42.1",
"@types/node": "^20.11.28",
"@playwright/test": "^1.46.1",
"@types/node": "^22.5.0",
"npm-run-all": "^4.1.5",
"shelljs": "^0.8.5"
},
"packageManager": "pnpm@8.15.4"
"packageManager": "pnpm@10.3.0"
}

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,27 @@
[package]
name = "cryptgeon"
version = "2.6.1"
version = "2.9.1"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021"
rust-version = "1.76"
rust-version = "1.85"
[[bin]]
name = "cryptgeon"
path = "src/main.rs"
[dependencies]
actix-web = "4"
actix-files = "0.6"
serde = { version = "1.0", features = ["derive"] }
# Core
axum = "0.7.5"
serde = { version = "1.0.208", features = ["derive"] }
tokio = { version = "1.39.3", features = ["full"] }
tower = "0.5.0"
tower-http = { version = "0.5.2", features = ["full"] }
redis = { version = "0.25.2", features = ["tls-native-tls"] }
# Utility
serde_json = "1"
lazy_static = "1"
ring = "0.16"
ring = "0.17"
bs62 = "0.1"
byte-unit = "4"
dotenv = "0.15"
mime = "0.3"
env_logger = "0.9"
log = "0.4"
redis = "0.23"

View File

@ -4,7 +4,7 @@
"scripts": {
"dev": "cargo watch -x 'run --bin cryptgeon'",
"build": "cargo build --release",
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:1234 cargo run",
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
"test:prepare": "cargo build"
}
}

View File

@ -1,14 +0,0 @@
use actix_web::web;
use crate::health;
use crate::note;
use crate::status;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api")
.service(note::init())
.service(status::init())
.service(health::init()),
);
}

View File

@ -1,17 +0,0 @@
use actix_files::{Files, NamedFile};
use actix_web::{web, Result};
use crate::config;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
Files::new("/", config::FRONTEND_PATH.to_string())
.index_file("index.html")
.use_etag(true),
);
}
pub async fn index() -> Result<NamedFile> {
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
Ok(NamedFile::open(index)?)
}

View File

@ -38,8 +38,12 @@ pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
.unwrap_or("true".to_string())
.parse()
.unwrap();
pub static ref THEME_NEW_NOTE_NOTICE: bool = std::env::var("THEME_NEW_NOTE_NOTICE")
.unwrap_or("true".to_string())
pub static ref IMPRINT_URL: String = std::env::var("IMPRINT_URL")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
.unwrap_or("".to_string())
.parse()
.unwrap();
}
@ -62,4 +66,8 @@ lazy_static! {
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref THEME_NEW_NOTE_NOTICE: bool = std::env::var("THEME_NEW_NOTE_NOTICE")
.unwrap_or("true".to_string())
.parse()
.unwrap();
}

View File

@ -0,0 +1,16 @@
use axum::{body::Body, extract::Request, http::HeaderValue, middleware::Next, response::Response};
const CUSTOM_HEADER_NAME: &str = "Content-Security-Policy";
const CUSTOM_HEADER_VALUE: &str = "default-src 'self'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; object-src 'none'; base-uri 'self'; connect-src 'self' data:; font-src 'self'; frame-src 'self'; img-src 'self'; manifest-src 'self'; media-src 'self'; worker-src 'none';";
lazy_static! {
static ref HEADER_VALUE: HeaderValue = HeaderValue::from_static(CUSTOM_HEADER_VALUE);
}
pub async fn add_csp_header(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.append(CUSTOM_HEADER_NAME, HEADER_VALUE.clone());
response
}

View File

@ -1,3 +1,10 @@
mod routes;
use crate::store;
use axum::http::StatusCode;
pub use routes::*;
pub async fn report_health() -> (StatusCode,) {
if store::can_reach_redis() {
return (StatusCode::OK,);
} else {
return (StatusCode::SERVICE_UNAVAILABLE,);
}
}

View File

@ -1,16 +0,0 @@
use actix_web::{get, web, HttpResponse, Responder, Scope};
use crate::store;
#[get("/")]
async fn get_live() -> impl Responder {
if store::can_reach_redis() {
return HttpResponse::Ok();
} else {
return HttpResponse::ServiceUnavailable();
}
}
pub fn init() -> Scope {
web::scope("/live").service(get_live)
}

View File

@ -0,0 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct SharedState {
pub locks: LockMap,
}
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;

View File

@ -1,44 +1,80 @@
use actix_web::{
middleware::{self, Logger},
web::{self},
App, HttpServer,
use std::{collections::HashMap, sync::Arc};
use axum::{
extract::{DefaultBodyLimit, Request},
routing::{delete, get, post},
Router, ServiceExt,
};
use dotenv::dotenv;
use log::error;
use lock::SharedState;
use tokio::sync::Mutex;
use tower::Layer;
use tower_http::{
compression::CompressionLayer,
normalize_path::NormalizePathLayer,
services::{ServeDir, ServeFile},
};
#[macro_use]
extern crate lazy_static;
mod api;
mod client;
mod config;
mod csp;
mod health;
mod lock;
mod note;
mod size;
mod status;
mod store;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
#[tokio::main]
async fn main() {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
let shared_state = SharedState {
locks: Arc::new(Mutex::new(HashMap::new())),
};
if !store::can_reach_redis() {
error!("cannot reach redis");
panic!("canont reach redis");
println!("cannot reach redis");
panic!("cannot reach redis");
}
return HttpServer::new(|| {
App::new()
.wrap(Logger::new("\"%r\" %s %b %T"))
.wrap(middleware::Compress::default())
.wrap(middleware::DefaultHeaders::default())
.configure(size::init)
.configure(api::init)
.configure(client::init)
.default_service(web::to(client::index))
})
.bind(config::LISTEN_ADDR.to_string())?
.run()
.await;
let notes_routes = Router::new()
.route("/", post(note::create))
.route("/:id", delete(note::delete))
.route("/:id", get(note::preview));
let health_routes = Router::new().route("/live", get(health::report_health));
let status_routes = Router::new().route("/status", get(status::get_status));
let api_routes = Router::new()
.nest("/notes", notes_routes)
.nest("/", health_routes)
.nest("/", status_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
let serve_dir =
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
let app = Router::new()
.nest("/api", api_routes)
.fallback_service(serve_dir)
// Disabled for now, as svelte inlines scripts
// .layer(middleware::from_fn(csp::add_csp_header))
.layer(DefaultBodyLimit::max(*config::LIMIT))
.layer(
CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true),
)
.with_state(shared_state);
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
let listener = tokio::net::TcpListener::bind(config::LISTEN_ADDR.to_string())
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, ServiceExt::<Request>::into_make_service(app))
.await
.unwrap();
}

View File

@ -12,12 +12,12 @@ pub struct Note {
pub expiration: Option<u32>,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize)]
pub struct NoteInfo {
pub meta: String,
}
#[derive(Serialize, Deserialize, Clone)]
#[derive(Serialize)]
pub struct NotePublic {
pub meta: String,
pub contents: String,

View File

@ -1,10 +1,18 @@
use actix_web::{delete, get, post, web, HttpResponse, Responder, Scope};
use axum::{
extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
use std::{sync::Arc, time::SystemTime};
use tokio::sync::Mutex;
use crate::config;
use crate::note::{generate_id, Note, NoteInfo, NotePublic};
use crate::note::{generate_id, Note, NoteInfo};
use crate::store;
use crate::{config, lock::SharedState};
use super::NotePublic;
pub fn now() -> u32 {
SystemTime::now()
@ -13,20 +21,18 @@ pub fn now() -> u32 {
.as_secs() as u32
}
#[derive(Serialize, Deserialize)]
struct NotePath {
#[derive(Deserialize)]
pub struct OneNoteParams {
id: String,
}
#[get("/{id}")]
async fn one(path: web::Path<NotePath>) -> impl Responder {
let p = path.into_inner();
let note = store::get(&p.id);
pub async fn preview(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response {
let note = store::get(&id);
match note {
Ok(Some(n)) => HttpResponse::Ok().json(NoteInfo { meta: n.meta }),
Ok(None) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
Ok(Some(n)) => (StatusCode::OK, Json(NoteInfo { meta: n.meta })).into_response(),
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -35,13 +41,16 @@ struct CreateResponse {
id: String,
}
#[post("/")]
async fn create(note: web::Json<Note>) -> impl Responder {
let mut n = note.into_inner();
pub async fn create(Json(mut n): Json<Note>) -> Response {
// let mut n = note.into_inner();
let id = generate_id();
let bad_req = HttpResponse::BadRequest().finish();
// let bad_req = HttpResponse::BadRequest().finish();
if n.views == None && n.expiration == None {
return bad_req;
return (
StatusCode::BAD_REQUEST,
"At least views or expiration must be set",
)
.into_response();
}
if !*config::ALLOW_ADVANCED {
n.views = Some(1);
@ -50,7 +59,7 @@ async fn create(note: web::Json<Note>) -> impl Responder {
match n.views {
Some(v) => {
if v > *config::MAX_VIEWS || v < 1 {
return bad_req;
return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
}
n.expiration = None; // views overrides expiration
}
@ -58,8 +67,8 @@ async fn create(note: web::Json<Note>) -> impl Responder {
}
match n.expiration {
Some(e) => {
if e > *config::MAX_EXPIRATION {
return bad_req;
if e > *config::MAX_EXPIRATION || e < 1 {
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
}
let expiration = now() + (e * 60);
n.expiration = Some(expiration);
@ -67,38 +76,49 @@ async fn create(note: web::Json<Note>) -> impl Responder {
_ => {}
}
match store::set(&id.clone(), &n.clone()) {
Ok(_) => return HttpResponse::Ok().json(CreateResponse { id: id }),
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
Ok(_) => (StatusCode::OK, Json(CreateResponse { id })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
#[delete("/{id}")]
async fn delete(path: web::Path<NotePath>) -> impl Responder {
let p = path.into_inner();
let note = store::get(&p.id);
pub async fn delete(
Path(OneNoteParams { id }): Path<OneNoteParams>,
state: axum::extract::State<SharedState>,
) -> Response {
let mut locks_map = state.locks.lock().await;
let lock = locks_map
.entry(id.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
drop(locks_map);
let _guard = lock.lock().await;
let note = store::get(&id);
match note {
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
Ok(None) => return HttpResponse::NotFound().finish(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Ok(Some(note)) => {
let mut changed = note.clone();
if changed.views == None && changed.expiration == None {
return HttpResponse::BadRequest().finish();
return (StatusCode::BAD_REQUEST).into_response();
}
match changed.views {
Some(v) => {
changed.views = Some(v - 1);
let id = p.id.clone();
let id = id.clone();
if v <= 1 {
match store::del(&id) {
Err(e) => {
return HttpResponse::InternalServerError().body(e.to_string())
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response();
}
_ => {}
}
} else {
match store::set(&id, &changed.clone()) {
Err(e) => {
return HttpResponse::InternalServerError().body(e.to_string())
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response();
}
_ => {}
}
@ -111,33 +131,26 @@ async fn delete(path: web::Path<NotePath>) -> impl Responder {
match changed.expiration {
Some(e) => {
if e < n {
match store::del(&p.id.clone()) {
Ok(_) => return HttpResponse::BadRequest().finish(),
match store::del(&id.clone()) {
Ok(_) => return (StatusCode::BAD_REQUEST).into_response(),
Err(e) => {
return HttpResponse::InternalServerError().body(e.to_string())
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response()
}
}
}
}
_ => {}
}
return HttpResponse::Ok().json(NotePublic {
contents: changed.contents,
meta: changed.meta,
});
return (
StatusCode::OK,
Json(NotePublic {
contents: changed.contents,
meta: changed.meta,
}),
)
.into_response();
}
}
}
#[derive(Serialize, Deserialize)]
struct Status {
version: String,
max_size: usize,
}
pub fn init() -> Scope {
web::scope("/notes")
.service(one)
.service(create)
.service(delete)
}

View File

@ -1,12 +0,0 @@
use crate::config;
use actix_web::web;
use mime;
pub fn init(cfg: &mut web::ServiceConfig) {
let json = web::JsonConfig::default().limit(*config::LIMIT);
let plain = web::PayloadConfig::default()
.limit(*config::LIMIT)
.mimetype(mime::STAR_STAR);
// cfg.app_data(plain);
cfg.app_data(json).app_data(plain);
}

View File

@ -1,5 +1,43 @@
mod model;
mod routes;
use crate::config;
use axum::{http::StatusCode, Json};
use serde::Serialize;
pub use model::*;
pub use routes::*;
#[derive(Serialize)]
pub struct Status {
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
pub allow_files: bool,
pub imprint_url: String,
pub imprint_html: String,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
pub theme_new_note_notice: bool,
}
pub async fn get_status() -> (StatusCode, Json<Status>) {
let status = Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
allow_files: *config::ALLOW_FILES,
imprint_url: config::IMPRINT_URL.to_string(),
imprint_html: config::IMPRINT_HTML.to_string(),
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string(),
};
(StatusCode::OK, Json(status))
}

View File

@ -1,19 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Status {
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
pub allow_files: bool,
pub theme_new_note_notice: bool,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
}

View File

@ -1,25 +0,0 @@
use actix_web::{get, web, HttpResponse, Responder, Scope};
use crate::config;
use crate::status::Status;
#[get("/")]
async fn get_status() -> impl Responder {
return HttpResponse::Ok().json(Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
allow_files: *config::ALLOW_FILES,
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string(),
});
}
pub fn init() -> Scope {
web::scope("/status").service(get_status)
}

View File

@ -36,7 +36,7 @@ pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
match note.expiration {
Some(e) => {
let seconds = e - now();
conn.expire(id, seconds as usize)
conn.expire(id, seconds as i64)
.map_err(|_| "Unable to set expiration on notion")?
}
None => {}

15
packages/cli/build.js Normal file
View File

@ -0,0 +1,15 @@
import { build } from 'tsup'
import pkg from './package.json' with { type: 'json' }
const watch = process.argv.slice(2)[0] === '--watch'
await build({
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
dts: true,
minify: true,
format: ['esm', 'cjs'],
target: 'es2020',
clean: true,
define: { VERSION: `"${pkg.version}"` },
watch,
})

View File

@ -1,6 +1,6 @@
{
"name": "cryptgeon",
"version": "2.6.1",
"version": "2.9.1",
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": {
"type": "git",
@ -9,7 +9,11 @@
},
"type": "module",
"exports": {
".": "./dist/index.mjs"
".": "./dist/index.js",
"./shared": {
"import": "./dist/shared/shared.js",
"types": "./dist/shared/shared.d.ts"
}
},
"types": "./dist/index.d.ts",
"bin": {
@ -20,22 +24,21 @@
],
"scripts": {
"bin": "run-s build package",
"build": "rm -rf dist && tsc && ./scripts/build.js",
"dev": "./scripts/build.js --watch",
"build": "tsc && node build.js",
"dev": "node build.js --watch",
"prepublishOnly": "run-s build"
},
"devDependencies": {
"@commander-js/extra-typings": "^12.0.1",
"@cryptgeon/shared": "workspace:*",
"@commander-js/extra-typings": "^12.1.0",
"@types/inquirer": "^9.0.7",
"@types/mime": "^3.0.4",
"@types/mime": "^4.0.0",
"@types/node": "^20.11.24",
"commander": "^12.0.0",
"esbuild": "^0.20.1",
"commander": "^12.1.0",
"inquirer": "^9.2.15",
"mime": "^4.0.1",
"occulto": "^2.0.3",
"occulto": "^2.0.6",
"pretty-bytes": "^6.1.1",
"tsup": "^8.2.4",
"typescript": "^5.3.3"
},
"engines": {

View File

@ -1,34 +0,0 @@
#!/usr/bin/env node
import { build, context } from 'esbuild'
import pkg from '../package.json' assert { type: 'json' }
const common = {
bundle: true,
minify: true,
platform: 'node',
define: { VERSION: `"${pkg.version}"` },
}
const cliOptions = {
...common,
entryPoints: ['./src/cli.ts'],
format: 'cjs',
outfile: './dist/cli.cjs',
}
const indexOptions = {
...common,
entryPoints: ['./src/index.ts'],
outfile: './dist/index.mjs',
format: 'esm',
}
const watch = process.argv.slice(2)[0] === '--watch'
if (watch) {
const ctx = await context(cliOptions)
ctx.watch()
} else {
await build(cliOptions)
await build(indexOptions)
}

View File

@ -1,14 +1,15 @@
import { Adapters, get, info, setBase } from '@cryptgeon/shared'
import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { AES, Hex } from 'occulto'
import pretty from 'pretty-bytes'
import { Adapters } from '../shared/adapters.js'
import { API } from '../shared/api.js'
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
setBase(url.origin)
API.setOptions({ server: url.origin })
const id = url.pathname.split('/')[2]
const preview = await info(id).catch(() => {
const preview = await API.info(id).catch(() => {
throw new Error('Note does not exist or is expired')
})
@ -33,7 +34,7 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
}
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
const note = await get(id)
const note = await API.get(id)
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
switch (note.meta.type) {

View File

@ -1,9 +1,10 @@
import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Adapters, BASE, create, FileDTO, Note, NoteMeta } from '@cryptgeon/shared'
import mime from 'mime'
import { AES, Hex } from 'occulto'
import { Adapters } from '../shared/adapters.js'
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
@ -38,8 +39,8 @@ export async function upload(input: string | string[], options: UploadOptions):
// Create the actual note and upload it.
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
const result = await create(note)
let url = `${BASE}/note/${result.id}`
const result = await API.create(note)
let url = `${API.getOptions().server}/note/${result.id}`
if (!derived) url += `#${Hex.encode(key)}`
return url
}

View File

@ -1,14 +1,14 @@
#!/usr/bin/env node
import { Argument, Option, program } from '@commander-js/extra-typings'
import { setBase, status } from '@cryptgeon/shared'
import prettyBytes from 'pretty-bytes'
import { download } from './download.js'
import { parseFile, parseNumber } from './parsers.js'
import { getStdin } from './stdin.js'
import { upload } from './upload.js'
import { checkConstrains, exit } from './utils.js'
import { download } from './actions/download.js'
import { upload } from './actions/upload.js'
import { API } from './shared/api.js'
import { parseFile, parseNumber } from './utils/parsers.js'
import { getStdin } from './utils/stdin.js'
import { checkConstrains, exit } from './utils/utils.js'
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
@ -33,8 +33,8 @@ program
.description('show information about the server')
.addOption(server)
.action(async (options) => {
setBase(options.server)
const response = await status()
API.setOptions({ server: options.server })
const response = await API.status()
const formatted = {
...response,
max_size: prettyBytes(response.max_size),
@ -54,7 +54,7 @@ send
.addOption(minutes)
.addOption(password)
.action(async (files, options) => {
setBase(options.server!)
API.setOptions({ server: options.server })
await checkConstrains(options)
options.password ||= await getStdin()
try {
@ -72,7 +72,7 @@ send
.addOption(minutes)
.addOption(password)
.action(async (text, options) => {
setBase(options.server!)
API.setOptions({ server: options.server })
await checkConstrains(options)
options.password ||= await getStdin()
try {

View File

@ -1,4 +1,4 @@
export * from '@cryptgeon/shared'
export * from './download.js'
export * from './upload.js'
export * from './utils.js'
export * from './actions/download.js'
export * from './actions/upload.js'
export * from './shared/adapters.js'
export * from './shared/api.js'

View File

@ -23,6 +23,10 @@ export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string
}
type ClientOptions = {
server: string
}
type CallOptions = {
url: string
method: string
@ -31,14 +35,21 @@ type CallOptions = {
export class PayloadToLargeError extends Error {}
export let BASE = ''
export function setBase(url: string) {
BASE = url
export let client: ClientOptions = {
server: '',
}
export async function call(options: CallOptions) {
const response = await fetch(BASE + '/api/' + options.url, {
function setOptions(options: Partial<ClientOptions>) {
client = { ...client, ...options }
}
function getOptions(): ClientOptions {
return client
}
async function call(options: CallOptions) {
const url = client.server + '/api/' + options.url
const response = await fetch(url, {
method: options.method,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
mode: 'cors',
@ -54,7 +65,7 @@ export async function call(options: CallOptions) {
return response.json()
}
export async function create(note: Note) {
async function create(note: Note) {
const { meta, ...rest } = note
const body: NoteCreate = {
...rest,
@ -68,7 +79,7 @@ export async function create(note: Note) {
return data as { id: string }
}
export async function get(id: string): Promise<NotePublic> {
async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
@ -82,7 +93,7 @@ export async function get(id: string): Promise<NotePublic> {
return note
}
export async function info(id: string): Promise<NoteInfo> {
async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
@ -101,16 +112,29 @@ export type Status = {
max_views: number
max_expiration: number
allow_advanced: boolean
allow_files: boolean
imprint_url: string
imprint_html: string
theme_image: string
theme_text: string
theme_favicon: string
theme_page_title: string
theme_new_note_notice: boolean
}
export async function status() {
async function status() {
const data = await call({
url: 'status/',
method: 'get',
})
return data as Status
}
export const API = {
setOptions,
getOptions,
create,
get,
info,
status,
}

View File

@ -21,7 +21,7 @@ export function parseURL(value: string, _: URL): URL {
}
export function parseNumber(value: string, _: number): number {
const n = parseInt(value, 10)
if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
const n = Number.parseInt(value, 10)
if (Number.isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
return n
}

View File

@ -18,6 +18,7 @@ export function getStdin(timeout: number = 10): Promise<string> {
resolve('')
}, timeout)
process.stdin.on('error', reject)
process.stdin.on('data', dataHandler)
process.stdin.on('end', endHandler)
})

View File

@ -1,5 +1,5 @@
import { status } from '@cryptgeon/shared'
import { exit as exitNode } from 'node:process'
import { API } from '../shared/api.js'
export function exit(message: string) {
console.error(message)
@ -11,7 +11,7 @@ export async function checkConstrains(constrains: { views?: number; minutes?: nu
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
if (!views && !minutes) constrains.views = 1
const response = await status()
const response = await API.status()
if (views && views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
if (minutes && minutes > response.max_expiration)

View File

@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "node",
"moduleResolution": "Bundler",
"declaration": true,
"emitDeclarationOnly": true,
"strict": true,

View File

@ -25,7 +25,7 @@
"new_note": "new note",
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
"errors": {
"note_to_big": "could not create note. note is to big",
"note_to_big": "could not create note. note is too big",
"note_error": "could not create note. please try again.",
"max": "max: {n}",
"empty_content": "note is empty."
@ -44,7 +44,7 @@
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.",
"unsupported_type": "unsupported note type."
},
"explanation": "click below to show and delete the note if the counter has reached it's limit",
"explanation": "click below to show and delete the note if the counter has reached its limit",
"show_note": "show note",
"warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.",
"download_all": "download all",

View File

@ -23,7 +23,7 @@
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement chiffrés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.",
"explanation": "la note expirera et sera détruite après {type}.",
"new_note": "nouvelle note",
"new_note_notice": "<b>disponibilité :</b><br />la note n'est pas garantie d'être stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)",
"new_note_notice": "<b>disponibilité :</b><br />il n'est pas garanti que la note reste stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)",
"errors": {
"note_to_big": "Impossible de créer une note. La note est trop grande.",
"note_error": "n'a pas pu créer de note. Veuillez réessayer.",
@ -45,9 +45,9 @@
"unsupported_type": "type de note non supporté."
},
"explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.",
"show_note": "note de présentation",
"show_note": "afficher la note",
"warning_will_not_see_again": "vous <b>n'aurez pas</b> la chance de revoir la note.",
"download_all": "télécharger tout",
"download_all": "tout télécharger",
"links_found": "liens trouvés à lintérieur de la note :"
},
"file_upload": {

View File

@ -0,0 +1,58 @@
{
"common": {
"note": "筆記",
"file": "檔案",
"advanced": "進階",
"create": "創建",
"loading": "載入中",
"mode": "模式",
"views": "{n, plural, =0 {瀏覽次數} =1 {1 次瀏覽} other {# 次瀏覽}}",
"minutes": "{n, plural, =0 {分鐘} =1 {1 分鐘} other {# 分鐘}}",
"max": "最大",
"share_link": "分享連結",
"copy_clipboard": "複製到剪貼板",
"copied_to_clipboard": "已複製到剪貼板",
"encrypting": "加密中",
"decrypting": "解密中",
"uploading": "上傳中",
"downloading": "下載中",
"qr_code": "QR 碼",
"password": "密碼"
},
"home": {
"intro": "輕鬆地以一鍵傳送<i>完全加密</i>的安全筆記或檔案。只需創建筆記並分享連結。",
"explanation": "筆記將在 {type} 後過期並被銷毀。",
"new_note": "新筆記",
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
"errors": {
"note_to_big": "無法創建筆記。筆記過大",
"note_error": "無法創建筆記。請再試一次。",
"max": "最大值:{n}",
"empty_content": "筆記內容為空。"
},
"messages": {
"note_created": "筆記已創建。"
},
"advanced": {
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
"custom_password": "自定義密碼"
}
},
"show": {
"errors": {
"not_found": "筆記未找到或已被刪除。",
"decryption_failed": "密碼錯誤。無法解密。可能是連結已損壞。筆記已被銷毀。",
"unsupported_type": "不支持的筆記類型。"
},
"explanation": "如果計數器達到限制,請點擊下方以顯示並刪除筆記",
"show_note": "顯示筆記",
"warning_will_not_see_again": "您將<b>無法</b>再次查看筆記。",
"download_all": "全部下載",
"links_found": "在筆記中找到的連結:"
},
"file_upload": {
"selected_files": "已選擇的檔案",
"no_files_selected": "未選擇檔案",
"clear": "重置"
}
}

View File

@ -13,27 +13,24 @@
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^12.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@types/file-saver": "^2.0.7",
"@zerodevx/svelte-toast": "^0.9.5",
"adm-zip": "^0.5.10",
"dotenv": "^16.4.5",
"svelte": "^4.2.12",
"svelte-check": "^3.6.6",
"@lokalise/node-api": "^13.2.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.17.3",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@zerodevx/svelte-toast": "^0.9.6",
"adm-zip": "^0.5.16",
"dotenv": "^16.4.7",
"svelte": "^5.20.5",
"svelte-check": "^4.1.4",
"svelte-intl-precompile": "^0.12.3",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.7"
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vite": "^6.2.0"
},
"dependencies": {
"@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^5.0.8",
"copy-to-clipboard": "^3.3.3",
"file-saver": "^2.0.5",
"occulto": "^2.0.3",
"@fontsource/fira-mono": "^5.1.1",
"cryptgeon": "workspace:*",
"occulto": "^2.0.6",
"pretty-bytes": "^6.1.1",
"qrious": "^4.0.2"
}

View File

@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><title>Contrast</title><path
d="M256 32C132.29 32 32 132.29 32 256s100.29 224 224 224 224-100.29 224-224S379.71 32 256 32zM128.72 383.28A180 180 0 01256 76v360a178.82 178.82 0 01-127.28-52.72z"

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 316 B

View File

@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><title>Copy</title><path
d="M456 480H136a24 24 0 01-24-24V128a16 16 0 0116-16h328a24 24 0 0124 24v320a24 24 0 01-24 24z"

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 354 B

View File

@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><title>Dice</title><path
d="M48 366.92L240 480V284L48 170zM192 288c8.84 0 16 10.75 16 24s-7.16 24-16 24-16-10.75-16-24 7.16-24 16-24zm-96 32c8.84 0 16 10.75 16 24s-7.16 24-16 24-16-10.75-16-24 7.16-24 16-24zM272 284v196l192-113.08V170zm48 140c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm0-88c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm96 32c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm0-88c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm32 77.64zM256 32L64 144l192 112 192-112zm0 120c-13.25 0-24-7.16-24-16s10.75-16 24-16 24 7.16 24 16-10.75 16-24 16z"

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 765 B

View File

@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><title>Eye</title><circle cx="256" cy="256" r="64" /><path
d="M394.82 141.18C351.1 111.2 304.31 96 255.76 96c-43.69 0-86.28 13-126.59 38.48C88.52 160.23 48.67 207 16 256c26.42 44 62.56 89.24 100.2 115.18C159.38 400.92 206.33 416 255.76 416c49 0 95.85-15.07 139.3-44.79C433.31 345 469.71 299.82 496 256c-26.38-43.43-62.9-88.56-101.18-114.82zM256 352a96 96 0 1196-96 96.11 96.11 0 01-96 96z"

Before

Width:  |  Height:  |  Size: 483 B

After

Width:  |  Height:  |  Size: 512 B

View File

@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><title>Eye Off</title><path
d="M63.998 86.004l21.998-21.998L448 426.01l-21.998 21.998zM259.34 192.09l60.57 60.57a64.07 64.07 0 00-60.57-60.57zM252.66 319.91l-60.57-60.57a64.07 64.07 0 0060.57 60.57z"

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 761 B

View File

@ -1,8 +1,8 @@
import { status as getStatus, type Status } from '@cryptgeon/shared'
import { API, type Status } from 'cryptgeon/shared'
import { writable } from 'svelte/store'
export const status = writable<null | Status>(null)
export async function init() {
status.set(await getStatus())
status.set(await API.status())
}

View File

@ -1,11 +1,11 @@
import { toast, type SvelteToastOptions } from '@zerodevx/svelte-toast'
import { toast } from '@zerodevx/svelte-toast'
export enum NotifyType {
Success = 'success',
Error = 'error',
}
const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
const themeMapping: Record<NotifyType, Record<string, string>> = {
[NotifyType.Success]: {
'--toastBackground': 'var(--ui-clr-primary)',
'--toastBarBackground': 'var(--ui-clr-primary-alt)',
@ -17,7 +17,7 @@ const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
}
function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
const options: SvelteToastOptions = {
const options = {
duration: 5_000,
theme: {
...themeMapping[type],

View File

@ -1,10 +1,17 @@
<script lang="ts">
export let title: string
import type { Snippet } from 'svelte'
interface Props {
title: string
children?: Snippet
}
let { title, children }: Props = $props()
</script>
<p>
<b>{title}</b>
<slot />
{@render children?.()}
</p>
<style>

View File

@ -4,15 +4,25 @@
import { status } from '$lib/stores/status'
import Switch from '$lib/ui/Switch.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import type { Note } from '@cryptgeon/shared'
import type { Note } from 'cryptgeon/shared'
export let note: Note
export let timeExpiration = false
export let customPassword: string | null = null
interface Props {
note: Note
timeExpiration?: boolean
customPassword?: string | null
}
let hasCustomPassword = false
let {
note = $bindable(),
timeExpiration = $bindable(false),
customPassword = $bindable(null),
}: Props = $props()
$: if (!hasCustomPassword) customPassword = null
let hasCustomPassword = $state(false)
$effect(() => {
if (!hasCustomPassword) customPassword = null
})
</script>
<div class="flex col">

View File

@ -1,4 +1,14 @@
<button {...$$restProps} on:click><slot /></button>
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements'
interface Props {
children?: import('svelte').Snippet
}
let { children, ...rest }: HTMLButtonAttributes & Props = $props()
</script>
<button {...rest}>{@render children?.()}</button>
<style>
button {

View File

@ -5,11 +5,15 @@
import { getCSSVariable } from '$lib/utils'
export let value: string
interface Props {
value: string
}
let canvas: HTMLCanvasElement
let { value }: Props = $props()
$: {
let canvas: HTMLCanvasElement | null = $state(null)
$effect(() => {
new QR({
value,
level: 'Q',
@ -18,12 +22,12 @@
foreground: getCSSVariable('--ui-text-0'),
element: canvas,
})
}
})
</script>
<small>{$t('common.qr_code')}</small>
<div>
<canvas bind:this={canvas} />
<canvas bind:this={canvas}></canvas>
</div>
<style>

View File

@ -3,10 +3,15 @@
import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from '@cryptgeon/shared'
import type { FileDTO } from 'cryptgeon/shared'
export let label: string = ''
export let files: FileDTO[] = []
interface Props {
label?: string
files?: FileDTO[]
[key: string]: any
}
let { label = '', files = $bindable([]), ...rest }: Props = $props()
async function fileToDTO(file: File): Promise<FileDTO> {
return {
@ -35,7 +40,7 @@
<small>
{label}
</small>
<input {...$$restProps} type="file" on:change={onInput} multiple />
<input {...rest} type="file" onchange={onInput} multiple />
<div class="box">
{#if files.length}
<div>
@ -45,8 +50,8 @@
{file.name}
</div>
{/each}
<div class="spacer" />
<Button on:click={clear}>{$t('file_upload.clear')}</Button>
<div class="spacer"></div>
<Button onclick={clear}>{$t('file_upload.clear')}</Button>
</div>
{:else}
<div>

View File

@ -1,9 +1,10 @@
<script lang="ts" context="module">
<script lang="ts" module>
import IconContrast from '$lib/icons/IconContrast.svelte'
import IconCopy from '$lib/icons/IconCopy.svelte'
import IconDice from '$lib/icons/IconDice.svelte'
import IconEye from '$lib/icons/IconEye.svelte'
import IconEyeOff from '$lib/icons/IconEyeOff.svelte'
import type { HTMLButtonAttributes } from 'svelte/elements'
const map = {
contrast: IconContrast,
@ -15,22 +16,27 @@
</script>
<script lang="ts">
export let icon: keyof typeof map
interface Props {
icon: keyof typeof map
}
let { icon, ...rest }: HTMLButtonAttributes & Props = $props()
</script>
<div on:click {...$$restProps}>
<button type="button" {...rest}>
{#if map[icon]}
<svelte:component this={map[icon]} />
{@const SvelteComponent = map[icon]}
<SvelteComponent />
{/if}
</div>
</button>
<style>
div {
button {
display: inline-block;
contain: strict;
box-sizing: content-box;
}
div > :global(svg) {
button > :global(svg) {
display: block;
fill: currentColor;
}

View File

@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 813 B

View File

@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
export type NoteResult = {
id: string
password?: string
@ -12,9 +12,13 @@
import TextInput from '$lib/ui/TextInput.svelte'
import Canvas from './Canvas.svelte'
export let result: NoteResult
interface Props {
result: NoteResult
}
let url = `${window.location.origin}/note/${result.id}`
let { result }: Props = $props()
let url = $state(`${window.location.origin}/note/${result.id}`)
if (result.password) url += `#${result.password}`
function reset() {
@ -41,7 +45,7 @@
</p>
{/if}
<br />
<Button on:click={reset}>{$t('home.new_note')}</Button>
<Button onclick={reset}>{$t('home.new_note')}</Button>
<style>
div {

View File

@ -1,31 +1,35 @@
<script lang="ts" context="module">
<script lang="ts" module>
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
function saveAs(file: File) {
const url = window.URL.createObjectURL(file)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = file.name
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
a.remove()
}
</script>
<script lang="ts">
import pkg from 'file-saver'
const { saveAs } = pkg
import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile'
import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils'
import type { FileDTO, NotePublic } from '@cryptgeon/shared'
import type { FileDTO, NotePublic } from 'cryptgeon/shared'
export let note: DecryptedNote
interface Props {
note: DecryptedNote
}
let { note }: Props = $props()
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
let files: FileDTO[] = []
$: if (note.meta.type === 'file') {
files = note.contents
}
$: download = () => {
for (const file of files) {
downloadFile(file)
}
}
let files: FileDTO[] = $state([])
async function downloadFile(file: FileDTO) {
const f = new File([file.contents], file.name, {
@ -34,7 +38,17 @@
saveAs(f)
}
$: links = typeof note.contents === 'string' ? note.contents.match(RE_URL) : []
$effect(() => {
if (note.meta.type === 'file') {
files = note.contents
}
})
let download = $derived(() => {
for (const file of files) {
downloadFile(file)
}
})
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
</script>
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
@ -43,7 +57,7 @@
<div class="note">
{note.contents}
</div>
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
<Button onclick={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
{#if links && links.length}
<div class="links">
@ -60,13 +74,13 @@
{:else}
{#each files as file}
<div class="note file">
<button on:click={() => downloadFile(file)}>
<button onclick={() => downloadFile(file)}>
<b>{file.name}</b>
</button>
<small> {file.type} {prettyBytes(file.size)}</small>
</div>
{/each}
<Button on:click={download}>{$t('show.download_all')}</Button>
<Button onclick={download}>{$t('show.download_all')}</Button>
{/if}
</div>

View File

@ -1,13 +1,18 @@
<script lang="ts">
export let label: string = ''
export let value: boolean
export let color = true
interface Props {
label?: string
value: boolean
color?: boolean
[key: string]: any
}
let { label = '', value = $bindable(), color = true, ...rest }: Props = $props()
</script>
<label {...$$restProps}>
<label {...rest}>
<small>{label}</small>
<input type="checkbox" bind:checked={value} />
<span class:color class="slider" />
<span class:color class="slider"></span>
</label>
<style>

View File

@ -1,11 +1,16 @@
<script lang="ts">
export let label: string = ''
export let value: string
interface Props {
label?: string
value: string
[key: string]: any
}
let { label = '', value = $bindable(), ...rest }: Props = $props()
</script>
<label>
<small>
{label}
</small>
<textarea class="box" {...$$restProps} bind:value />
<textarea class="box" {...rest} bind:value></textarea>
</label>

View File

@ -2,25 +2,34 @@
import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils'
import { getRandomBytes, Hex } from 'occulto'
import type { HTMLInputAttributes } from 'svelte/elements'
export let label: string = ''
export let value: any
export let validate: (value: any) => boolean | string = () => true
export let copy: boolean = false
export let random: boolean = false
const initialType = $$restProps.type
const isPassword = initialType === 'password'
let hidden = true
$: valid = validate(value)
$: if (isPassword) {
value
$$restProps.type = hidden ? initialType : 'text'
interface Props {
label?: string
value: any
validate?: (value: any) => boolean | string
copy?: boolean
random?: boolean
}
let {
label = '',
value = $bindable(),
validate = () => true,
copy = false,
random = false,
...rest
}: HTMLInputAttributes & Props = $props()
const initialType = $state(rest.type)
const isPassword = initialType === 'password'
let hidden = $state(true)
let valid = $derived(validate(value))
let type = $derived(isPassword ? (hidden ? 'password' : 'text') : rest.type)
function toggle() {
console.debug('toggle')
hidden = !hidden
}
@ -30,22 +39,32 @@
</script>
<label>
<small class:disabled={$$restProps.disabled}>
<small class:disabled={rest.disabled}>
{label}
{#if valid !== true}
<span class="error-text">{valid}</span>
{/if}
</small>
<input bind:value {...$$restProps} class:valid={valid === true} />
<input bind:value {...rest} {type} autocomplete="off" class:valid={valid === true} />
<div class="icons">
{#if isPassword}
<Icon class="icon" icon={hidden ? 'eye' : 'eye-off'} on:click={toggle} />
<Icon
disabled={rest.disabled}
class="icon"
icon={hidden ? 'eye' : 'eye-off'}
onclick={toggle}
/>
{/if}
{#if random}
<Icon class="icon" icon="dice" on:click={randomFN} />
<Icon disabled={rest.disabled} class="icon" icon="dice" onclick={randomFN} />
{/if}
{#if copy}
<Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} />
<Icon
disabled={rest.disabled}
class="icon"
icon="copy"
onclick={() => copyFN(value.toString())}
/>
{/if}
</div>
</label>

View File

@ -1,24 +1,21 @@
<script lang="ts" context="module">
<script lang="ts" module>
import { writable } from 'svelte/store'
enum Theme {
Dark = 'dark',
Light = 'light',
Auto = 'auto',
}
const themes = ['dark', 'light', 'auto'] as const
type Theme = (typeof themes)[number]
const NextTheme = {
[Theme.Auto]: Theme.Light,
[Theme.Light]: Theme.Dark,
[Theme.Dark]: Theme.Auto,
const NextTheme: Record<Theme, Theme> = {
auto: 'light',
light: 'dark',
dark: 'auto',
}
function init(): Theme {
if (typeof window !== 'undefined') {
const saved = window.localStorage.getItem('theme') as Theme
if (Object.values(Theme).includes(saved)) return saved
if (themes.includes(saved)) return saved
}
return Theme.Auto
return 'auto'
}
export const theme = writable<Theme>(init())
@ -40,7 +37,7 @@
}
</script>
<button on:click={change}>
<button onclick={change}>
<Icon class="icon" icon="contrast" />
{$theme}
</button>

View File

@ -1,11 +1,10 @@
import copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile'
import { get } from 'svelte/store'
import { notify } from './toast'
export function copy(value: string) {
copyToClipboard(value)
window.navigator.clipboard.writeText(value)
const msg = get(t)('common.copied_to_clipboard')
notify.success(msg)
}

View File

@ -13,30 +13,31 @@
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte'
import type { FileDTO, Note } from '@cryptgeon/shared'
import { Adapters, PayloadToLargeError, create } from '@cryptgeon/shared'
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared'
let note: Note = {
let note: Note = $state({
contents: '',
meta: { type: 'text' },
views: 1,
expiration: 60,
}
let files: FileDTO[]
let result: NoteResult | null = null
let advanced = false
let isFile = false
let timeExpiration = false
let customPassword: string | null = null
let description = ''
let loading: string | null = null
})
let files: FileDTO[] = $state([])
let result: NoteResult | null = $state(null)
let advanced = $state(false)
let isFile = $state(false)
let timeExpiration = $state(false)
let customPassword: string | null = $state(null)
let description = $state('')
let loading: string | null = $state(null)
$: if (!advanced) {
note.views = 1
timeExpiration = false
}
$effect(() => {
if (!advanced) {
note.views = 1
timeExpiration = false
}
})
$: {
$effect(() => {
description = $t('home.explanation', {
values: {
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
@ -44,17 +45,22 @@
}),
},
})
}
})
$: note.meta.type = isFile ? 'file' : 'text'
$effect(() => {
note.meta.type = isFile ? 'file' : 'text'
})
$: if (!isFile) {
note.contents = ''
}
$effect(() => {
if (!isFile) {
note.contents = ''
}
})
class EmptyContentError extends Error {}
async function submit() {
async function submit(e: SubmitEvent) {
e.preventDefault()
try {
loading = $t('common.encrypting')
@ -77,7 +83,7 @@
else data.views = parseInt(note.views as any)
loading = $t('common.uploading')
const response = await create(data)
const response = await API.create(data)
result = {
id: response.id,
password: customPassword ? undefined : Hex.encode(key),
@ -104,7 +110,7 @@
<p>
{@html $status?.theme_text || $t('home.intro')}
</p>
<form on:submit|preventDefault={submit}>
<form onsubmit={submit}>
<fieldset disabled={loading !== null}>
{#if isFile}
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
@ -133,7 +139,7 @@
bind:value={advanced}
/>
{/if}
<div class="grow" />
<div class="grow"></div>
<div class="tr">
<small>{$t('common.max')}: <MaxSize /> </small>
<br />

View File

@ -1,5 +1,6 @@
<script lang="ts">
import ThemeToggle from '$lib/ui/ThemeToggle.svelte'
import { status } from '$lib/stores/status'
</script>
<footer>
@ -7,6 +8,11 @@
<nav>
<a href="/">/home</a>
<a href="/about">/about</a>
{#if $status?.imprint_url}
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
{:else if $status?.imprint_html}
<a href="/imprint">/imprint</a>
{/if}
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
code
</a>

View File

@ -7,7 +7,7 @@
</script>
<header>
<a on:click={reset} href="/">
<a onclick={reset} href="/">
{#if $status?.theme_image}
<img alt="logo" src={$status.theme_image} />
{:else}

View File

@ -8,6 +8,11 @@
import { init as initStores, status } from '$lib/stores/status'
import Footer from '$lib/views/Footer.svelte'
import Header from '$lib/views/Header.svelte'
interface Props {
children?: import('svelte').Snippet
}
let { children }: Props = $props()
onMount(() => {
initStores()
@ -22,7 +27,7 @@
{#await waitLocale() then _}
<main>
<Header />
<slot />
{@render children?.()}
</main>
<SvelteToast />

View File

@ -0,0 +1,33 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { status } from '$lib/stores/status'
status.subscribe((config) => {
if (config != null) {
if (config.imprint_url) {
window.location.href = config.imprint_url
} else if (config.imprint_html == '') {
goto('/about')
}
}
})
</script>
<svelte:head>
<title>Imprint</title>
</svelte:head>
<section class="content">
{#if $status?.imprint_html}
{@html $status.imprint_html}
{/if}
</section>
<style>
section {
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
</style>

View File

@ -7,28 +7,32 @@
import Loader from '$lib/ui/Loader.svelte'
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import { Adapters, get, info, type NoteMeta } from '@cryptgeon/shared'
import { Adapters, API, type NoteMeta } from 'cryptgeon/shared'
import type { PageData } from './$types'
export let data: PageData
interface Props {
data: PageData
}
let { data }: Props = $props()
let id = data.id
let password: string | null = null
let note: DecryptedNote | null = null
let exists = false
let meta: NoteMeta | null = null
let password: string | null = $state<string | null>(null)
let note: DecryptedNote | null = $state(null)
let exists = $state(false)
let meta: NoteMeta | null = $state(null)
let loading: string | null = null
let error: string | null = null
let loading: string | null = $state(null)
let error: string | null = $state(null)
$: valid = !!password?.length
let valid = $derived(!!password?.length)
onMount(async () => {
// Check if note exists
try {
loading = $t('common.loading')
password = window.location.hash.slice(1)
const note = await info(id)
const note = await API.info(id)
meta = note.meta
exists = true
} catch {
@ -41,7 +45,8 @@
/**
* Get the actual contents of the note and decrypt it.
*/
async function show() {
async function show(e: SubmitEvent) {
e.preventDefault()
try {
if (!valid) {
error = $t('show.errors.no_password')
@ -51,7 +56,7 @@
// Load note
error = null
loading = $t('common.downloading')
const data = await get(id)
const data = await API.get(id)
loading = $t('common.decrypting')
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
const key = derived ? derived[0] : Hex.decode(password!)
@ -86,7 +91,7 @@
{:else if note && !error}
<ShowNote {note} />
{:else}
<form on:submit|preventDefault={show}>
<form onsubmit={show}>
<fieldset>
<p>{$t('show.explanation')}</p>
{#if meta?.derivation}

View File

@ -1,12 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
const port = 8001
const port = 3000
/** @type {import('vite').UserConfig} */
const config = {
clearScreen: false,
server: { port },
server: {
port,
proxy: {
'/api': 'http://localhost:8000',
},
},
preview: { port },
plugins: [sveltekit(), precompileIntl('locales')],
}

View File

@ -1,12 +0,0 @@
{
"private": true,
"name": "@cryptgeon/proxy",
"type": "module",
"main": "./proxy.js",
"scripts": {
"dev": "node ."
},
"dependencies": {
"http-proxy": "^1.18.1"
}
}

View File

@ -1,16 +0,0 @@
import http from 'http'
import httpProxy from 'http-proxy'
const proxy = httpProxy.createProxyServer()
proxy.on('error', function (err, req, res) {
console.error(err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('500 Internal Server Error')
})
const server = http.createServer(function (req, res) {
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
proxy.web(req, res, { target, proxyTimeout: 250, timeout: 250 })
})
server.listen(1234)
console.log('Proxy on http://localhost:1234')

View File

@ -1,22 +0,0 @@
{
"private": true,
"name": "@cryptgeon/shared",
"type": "module",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"dev": "tsc -w",
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"dependencies": {
"occulto": "^2.0.3"
}
}

View File

@ -1,12 +0,0 @@
{
"compilerOptions": {
"incremental": true,
"composite": true,
"target": "es2022",
"module": "es2022",
"rootDir": "./src",
"moduleResolution": "node",
"outDir": "./dist",
"strict": true
}
}

View File

@ -3,17 +3,19 @@ import { devices, type PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
use: {
video: 'retain-on-failure',
baseURL: 'http://localhost:1234',
actionTimeout: 60_000,
baseURL: 'http://localhost:3000',
actionTimeout: 30_000,
},
outputDir: './test-results',
testDir: './test',
timeout: 60_000,
timeout: 30_000,
fullyParallel: true,
retries: 2,
webServer: {
command: 'docker compose -f docker-compose.dev.yaml up',
port: 1234,
command: 'pnpm run docker:up',
port: 3000,
reuseExistingServer: true,
},
@ -21,10 +23,6 @@ const config: PlaywrightTestConfig = {
{ name: 'chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'safari', use: { ...devices['Desktop Safari'] } },
{ name: 'cli', use: { ...devices['Desktop Chrome'] }, grep: [/@cli/] },
{ name: 'web', use: { ...devices['Desktop Chrome'] }, grep: [/@web/] },
{ name: 'cross', use: { ...devices['Desktop Chrome'] }, grep: [/@cross/] },
],
}

5111
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,11 @@
import { test } from '@playwright/test'
import { basename } from 'node:path'
import { Files, getFileChecksum, rm, tmpFile } from '../../files'
import { rm } from 'node:fs/promises'
import { Files, getFileChecksum, tmpFile } from '../../files'
import { CLI, getLinkFromCLI } from '../../utils'
test.describe('file @cli', () => {
test('simple', async ({ page }) => {
test('simple', async () => {
const file = await tmpFile(Files.Image)
const checksum = await getFileChecksum(file)
const note = await CLI('send', 'file', file)
@ -17,7 +18,7 @@ test.describe('file @cli', () => {
test.expect(checksum).toBe(c)
})
test('simple with password', async ({ page }) => {
test('simple with password', async () => {
const file = await tmpFile(Files.Image)
const password = 'password'
const checksum = await getFileChecksum(file)

View File

@ -2,7 +2,7 @@ import { test } from '@playwright/test'
import { CLI, getLinkFromCLI } from '../../utils'
test.describe('text @cli', () => {
test('simple', async ({ page }) => {
test('simple', async () => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const note = await CLI('send', 'text', text)
const link = getLinkFromCLI(note.stdout)
@ -11,7 +11,7 @@ test.describe('text @cli', () => {
test.expect(retrieved.stdout.trim()).toBe(text)
})
test('simple with password', async ({ page }) => {
test('simple with password', async () => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying.`
const password = 'password'
const note = await CLI('send', 'text', text, '--password', password)

View File

@ -1,7 +1,8 @@
import { test } from '@playwright/test'
import { CLI, checkLinkForDownload, checkLinkForText, createNote, getLinkFromCLI } from '../../utils'
import { Files, getFileChecksum, rm, tmpFile } from '../../files'
import { basename } from 'path'
import { rm } from 'node:fs/promises'
import { basename } from 'node:path'
import { Files, getFileChecksum, tmpFile } from '../../files'
import { CLI, checkLinkForDownload, createNoteSuccessfully, getLinkFromCLI } from '../../utils'
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const password = 'password'
@ -30,7 +31,7 @@ test.describe('text @cross', () => {
test('web to cli', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
const link = await createNoteSuccessfully(page, { files })
const filename = basename(files[0])
await CLI('open', link, '--all')
@ -42,7 +43,7 @@ test.describe('text @cross', () => {
test('web to cli with password', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files, password })
const link = await createNoteSuccessfully(page, { files, password })
const filename = basename(files[0])
await CLI('open', link, '--all', '--password', password)

View File

@ -1,5 +1,5 @@
import { test } from '@playwright/test'
import { CLI, checkLinkForText, createNote, getLinkFromCLI } from '../../utils'
import { CLI, checkLinkForText, createNoteSuccessfully, getLinkFromCLI } from '../../utils'
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const password = 'password'
@ -13,7 +13,7 @@ test.describe('text @cross', () => {
})
test('web to cli', async ({ page }) => {
const link = await createNote(page, { text })
const link = await createNoteSuccessfully(page, { text })
const retrieved = await CLI('open', link)
test.expect(retrieved.stdout.trim()).toBe(text)
})
@ -25,7 +25,7 @@ test.describe('text @cross', () => {
})
test('web to cli with password', async ({ page }) => {
const link = await createNote(page, { text, password })
const link = await createNoteSuccessfully(page, { text, password })
const retrieved = await CLI('open', link, '--password', password)
test.expect(retrieved.stdout.trim()).toBe(text)
})

View File

@ -1,10 +1,5 @@
import { createHash } from 'crypto'
import { cp as cpFN, rm as rmFN } from 'fs'
import { readFile } from 'fs/promises'
import { promisify } from 'util'
export const cp = promisify(cpFN)
export const rm = promisify(rmFN)
import { createHash } from 'node:crypto'
import { cp, readFile } from 'node:fs/promises'
export const Files = {
PDF: 'test/assets/AES.pdf',

View File

@ -6,17 +6,22 @@ import { getFileChecksum } from './files'
const exec = promisify(execFile)
type CreatePage = {
text?: string
files?: string[]
views?: number
expiration?: number
error?: string
password?: string
}
export async function createNote(page: Page, options: CreatePage): Promise<string> {
} & (
| {
text: string
}
| {
files: string[]
}
)
async function createNote(page: Page, options: CreatePage): Promise<void> {
await page.goto('/')
if (options.text) {
if ('text' in options) {
await page.getByTestId('text-field').fill(options.text)
} else if (options.files) {
await page.getByTestId('switch-file').click()
@ -31,7 +36,8 @@ export async function createNote(page: Page, options: CreatePage): Promise<strin
if (options.views || options.expiration || options.password) await page.getByTestId('switch-advanced').click()
if (options.views) {
await page.getByTestId('field-views').fill(options.views.toString())
} else if (options.expiration) {
}
if (options.expiration) {
await page.getByTestId('switch-advanced-toggle').click()
await page.getByTestId('field-expiration').fill(options.expiration.toString())
}
@ -41,15 +47,18 @@ export async function createNote(page: Page, options: CreatePage): Promise<strin
}
await page.locator('button:has-text("create")').click()
}
if (options.error) {
await expect(page.locator('.error-text')).toContainText(options.error, { timeout: 60_000 })
}
// Return share link
export async function createNoteSuccessfully(page: Page, options: CreatePage): Promise<string> {
await createNote(page, options)
return await page.getByTestId('share-link').inputValue()
}
export async function createNoteError(page: Page, options: CreatePage, error: string): Promise<void> {
await createNote(page, options)
await expect(page.locator('._toastContainer')).toContainText(error)
}
type CheckLinkBase = {
link: string
text: string
@ -69,7 +78,7 @@ export async function checkLinkForDownload(page: Page, options: CheckLinkBase &
const path = await download.path()
if (!path) throw new Error('Download failed')
const cs = await getFileChecksum(path)
await expect(cs).toBe(options.checksum)
expect(cs).toBe(options.checksum)
}
export async function checkLinkForText(page: Page, options: CheckLinkBase) {
@ -78,7 +87,7 @@ export async function checkLinkForText(page: Page, options: CheckLinkBase) {
if (options.password) await page.getByTestId('show-note-password').fill(options.password)
await page.getByTestId('show-note-button').click()
const text = await page.getByTestId('result').locator('.note').innerText()
await expect(text).toContain(options.text)
expect(text).toContain(options.text)
}
export async function checkLinkDoesNotExist(page: Page, link: string) {
@ -91,7 +100,7 @@ export async function CLI(...args: string[]) {
return await exec('./packages/cli/dist/cli.cjs', args, {
env: {
...process.env,
CRYPTGEON_SERVER: 'http://localhost:1234',
CRYPTGEON_SERVER: 'http://localhost:3000',
},
})
}

View File

@ -1,12 +1,12 @@
import { test } from '@playwright/test'
import { Files, getFileChecksum } from '../../files'
import { checkLinkForDownload, createNote } from '../../utils'
import { checkLinkForDownload, createNoteSuccessfully } from '../../utils'
test.describe('@web', () => {
test('multiple', async ({ page }) => {
const files = [Files.PDF, Files.Image]
const checksums = await Promise.all(files.map(getFileChecksum))
const link = await createNote(page, { files, views: 2 })
const link = await createNoteSuccessfully(page, { files, views: 2 })
await checkLinkForDownload(page, { link, text: 'image.jpg', checksum: checksums[1] })
await checkLinkForDownload(page, { link, text: 'AES.pdf', checksum: checksums[0] })
})

View File

@ -1,11 +1,11 @@
import { test } from '@playwright/test'
import { Files, getFileChecksum } from '../../files'
import { checkLinkDoesNotExist, checkLinkForDownload, checkLinkForText, createNote } from '../../utils'
import { checkLinkDoesNotExist, checkLinkForDownload, checkLinkForText, createNoteSuccessfully } from '../../utils'
test.describe('@web', () => {
test('simple pdf', async ({ page }) => {
const files = [Files.PDF]
const link = await createNote(page, { files })
const link = await createNoteSuccessfully(page, { files })
await checkLinkForText(page, { link, text: 'AES.pdf' })
await checkLinkDoesNotExist(page, link)
})
@ -13,21 +13,21 @@ test.describe('@web', () => {
test('pdf content', async ({ page }) => {
const files = [Files.PDF]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
const link = await createNoteSuccessfully(page, { files })
await checkLinkForDownload(page, { link, text: 'AES.pdf', checksum })
})
test('image content', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
const link = await createNoteSuccessfully(page, { files })
await checkLinkForDownload(page, { link, text: 'image.jpg', checksum })
})
test('simple pdf with password', async ({ page }) => {
const files = [Files.PDF]
const password = 'password'
const link = await createNote(page, { files, password })
const link = await createNoteSuccessfully(page, { files, password })
await checkLinkForText(page, { link, text: 'AES.pdf', password })
await checkLinkDoesNotExist(page, link)
})

View File

@ -1,10 +1,10 @@
import { test } from '@playwright/test'
import { createNote } from '../../utils'
import { Files } from '../../files'
import { createNoteError } from '../../utils'
test.describe('@web', () => {
test.skip('to big zip', async ({ page }) => {
const files = [Files.Zip]
const link = await createNote(page, { files, error: 'note is to big' })
await createNoteError(page, { files }, 'could not create note. note is too big')
})
})

View File

@ -1,13 +1,14 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../../utils'
import { checkLinkDoesNotExist, checkLinkForText, createNoteSuccessfully } from '../../utils'
test.describe('@web', () => {
test('1 minute', async ({ page }) => {
test('1 minute', async ({ page, browserName }) => {
test.skip(browserName === 'webkit')
const text = `Virtues value ascetic revaluation sea dead strong burying.`
const minutes = 1
const timeout = minutes * 60_000
test.setTimeout(timeout * 2)
const link = await createNote(page, { text, expiration: minutes })
const link = await createNoteSuccessfully(page, { text, expiration: minutes })
await checkLinkForText(page, { link, text })
await checkLinkForText(page, { link, text })
await page.waitForTimeout(timeout)

View File

@ -1,17 +1,17 @@
import { test } from '@playwright/test'
import { checkLinkForText, createNote } from '../../utils'
import { checkLinkForText, createNoteSuccessfully } from '../../utils'
test.describe('@web', () => {
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of deceive play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const link = await createNote(page, { text })
const link = await createNoteSuccessfully(page, { text })
await checkLinkForText(page, { link, text })
})
test('simple with password', async ({ page }) => {
const text = 'Foo bar'
const password = '123'
const shareLink = await createNote(page, { text, password })
const shareLink = await createNoteSuccessfully(page, { text, password })
await checkLinkForText(page, { link: shareLink, text, password })
})
})

View File

@ -1,17 +1,17 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../../utils'
import { checkLinkDoesNotExist, checkLinkForText, createNoteSuccessfully } from '../../utils'
test.describe('@web', () => {
test('only shown once', async ({ page }) => {
const text = `Christian victorious reason suicide dead. Right ultimate gains god hope truth burying selfish society joy. Ultimate.`
const link = await createNote(page, { text })
const link = await createNoteSuccessfully(page, { text })
await checkLinkForText(page, { link, text })
await checkLinkDoesNotExist(page, link)
})
test('view 3 times', async ({ page }) => {
const text = `Justice holiest overcome fearful strong ultimate holiest christianity.`
const link = await createNote(page, { text, views: 3 })
const link = await createNoteSuccessfully(page, { text, views: 3 })
await checkLinkForText(page, { link, text })
await checkLinkForText(page, { link, text })
await checkLinkForText(page, { link, text })