Compare commits

...
64 changed files with 3537 additions and 3319 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

+6 -9
View File
@@ -10,32 +10,29 @@ jobs:
cli: cli:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v4 - uses: actions/setup-node@v7
with: with:
cache: 'pnpm' cache: 'pnpm'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
- run: | - run: |
pnpm install --frozen-lockfile pnpm install
pnpm run build pnpm --filter cryptgeon build
- run: npm publish - run: pnpm publish --filter cryptgeon
working-directory: ./packages/cli
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4 - uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
with:
install: true
- name: Docker Labels - name: Docker Labels
id: meta id: meta
uses: docker/metadata-action@v6 uses: docker/metadata-action@v6
+9 -6
View File
@@ -10,11 +10,11 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
# Node # Node
- uses: pnpm/action-setup@v6 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v4 - uses: actions/setup-node@v7
with: with:
cache: 'pnpm' cache: 'pnpm'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -22,19 +22,22 @@ jobs:
# Docker # Docker
- uses: docker/setup-qemu-action@v4 - uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
with:
install: true
- name: Prepare - name: Prepare
run: | run: |
pnpm install pnpm install
pnpm exec playwright install --with-deps pnpm exec playwright install --with-deps
pnpm run test:prepare pnpm run test:prepare
- name: Rust tests
run: cargo test --manifest-path packages/backend/Cargo.toml
- name: Shared tests
run: pnpm --filter @cryptgeon/shared test
- name: Run your tests - name: Run your tests
run: pnpm test run: pnpm test
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v7
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
with: with:
name: playwright-report name: playwright-report
+1 -1
View File
@@ -1 +1 @@
v24 v26
+2 -2
View File
@@ -3,7 +3,7 @@
## Requirements ## Requirements
- [mise](https://mise.jdx.dev) — manages pnpm, rust, node (see `mise.toml`) - [mise](https://mise.jdx.dev) — manages pnpm, rust, node (see `mise.toml`)
- docker or [colima](https://github.com/abiosoft/colima) (for redis) - docker or [colima](https://github.com/abiosoft/colima) (for cache)
## Setup ## Setup
@@ -18,7 +18,7 @@ pnpm install
pnpm run dev pnpm run dev
``` ```
Make sure docker/colima is running. This starts redis, the rust backend, the web client, and the CLI. The app is at [localhost:3000](http://localhost:3000). Make sure docker/colima is running. This starts the cache (valkey/redis), the rust backend, the web client, and the CLI. The app is at [localhost:3000](http://localhost:3000).
## Tests ## Tests
+1 -1
View File
@@ -25,6 +25,6 @@ RUN apk add --no-cache curl libgcc
COPY --from=backend /tmp/target/release/cryptgeon . COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/packages/frontend/build ./frontend COPY --from=client /tmp/packages/frontend/build ./frontend
ENV FRONTEND_PATH="./frontend" ENV FRONTEND_PATH="./frontend"
ENV REDIS="redis://redis/" ENV CACHE="redis://cache/"
EXPOSE 8000 EXPOSE 8000
ENTRYPOINT [ "/app/cryptgeon" ] ENTRYPOINT [ "/app/cryptgeon" ]
+9 -12
View File
@@ -11,7 +11,6 @@
<br/><br/> <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="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> <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/> <br/><br/>
@@ -23,8 +22,6 @@ _cryptgeon_ is a secure, open source sharing note or file service inspired by [_
It includes a server, a web page and a CLI client. It includes a server, a web page and a CLI client.
> 🌍 If you want to translate the project feel free to reach out to me. > 🌍 If you want to translate the project feel free to reach out to me.
>
> Thanks to [Lokalise](https://lokalise.com/) for providing free access to their platform.
## Live Service / Demo ## Live Service / Demo
@@ -63,7 +60,7 @@ 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 never persisted to disk. the server never sees the encryption key and cannot decrypt the contents
of the notes even if it tried to. 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. > View counts are guaranteed with one running instance of cryptgeon. Multiple instances connected to the same cache instance can run into race conditions, where a note might be retrieved more than the view count allows.
## Screenshot ## Screenshot
@@ -73,14 +70,14 @@ of the notes even if it tried to.
| Variable | Default | Description | | Variable | Default | Description |
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) | | `CACHE` | `redis://cache/` | Cache URL (valkey or redis) to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. | | `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
| `MAX_VIEWS` | `100` | Maximal number of views. | | `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. | | `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_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. | | `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. |
| `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_. | | `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_. |
| `REDIS_PREFIX` | `""` | Optional prefix for all Redis keys. Useful when sharing a Redis instance with other apps via ACL namespaces. | | `CACHE_PREFIX` | `""` | Optional prefix for all cache keys. Useful when sharing a cache instance with other apps via ACL namespaces. |
| `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` | | `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_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo | | `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
@@ -107,12 +104,12 @@ Docker is the easiest way. There is the [official image here](https://hub.docker
version: "3.8" version: "3.8"
services: services:
redis: cache:
image: redis:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: redis-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
# Set a size limit. See link below on how to customise. # Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/ # https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine # --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume. # This prevents the creation of an anonymous volume.
tmpfs: tmpfs:
@@ -121,7 +118,7 @@ services:
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:latest
depends_on: depends_on:
- redis - cache
environment: environment:
# Size limit for a single note. # Size limit for a single note.
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
@@ -130,7 +127,7 @@ services:
# Optional health checks # Optional health checks
# healthcheck: # healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"] # test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
# interval: 1m # interval: 1m
# timeout: 3s # timeout: 3s
# retries: 2 # retries: 2
-3
View File
@@ -11,7 +11,6 @@
<br/><br/> <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="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">
<br/><br/> <br/><br/>
[EN](README.md) | [简体中文](README_zh-CN.md) | ES [EN](README.md) | [简体中文](README_zh-CN.md) | ES
@@ -22,8 +21,6 @@ _cryptgeon_ es un servicio seguro y de código abierto para compartir notas o ar
Incluye un servidor, una página web y una interfaz de línea de comandos (CLI, por sus siglas en inglés). Incluye un servidor, una página web y una interfaz de línea de comandos (CLI, por sus siglas en inglés).
> 🌍 Si quieres traducir este proyecto no dudes en ponerte en contacto conmigo. > 🌍 Si quieres traducir este proyecto no dudes en ponerte en contacto conmigo.
>
> Gracias a [Lokalise](https://lokalise.com/) por darnos acceso gratis a su plataforma.
## Demo ## Demo
-3
View File
@@ -11,7 +11,6 @@
<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="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">
<br/> <br/>
[EN](README.md) | 简体中文 | [ES](README_ES.md) [EN](README.md) | 简体中文 | [ES](README_ES.md)
@@ -21,8 +20,6 @@
_加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器 _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器
> 🌍 如果你想翻译此项目请随时与我联系. > 🌍 如果你想翻译此项目请随时与我联系.
>
> 感谢 [Lokalise](https://lokalise.com/) 提供免费的平台服务支持
## 演示示例 ## 演示示例
+3 -3
View File
@@ -2,7 +2,7 @@
# For a production file see: README.md # For a production file see: README.md
services: services:
redis: cache:
image: valkey/valkey:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
@@ -19,13 +19,13 @@ services:
build: . build: .
env_file: .env.dev env_file: .env.dev
depends_on: depends_on:
- redis - cache
restart: unless-stopped restart: unless-stopped
ports: ports:
- 3000:8000 - 3000:8000
healthcheck: healthcheck:
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/'] test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/healthz']
interval: 1m interval: 1m
timeout: 3s timeout: 3s
retries: 2 retries: 2
+3 -3
View File
@@ -1,5 +1,5 @@
services: services:
redis: cache:
image: valkey/valkey:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only. # This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no command: valkey-server --save "" --appendonly no
@@ -13,7 +13,7 @@ services:
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:latest
depends_on: depends_on:
- redis - cache
environment: environment:
# Size limit for a single note. # Size limit for a single note.
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
@@ -22,7 +22,7 @@ services:
# Optional health checks # Optional health checks
# healthcheck: # healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"] # test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
# interval: 1m # interval: 1m
# timeout: 3s # timeout: 3s
# retries: 2 # retries: 2
+6
View File
@@ -0,0 +1,6 @@
# Todo
- also update readmes in other languages
- use catalog install for common deps (typescript, vite, tsdown, etc. ) please suggest.
- move formatting, linting, type checking and git hooks to vite-plus (uses oxlint, oxfmt, vitest and git hook dispatcher)
- re-add CSP (Content-Security-Policy) into axum router ( (was in csp.rs, removed as unused)
+66
View File
@@ -0,0 +1,66 @@
# v3 Breaking Changes
A list of changes users and operators need to consider when upgrading from v2 to v3.
## API
- All note endpoints moved under `/api/v3/notes/` (was `/api/notes/`)
- Status endpoint moved to `/api/v3/status` (was `/api/status`)
- All request/response bodies are now **MessagePack** (`Content-Type: application/msgpack`), not JSON
- Health check moved to `/healthz` (was `/api/live`)
- Notes can now have **both** `views` and `expiration` set simultaneously (previously mutually exclusive)
## API payload structure
The wire format changed entirely. v2 used:
```json
{ "contents": "<encrypted string>", "meta": "<stringified JSON>", "views": 5, "expiration": 30 }
```
v3 uses msgpack:
```
{ meta: { views?, expiration?, extra? }, data: <encrypted bytes> }
```
- `meta.extra` holds client-opaque data (e.g. scrypt derivation params), size-limited (default 512 bytes)
- `data` is the encrypted blob — the server never inspects its contents
- The encrypted inner payload is itself msgpack: `{ type: "text", data: string }` or `{ type: "files", data: [{ name, mime, size, data }] }`
## Environment variables
| v2 | v3 |
| ------------------- | -------------------- |
| `REDIS` | `CACHE` |
| `REDIS_PREFIX` | `CACHE_PREFIX` |
| _(new)_ | `EXTRA_SIZE_LIMIT` |
The `CACHE` env var accepts any RESP-compatible URL (valkey or redis).
`EXTRA_SIZE_LIMIT` (default `512`) limits the `extra` field size in bytes.
## Docker / Compose
- The `redis` service in docker-compose is renamed to `cache`
- Healthcheck URL updated: `http://127.0.0.1:8000/api/live/` → `http://127.0.0.1:8000/healthz`
- The default image stays `valkey/valkey:7-alpine` but operators can swap for any redis-compatible image
## CLI (`cryptgeon` npm package)
- Dropped `occulto` dependency — now uses `@noble/ciphers` + `@noble/hashes` internally
- Encryption changed from AES to **XChaCha20-Poly1305**
- The local `shared/` module removed — now imports from `@cryptgeon/shared` (workspace-internal)
- Notes created with v2 (AES) are **not readable** by v3 and vice versa
## Frontend
- Dropped `occulto` dependency
- Package import changed from `cryptgeon/shared` to `@cryptgeon/shared`
- Notes created in v2 are not accessible from the v3 frontend
## Storage
- Cache storage format changed from JSON blobs to hashes with atomic `HINCRBY` for view counting
- The per-note lock (`lock.rs`) is removed — no longer needed
- Existing v2 notes in cache are **not migrated** and will be inaccessible after upgrade
- Ensure cache is empty (or flush) before deploying v3
+323
View File
@@ -0,0 +1,323 @@
# v3 Plan
> Status: **Draft** — agreed on architecture, schema open for iteration.
>
> See also: [v3 Breaking Changes](./v3-breaking-changes.md) for the upgrade guide.
## Goals
- **XChaCha20-Poly1305** for encryption (replaces AES/`occulto`)
- **MessagePack** for all API request/response bodies (replaces JSON)
- **LZ4 compression** for note payloads (client-side, before encryption — pure JS, no wasm)
- **Cache hashes** (valkey or redis, both speak RESP) for storage — replaces JSON-blob-per-key
- **Clean break** from v1 — no backward compatibility, no v1 routes
- Remove all Redis references (env vars, service names, docs) in favor of the generic "cache" naming, so operators can choose valkey or redis
- Shared TypeScript package as the single source of truth for crypto + API client + types
## Non-goals
- Keeping v1 alive alongside v3
- Changing the backend language/framework (stays Rust + axum)
- Changing storage backend (stays valkey or redis via the `redis` crate — no separate crate)
- Publishing `@cryptgeon/shared` as a standalone npm package (workspace-internal for now)
---
## 1. Shared package — `@cryptgeon/shared`
Location: `packages/shared` (currently empty).
ESM-only, TypeScript-only. Consumed by both `packages/cli` and `packages/frontend` via workspace dependency.
### Dependencies
- `@noble/ciphers` — XChaCha20-Poly1305
- `@noble/hashes` — scrypt
- `@msgpack/msgpack` — encode/decode
- `lz4js` — LZ4 compression (pure JS, no wasm)
- `ky` — HTTP client
### Structure
```
packages/shared/src/
index.ts # re-exports
crypto.ts # key derivation, encrypt, decrypt
compression.ts # LZ4 compress / decompress
types.ts # Note, NoteMetadata, FileDTO, Status, etc.
api.ts # high-level client: create, info, view, status
api.test.ts # tests
crypto.test.ts # tests
compression.test.ts # tests
```
### `crypto.ts`
- `deriveKey(password: string): Uint8Array` — scrypt, N=2^15, r=8, p=1, dkLen=32, fixed app-specific salt
- `generateKey(): Uint8Array` — `randomBytes(32)`
- `encrypt(data: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).encrypt(data)`
- `decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).decrypt(ciphertext)`
> **Note (carried over from msgpack branch):** the v2 stub had a bug — `decrypt` passed `key` as a second arg to `chacha.decrypt`, which only takes ciphertext. v3 must not repeat this.
### `compression.ts`
- `compress(data: Uint8Array): Uint8Array` — LZ4 block format
- `decompress(data: Uint8Array): Uint8Array` — LZ4 block format
Compression is a **client-only** concern. The server never sees or knows about compression — it stores the encrypted `data` blob as opaque bytes. The pipeline is:
```
msgpack encode → LZ4 compress → XChaCha20-Poly1305 encrypt
XChaCha20-Poly1305 decrypt → LZ4 decompress → msgpack decode
```
Compression runs on the plaintext (inner msgpack), never on ciphertext — encrypted data is high-entropy and incompressible. Always-on for v3 (all clients share the same package, no interop flag needed).
### `api.ts`
High-level client. All requests/responses are msgpack (`Content-Type: application/msgpack`). Methods:
- `setOptions({ server })` / `getOptions()`
- `create(note, key): Promise<{ id: string }>` — encodes msgpack, compresses (LZ4), encrypts, POST `/api/v3/notes/`
- `info(id): Promise<NoteInfo>` — GET `/api/v3/notes/{id}`, returns metadata only (no `data`)
- `view(id, key): Promise<NotePublic>` — DELETE `/api/v3/notes/{id}`, decrypts `data`, decompresses (LZ4), decodes msgpack
- `status(): Promise<Status>` — GET `/api/v3/status` (still JSON — server config, not note data)
---
## 2. Backend (Rust)
### 2.1 Storage — `store.rs` rewrite
Switch from JSON-blob-per-key to **cache hashes** (valkey or redis, both speak RESP):
```
Key: {CACHE_PREFIX}{id}
Fields:
views (i32) # remaining views, or absent
expiration (u32) # unix timestamp, or absent
type ("text"|"file")
derivation (msgpack bytes, optional) # scrypt salt+params if password-based
data (bytes) # encrypted msgpack blob
```
Functions:
- `set(id, note)` → `HSET` all fields + `EXPIRE` (if time-limited)
- `get_meta(id)` → `HMGET views expiration type derivation` — never touches `data` (cheap preview)
- `get_data(id)` → `HGET data` — only when consuming
- `decrement_view(id)` → `HINCRBY views -1` — **atomic**, see 2.2
- `del(id)` → `DEL`
- `can_reach_cache()` — health check (renamed from `can_reach_redis`)
Use `rmp-serde` for msgpack (de)serialization of note structs.
### 2.2 Remove `lock.rs`
The per-id `Mutex` map in `SharedState` existed only because the consume endpoint did non-atomic read-modify-write on `views`. With `HINCRBY` this is atomic at the cache level.
- Delete `packages/backend/src/lock.rs`
- Remove `SharedState` from `main.rs` (the `.with_state(shared_state)` call)
- Remove the lock map + `Arc`/`Mutex` imports
### 2.3 Rewrite `note/`
- `model.rs` — msgpack-compatible structs (derive `Serialize`/`Deserialize` for `rmp-serde`)
- `routes.rs` — three handlers:
#### `create` — `POST /api/v3/notes/`
- Accepts `application/msgpack` body (raw `Bytes`)
- Deserialize with rmp-serde
- Validate:
- At least one of `views`/`expiration` must be set
- `views` ≤ `MAX_VIEWS` and ≥ 1
- `expiration` ≤ `MAX_EXPIRATION` (minutes) and ≥ 1
- If `ALLOW_ADVANCED=false`: force `views=1, expiration=None`
- Store via `store::set`
- Return `{ id }` as msgpack
#### `preview` (info) — `GET /api/v3/notes/{id}`
- `store::get_meta(id)` — does not load `data`
- Return metadata as msgpack (no `data` field)
- `404` if not found
#### `view` (consume) — `DELETE /api/v3/notes/{id}`
- If `views` is set:
- `HINCRBY views -1` (atomic)
- If result ≤ 0: `HGET data`, `DEL` key, return data
- If result > 0: `HGET data`, return data (note survives for remaining views)
- If `views` is not set (time-only):
- `HGET data`, `DEL` key, return data
- Expiration handled lazily by cache (`EXPIRE` on the key) — no manual `if e < n` check on read
### 2.4 Config — `config.rs`
Rename:
- `REDIS` env → `CACHE`
- `REDIS_PREFIX` → `CACHE_PREFIX`
- `REDIS_CLIENT` static → `CACHE_CLIENT`
Everything else stays.
### 2.5 Health — `health/mod.rs`
- Rename `can_reach_redis` → `can_reach_cache`. Update panic message in `main.rs`.
- Move route from `/api/live` to `/healthz` (k8s standard). Not under `/api/v3/` — health checks are infrastructure, not API surface.
### 2.6 Status — `status/mod.rs`
Keep as JSON. It's server configuration, not note data — msgpack adds nothing. Frontend fetches once on load.
### 2.7 Dependencies — `Cargo.toml`
- Add `rmp-serde` (msgpack)
- Remove `serde_json` if no longer used (status endpoint still uses `Json<T>` which needs `serde_json` — keep)
- Keep `redis` crate (RESP client, works with valkey and redis)
---
## 3. "Both" constraint (views AND expiration)
New in v3: a note can have **both** `views` and `expiration` set simultaneously.
Implementation:
- `views` decremented via `HINCRBY views -1` on each consume
- `expiration` set via key-level `EXPIRE` (unix timestamp → seconds remaining)
- Whichever trips first removes the note:
- Views hit 0 → we `DEL` on the last consume
- Time expires → cache lazily removes the key
- No read-time expiration check needed in application code
---
## 4. Infra / docs cleanup
- `docker-compose.dev.yaml`: rename `redis` service → `cache` (image stays `valkey/valkey:7-alpine`, operators can swap for redis)
- `docker-compose.yaml` (if present): same
- `Dockerfile`: `ENV REDIS=...` → `ENV CACHE=...`
- `package.json` (root): `dev:docker` script service name
- `README.md`, `README_ES.md`, `README_zh-CN.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `examples/*` — replace "redis" with "cache" (or "valkey/redis" where context calls for naming the implementation)
- `Cryptgeon.postman_collection.json` — update content types to `application/msgpack`
- `.env.dev` — update `REDIS` → `CACHE` if present
- Healthcheck URLs: update all `/api/live` references → `/healthz` (docker-compose files, README, postman collection)
---
## 5. CLI (`packages/cli`)
- Drop `occulto` dependency
- Delete `packages/cli/src/shared/` (api.ts, adapters.ts, shared.ts) — replaced by `@cryptgeon/shared`
- `actions/upload.ts` and `actions/download.ts` call into `@cryptgeon/shared` API client
- Package still published as `cryptgeon` on npm
- `@cryptgeon/shared` stays workspace-internal (not published) for now
---
## 6. Frontend (`packages/frontend`)
- Drop `occulto` dependency
- Import from `@cryptgeon/shared` instead of `cryptgeon/shared`
- Update `package.json`: `"cryptgeon": "workspace:*"` → `"@cryptgeon/shared": "workspace:*"`
- Update files:
- `src/lib/views/Create.svelte`
- `src/lib/ui/ShowNote.svelte`
- `src/lib/ui/FileUpload.svelte`
- `src/lib/ui/PastedFilesPreview.svelte`
- `src/lib/ui/AdvancedParameters.svelte`
- `src/lib/stores/status.ts`
- `src/routes/note/[id]/+page.svelte`
---
## 7. msgpack note schema — "matrioshka" design
The server is **agnostic to the content**. It only sees an outer envelope with metadata and an opaque encrypted blob. The content type (text vs. files) lives inside the encrypted inner layer, invisible to the server.
### Outer layer (server-visible)
```
{
meta: {
expiration: u32? # optional, unix timestamp
views: u32? # optional, remaining view count
extra: bytes? # optional, client-opaque, size-limited
}
data: bytes # encrypted inner msgpack blob
}
```
- `meta.expiration` / `meta.views`: at least one must be set; both can be set simultaneously (see section 3)
- `meta.extra`: opaque client-owned data the server stores and returns verbatim in preview, but never interprets. Used for `derivation` (scrypt salt + params) so the client knows at preview time whether to prompt for a password. Size-limited (e.g. 512 bytes) to prevent abuse.
### Inner layer (encrypted, client-only)
Inside the encrypted `data` blob, after decryption, is a msgpack union:
```
# Text note
{ type: "text", data: string }
# File note
{ type: "files", data: [{ name: string, mime: string, data: bytes }] }
```
The server never sees this structure — it stores/retrieves `data` as opaque bytes.
The inner msgpack blob is **LZ4-compressed before encryption** (see `compression.ts`). Full client pipeline: `msgpack encode → lz4 compress → xchacha20poly1305 encrypt`, reversed on consume. The server is unaware of compression — it only ever handles the encrypted `data` bytes.
### Endpoints
#### `POST /api/v3/notes/` — create
**Request** (msgpack): outer layer `{ meta: { expiration?, views?, extra? }, data }`
**Response** (msgpack): `{ id: string }`
#### `GET /api/v3/notes/{id}` — preview / info
**Response** (msgpack): `{ meta: { expiration?, views?, extra? } }`
Returns metadata only — does not load `data` from cache. Client inspects `meta.extra` to determine key derivation strategy (password vs. URL-fragment key) before consuming.
#### `DELETE /api/v3/notes/{id}` — view / consume
**Response** (msgpack): `{ meta: { expiration?, views?, extra? }, data: bytes }`
Returns the full envelope. Client decrypts `data` using key derived from `meta.extra` (if present) or URL fragment, then decodes the inner msgpack to get text/files.
#### `GET /api/v3/status` — server config
**Response** (JSON, not msgpack): server configuration, not note data. Kept as JSON for simplicity.
### Valkey hash field layout
```
Key: {CACHE_PREFIX}{id}
Fields:
views (i32) # remaining views, or absent
expiration (u32) # unix timestamp, or absent
extra (bytes) # client-opaque, size-limited
data (bytes) # encrypted msgpack blob
```
`get_meta(id)` does `HMGET views expiration extra` — never touches `data`.
---
## 8. Implementation order
1. Shared package scaffolding (package.json, tsconfig, vitest config)
2. `crypto.ts` + tests
3. `compression.ts` + tests
4. `types.ts`
5. Backend: config rename + store rewrite + remove lock.rs
6. Backend: note routes rewrite (msgpack)
7. `api.ts` in shared (client) + tests
8. CLI rewrite (drop shared/, use @cryptgeon/shared)
9. Frontend migration
10. Infra/docs cleanup (cache rename, compose, Dockerfile)
11. Integration tests (playwright)
+10 -9
View File
@@ -1,22 +1,23 @@
{ {
"scripts": { "scripts": {
"dev:docker": "docker compose -f docker-compose.dev.yaml up redis", "dev:docker": "docker compose -f docker-compose.dev.yaml up cache",
"dev:packages": "pnpm --parallel run dev", "dev:packages": "pnpm --parallel run dev",
"dev": "run-p dev:*", "dev": "pnpm --parallel run /dev/",
"docker:up": "docker compose -f docker-compose.dev.yaml up", "docker:up": "docker compose -f docker-compose.dev.yaml up",
"docker:build": "docker compose -f docker-compose.dev.yaml build", "docker:build": "docker compose -f docker-compose.dev.yaml build",
"test": "playwright test --project=chrome --project=firefox --project=safari", "test": "playwright test --project=chrome --project=firefox --project=safari",
"test:local": "playwright test --project=chrome", "test:local": "playwright test --project=chrome",
"test:server": "run-s docker:up", "test:server": "docker compose -f docker-compose.dev.yaml up",
"test:dl-browsers": "playwright install", "test:dl-browsers": "playwright install",
"test:prepare": "run-p test:dl-browsers build docker:build", "test:prepare": "pnpm run build && pnpm run docker:build",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build" "build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.62.1",
"@types/node": "^24.12.4", "@types/node": "^24.13.3"
"npm-run-all": "^4.1.5",
"shelljs": "^0.8.5"
}, },
"packageManager": "pnpm@11.5.0" "packageManager": "pnpm@11.5.0",
"engines": {
"node": ">=22"
}
} }
+21 -1
View File
@@ -252,7 +252,7 @@ dependencies = [
[[package]] [[package]]
name = "cryptgeon" name = "cryptgeon"
version = "2.9.3" version = "3.0.0"
dependencies = [ dependencies = [
"axum", "axum",
"bs62", "bs62",
@@ -261,6 +261,7 @@ dependencies = [
"lazy_static", "lazy_static",
"redis", "redis",
"ring", "ring",
"rmp-serde",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -1004,6 +1005,25 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "cryptgeon" name = "cryptgeon"
version = "2.9.3" version = "3.0.0"
authors = ["cupcakearmy <hi@nicco.io>"] authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
@@ -20,6 +20,7 @@ redis = { version = "1", features = ["tls-native-tls"] }
# Utility # Utility
serde_json = "1" serde_json = "1"
rmp-serde = "1"
lazy_static = "1" lazy_static = "1"
ring = "0.17" ring = "0.17"
bs62 = "0.1" bs62 = "0.1"
+5 -1
View File
@@ -34,7 +34,7 @@ pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string()) .unwrap_or("32".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref REDIS_PREFIX: String = std::env::var("REDIS_PREFIX") pub static ref CACHE_PREFIX: String = std::env::var("CACHE_PREFIX")
.unwrap_or("".to_string()) .unwrap_or("".to_string())
.parse() .parse()
.unwrap(); .unwrap();
@@ -50,6 +50,10 @@ pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
.unwrap_or("".to_string()) .unwrap_or("".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref EXTRA_SIZE_LIMIT: usize = std::env::var("EXTRA_SIZE_LIMIT")
.unwrap_or("512".to_string())
.parse()
.unwrap();
} }
// THEME // THEME
-16
View File
@@ -1,16 +0,0 @@
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
}
+1 -1
View File
@@ -2,7 +2,7 @@ use crate::store;
use axum::http::StatusCode; use axum::http::StatusCode;
pub async fn report_health() -> (StatusCode,) { pub async fn report_health() -> (StatusCode,) {
if store::can_reach_redis() { if store::can_reach_cache() {
return (StatusCode::OK,); return (StatusCode::OK,);
} else { } else {
return (StatusCode::SERVICE_UNAVAILABLE,); return (StatusCode::SERVICE_UNAVAILABLE,);
-10
View File
@@ -1,10 +0,0 @@
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<()>>>>>;
+10 -21
View File
@@ -1,13 +1,9 @@
use std::{collections::HashMap, sync::Arc};
use axum::{ use axum::{
Router, ServiceExt, Router, ServiceExt,
extract::{DefaultBodyLimit, Request}, extract::{DefaultBodyLimit, Request},
routing::{delete, get, post}, routing::{delete, get, post},
}; };
use dotenv::dotenv; use dotenv::dotenv;
use lock::SharedState;
use tokio::sync::Mutex;
use tower::Layer; use tower::Layer;
use tower_http::{ use tower_http::{
compression::CompressionLayer, compression::CompressionLayer,
@@ -19,9 +15,7 @@ use tower_http::{
extern crate lazy_static; extern crate lazy_static;
mod config; mod config;
mod csp;
mod health; mod health;
mod lock;
mod note; mod note;
mod status; mod status;
mod store; mod store;
@@ -30,34 +24,30 @@ mod store;
async fn main() { async fn main() {
dotenv().ok(); dotenv().ok();
let shared_state = SharedState { if !store::can_reach_cache() {
locks: Arc::new(Mutex::new(HashMap::new())), println!("cannot reach cache");
}; panic!("cannot reach cache");
if !store::can_reach_redis() {
println!("cannot reach redis");
panic!("cannot reach redis");
} }
let notes_routes = Router::new() let notes_routes = Router::new()
.route("/", post(note::create)) .route("/", post(note::create))
.route("/{id}", delete(note::delete)) .route("/{id}", delete(note::view))
.route("/{id}", get(note::preview)); .route("/{id}", get(note::preview));
let health_routes = Router::new().route("/live", get(health::report_health)); let health_routes = Router::new().route("/healthz", get(health::report_health));
let status_routes = Router::new().route("/status", get(status::get_status)); let status_routes = Router::new().route("/status", get(status::get_status));
let api_routes = Router::new() let v3_routes = Router::new()
.nest("/notes", notes_routes) .nest("/notes", notes_routes)
.merge(health_routes)
.merge(status_routes); .merge(status_routes);
let api_routes = Router::new().nest("/v3", v3_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html"); let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
let serve_dir = let serve_dir =
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index)); ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
let app = Router::new() let app = Router::new()
.nest("/api", api_routes) .nest("/api", api_routes)
.merge(health_routes)
.fallback_service(serve_dir) .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(DefaultBodyLimit::max(*config::LIMIT))
.layer( .layer(
CompressionLayer::new() CompressionLayer::new()
@@ -65,8 +55,7 @@ async fn main() {
.deflate(true) .deflate(true)
.gzip(true) .gzip(true)
.zstd(true), .zstd(true),
) );
.with_state(shared_state);
let app = NormalizePathLayer::trim_trailing_slash().layer(app); let app = NormalizePathLayer::trim_trailing_slash().layer(app);
+24 -11
View File
@@ -5,22 +5,35 @@ use serde::{Deserialize, Serialize};
use crate::config; use crate::config;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct Note { pub struct NoteMeta {
pub meta: String, #[serde(skip_serializing_if = "Option::is_none")]
pub contents: String,
pub views: Option<u32>, pub views: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration: Option<u32>, pub expiration: Option<u32>,
#[serde(default)]
pub extra: Vec<u8>,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize, Clone)]
pub struct NoteInfo { pub struct CreateRequest {
pub meta: String, pub meta: NoteMeta,
pub data: Vec<u8>,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct NotePublic { pub struct CreateResponse {
pub meta: String, pub id: String,
pub contents: String, }
#[derive(Serialize, Deserialize)]
pub struct MetaResponse {
pub meta: NoteMeta,
}
#[derive(Serialize, Deserialize)]
pub struct NoteResponse {
pub meta: NoteMeta,
pub data: Vec<u8>,
} }
pub fn generate_id() -> String { pub fn generate_id() -> String {
@@ -32,5 +45,5 @@ pub fn generate_id() -> String {
let _ = sr.fill(&mut id); let _ = sr.fill(&mut id);
result.push_str(&bs62::encode_data(&id)); result.push_str(&bs62::encode_data(&id));
} }
return result; result
} }
+95 -107
View File
@@ -2,155 +2,143 @@ use axum::{
extract::Path, extract::Path,
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, body::Bytes,
}; };
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use std::{sync::Arc, time::SystemTime}; use std::time::SystemTime;
use tokio::sync::Mutex;
use crate::note::{generate_id, Note, NoteInfo}; use crate::note::{CreateRequest, generate_id};
use crate::store; use crate::store;
use crate::{config, lock::SharedState}; use crate::config;
use super::NotePublic; use super::{CreateResponse, MetaResponse, NoteResponse, NoteMeta};
pub fn now() -> u32 { pub fn now() -> u64 {
SystemTime::now() SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH) .duration_since(SystemTime::UNIX_EPOCH)
.unwrap() .unwrap()
.as_secs() as u32 .as_secs()
} }
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct OneNoteParams { pub struct NoteParams {
id: String, id: String,
} }
pub async fn preview(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response { pub async fn create(body: Bytes) -> Response {
let note = store::get(&id); let req: CreateRequest = match rmp_serde::from_slice(&body) {
Ok(r) => r,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid msgpack").into_response(),
};
match note { if req.meta.views.is_none() && req.meta.expiration.is_none() {
Ok(Some(n)) => (StatusCode::OK, Json(NoteInfo { meta: n.meta })).into_response(), return (StatusCode::BAD_REQUEST, "At least views or expiration must be set").into_response();
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
} }
#[derive(Serialize, Deserialize)] if req.meta.extra.len() > *config::EXTRA_SIZE_LIMIT {
struct CreateResponse { return (StatusCode::BAD_REQUEST, "Extra data too large").into_response();
id: String,
} }
pub async fn create(Json(mut n): Json<Note>) -> Response { let mut meta = req.meta;
// let mut n = note.into_inner();
let id = generate_id();
// let bad_req = HttpResponse::BadRequest().finish();
if n.views == None && n.expiration == None {
return (
StatusCode::BAD_REQUEST,
"At least views or expiration must be set",
)
.into_response();
}
if !*config::ALLOW_ADVANCED { if !*config::ALLOW_ADVANCED {
n.views = Some(1); meta.views = Some(1);
n.expiration = None; meta.expiration = None;
} }
match n.views {
match meta.views {
Some(v) => { Some(v) => {
if v > *config::MAX_VIEWS || v < 1 { if v > *config::MAX_VIEWS || v < 1 {
return (StatusCode::BAD_REQUEST, "Invalid views").into_response(); return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
} }
n.expiration = None; // views overrides expiration
} }
_ => {} None => {}
} }
match n.expiration {
let expiration_ts = match meta.expiration {
Some(e) => { Some(e) => {
if e > *config::MAX_EXPIRATION || e < 1 { if e > *config::MAX_EXPIRATION || e < 1 {
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response(); return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
} }
let expiration = now() + (e * 60); Some(now() + (e as u64 * 60))
n.expiration = Some(expiration);
} }
_ => {} None => None,
};
let id = generate_id();
let views = meta.views.map(|v| v as i64);
match store::set(&id, &req.data, views, expiration_ts, &meta.extra) {
Ok(_) => {
let resp = CreateResponse { id };
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
(StatusCode::OK, Bytes::from(bytes)).into_response()
} }
match store::set(&id.clone(), &n.clone()) {
Ok(_) => (StatusCode::OK, Json(CreateResponse { id })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
} }
pub async fn delete( pub async fn preview(Path(NoteParams { id }): Path<NoteParams>) -> Response {
Path(OneNoteParams { id }): Path<OneNoteParams>, match store::get_meta(&id) {
state: axum::extract::State<SharedState>, Ok(Some((views, expiration, extra))) => {
) -> Response { let meta = NoteMeta {
let mut locks_map = state.locks.lock().await; views: views.map(|v| v as u32),
let lock = locks_map expiration: expiration.map(|e| e as u32),
.entry(id.clone()) extra,
.or_insert_with(|| Arc::new(Mutex::new(()))) };
.clone(); let resp = MetaResponse { meta };
drop(locks_map); let bytes = rmp_serde::to_vec_named(&resp).unwrap();
let _guard = lock.lock().await; (StatusCode::OK, Bytes::from(bytes)).into_response()
}
let note = store::get(&id);
match note {
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
Ok(None) => (StatusCode::NOT_FOUND).into_response(), Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Ok(Some(note)) => { Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
let mut changed = note.clone();
if changed.views == None && changed.expiration == None {
return (StatusCode::BAD_REQUEST).into_response();
} }
match changed.views {
Some(v) => {
changed.views = Some(v - 1);
let id = id.clone();
if v <= 1 {
match store::del(&id) {
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response();
} }
_ => {}
pub async fn view(Path(NoteParams { id }): Path<NoteParams>) -> Response {
let (views, expiration, extra) = match store::get_meta(&id) {
Ok(Some(v)) => v,
_ => return (StatusCode::NOT_FOUND).into_response(),
};
let has_views = views.is_some();
if has_views {
let remaining = match store::decrement_views(&id) {
Ok(r) => r,
Err(_) => return (StatusCode::NOT_FOUND).into_response(),
};
let data = match store::get_data(&id) {
Ok(Some(d)) => d,
_ => return (StatusCode::NOT_FOUND).into_response(),
};
if remaining <= 0 {
let _ = store::del(&id);
} }
let meta = NoteMeta {
views: Some(if remaining > 0 { remaining as u32 } else { 0 }),
expiration: expiration.map(|e| e as u32),
extra,
};
let resp = NoteResponse { meta, data };
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
(StatusCode::OK, Bytes::from(bytes)).into_response()
} else { } else {
match store::set(&id, &changed.clone()) { let data = match store::get_data(&id) {
Err(e) => { Ok(Some(d)) => d,
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) _ => return (StatusCode::NOT_FOUND).into_response(),
.into_response(); };
}
_ => {}
}
}
}
_ => {}
}
let n = now(); let meta = NoteMeta {
match changed.expiration { views: None,
Some(e) => { expiration: expiration.map(|e| e as u32),
if e < n { extra,
match store::del(&id.clone()) { };
Ok(_) => return (StatusCode::BAD_REQUEST).into_response(), let resp = NoteResponse { meta, data };
Err(e) => { let bytes = rmp_serde::to_vec_named(&resp).unwrap();
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) (StatusCode::OK, Bytes::from(bytes)).into_response()
.into_response()
}
}
}
}
_ => {}
}
return (
StatusCode::OK,
Json(NotePublic {
contents: changed.contents,
meta: changed.meta,
}),
)
.into_response();
}
} }
} }
+54 -42
View File
@@ -1,71 +1,83 @@
use redis;
use redis::Commands; use redis::Commands;
use crate::config; use crate::config;
use crate::note::now;
use crate::note::Note;
lazy_static! { lazy_static! {
static ref REDIS_CLIENT: String = std::env::var("REDIS") static ref CACHE_URL: String = std::env::var("CACHE")
.unwrap_or("redis://127.0.0.1/".to_string()) .unwrap_or("redis://127.0.0.1/".to_string())
.parse() .parse()
.unwrap(); .unwrap();
} }
fn prefixed(id: &String) -> String { fn prefixed(id: &str) -> String {
format!("{}{}", config::REDIS_PREFIX.as_str(), id) format!("{}{}", config::CACHE_PREFIX.as_str(), id)
} }
fn get_connection() -> Result<redis::Connection, &'static str> { fn conn() -> Result<redis::Connection, &'static str> {
let client = let client =
redis::Client::open(REDIS_CLIENT.to_string()).map_err(|_| "Unable to connect to redis")?; redis::Client::open(CACHE_URL.to_string()).map_err(|_| "Unable to connect to cache")?;
client client.get_connection().map_err(|_| "Unable to connect to cache")
.get_connection()
.map_err(|_| "Unable to connect to redis")
} }
pub fn can_reach_redis() -> bool { pub fn can_reach_cache() -> bool {
let conn = get_connection(); conn().is_ok()
return match conn {
Ok(_) => true,
Err(_) => false,
};
} }
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> { pub fn set(id: &str, data: &[u8], views: Option<i64>, expiration: Option<u64>, extra: &[u8]) -> Result<(), &'static str> {
let key = prefixed(id); let key = prefixed(id);
let serialized = serde_json::to_string(&note.clone()).unwrap(); let mut c = conn()?;
let mut conn = get_connection()?;
conn.set::<_, _, ()>(key.as_str(), serialized) c.hset::<_, _, _, ()>(&key, "data", data).map_err(|_| "Unable to set note")?;
.map_err(|_| "Unable to set note in redis")?; c.hset::<_, _, _, ()>(&key, "extra", extra).map_err(|_| "Unable to set note")?;
match note.expiration {
Some(e) => { if let Some(v) = views {
let seconds = e - now(); c.hset::<_, _, _, ()>(&key, "views", v).map_err(|_| "Unable to set note")?;
conn.expire::<_, ()>(key.as_str(), seconds as i64)
.map_err(|_| "Unable to set expiration on note")?
} }
None => {} if let Some(e) = expiration {
}; let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let ttl = e.saturating_sub(now);
c.expire::<_, ()>(&key, ttl as i64).map_err(|_| "Unable to set expiration")?;
}
Ok(()) Ok(())
} }
pub fn get(id: &String) -> Result<Option<Note>, &'static str> { pub fn get_meta(id: &str) -> Result<Option<(Option<i64>, Option<u64>, Vec<u8>)>, &'static str> {
let key = prefixed(id); let key = prefixed(id);
let mut conn = get_connection()?; let mut c = conn()?;
let value: Option<String> = conn.get(key.as_str()).map_err(|_| "Could not load note in redis")?;
match value { let exists: bool = c.exists::<_, bool>(&key).map_err(|_| "Cache error")?;
None => return Ok(None), if !exists {
Some(s) => { return Ok(None);
let deserialize: Note = serde_json::from_str(&s).unwrap();
return Ok(Some(deserialize));
}
}
} }
pub fn del(id: &String) -> Result<(), &'static str> { let views: Option<i64> = c.hget::<_, _, Option<i64>>(&key, "views").map_err(|_| "Cache error")?;
let expiration: Option<u64> = c.hget::<_, _, Option<u64>>(&key, "expiration").map_err(|_| "Cache error")?;
let extra: Vec<u8> = c.hget::<_, _, Vec<u8>>(&key, "extra").unwrap_or_default();
Ok(Some((views, expiration, extra)))
}
pub fn get_data(id: &str) -> Result<Option<Vec<u8>>, &'static str> {
let key = prefixed(id); let key = prefixed(id);
let mut conn = get_connection()?; let mut c = conn()?;
conn.del::<_, ()>(key.as_str()).map_err(|_| "Unable to delete note in redis")?; let data: Option<Vec<u8>> = c.hget::<_, _, Option<Vec<u8>>>(&key, "data").map_err(|_| "Cache error")?;
Ok(data)
}
pub fn decrement_views(id: &str) -> Result<i64, &'static str> {
let key = prefixed(id);
let mut c = conn()?;
let result: i64 = c.hincr::<_, _, _, i64>(&key, "views", -1).map_err(|_| "Cache error")?;
Ok(result)
}
pub fn del(id: &str) -> Result<(), &'static str> {
let key = prefixed(id);
let mut c = conn()?;
c.del::<_, ()>(&key).map_err(|_| "Unable to delete note")?;
Ok(()) Ok(())
} }
-15
View File
@@ -1,15 +0,0 @@
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,
})
+19 -21
View File
@@ -1,6 +1,6 @@
{ {
"name": "cryptgeon", "name": "cryptgeon",
"version": "2.9.3", "version": "3.0.0",
"homepage": "https://github.com/cupcakearmy/cryptgeon", "homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -9,39 +9,37 @@
}, },
"type": "module", "type": "module",
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.mjs"
"./shared": {
"import": "./dist/shared/shared.js",
"types": "./dist/shared/shared.d.ts"
}
}, },
"types": "./dist/index.d.ts", "types": "./dist/index.d.mts",
"bin": { "bin": {
"cryptgeon": "./dist/cli.cjs" "cryptgeon": "./dist/cli.mjs"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"scripts": { "scripts": {
"bin": "run-s build package", "build": "vp pack",
"build": "tsc && node build.js", "dev": "vp pack --watch",
"dev": "node build.js --watch",
"prepublishOnly": "run-s build" "prepublishOnly": "run-s build"
}, },
"devDependencies": { "dependencies": {
"@cryptgeon/shared": "workspace:*",
"@msgpack/msgpack": "^3.1.3",
"@commander-js/extra-typings": "^12.1.0", "@commander-js/extra-typings": "^12.1.0",
"@types/inquirer": "^9.0.9",
"@types/mime": "^4.0.0",
"@types/node": "^20.19.41",
"commander": "^12.1.0",
"inquirer": "^9.3.8", "inquirer": "^9.3.8",
"mime": "^4.1.0", "mime": "^4.1.0",
"occulto": "^2.0.6", "pretty-bytes": "^6.1.1"
"pretty-bytes": "^6.1.1", },
"tsup": "^8.5.1", "devDependencies": {
"typescript": "^5.9.3" "@tsconfig/strictest": "catalog:",
"@types/inquirer": "^9.0.9",
"@types/node": "^22.15.3",
"commander": "^12.1.0",
"typescript": "catalog:",
"vite-plus": "catalog:"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=22"
} }
} }
+33 -45
View File
@@ -1,51 +1,43 @@
import inquirer from 'inquirer' import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises' import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path' import { basename, resolve } from 'node:path'
import { AES, Hex } from 'occulto' import { decode } from '@msgpack/msgpack'
import pretty from 'pretty-bytes' import pretty from 'pretty-bytes'
import { Adapters } from '../shared/adapters.js' import { decrypt, deriveKey, setServer, info, get, decompress } from '@cryptgeon/shared'
import { API } from '../shared/api.js'
export async function download(url: URL, all: boolean, suggestedPassword?: string) { export async function download(url: URL, all: boolean, suggestedPassword?: string) {
API.setOptions({ server: url.origin }) setServer(url.origin)
const id = url.pathname.split('/')[2] const id = url.pathname.split('/')[2]
const preview = await API.info(id).catch(() => { if (!id) throw new Error('Invalid URL')
throw new Error('Note does not exist or is expired') const meta = await info(id)
}) if (!meta) throw new Error('Note does not exist or is expired')
// Password let key: Uint8Array
let password: string if (meta.extra && meta.extra.length > 0) {
const derivation = preview?.meta.derivation
if (derivation) {
if (suggestedPassword) { if (suggestedPassword) {
password = suggestedPassword const derivation = decode(meta.extra) as any
key = deriveKey(suggestedPassword, new Uint8Array(derivation.salt))
} else { } else {
const response = await inquirer.prompt([ const response = await inquirer.prompt([
{ { type: 'password', message: 'Note password', name: 'password' },
type: 'password',
message: 'Note password',
name: 'password',
},
]) ])
password = response.password const derivation = decode(meta.extra) as any
key = deriveKey(response.password, new Uint8Array(derivation.salt))
} }
} else { } else {
password = url.hash.slice(1) const hex = url.hash.slice(1)
key = new Uint8Array(Buffer.from(hex, 'hex'))
} }
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password) const note = await get(id)
const note = await API.get(id) if (!note) throw new Error('Could not load note')
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password') const decrypted = decrypt(note.data, key)
switch (note.meta.type) { const content = decode(decompress(decrypted)) as any
case 'file':
const files = await Adapters.Files.decrypt(note.contents, key).catch(() => {
throw couldNotDecrypt
})
if (!files) {
throw new Error('No files found in note')
}
switch (content.type) {
case 'files':
const files: { name: string; data: Uint8Array }[] = content.data
let selected: typeof files let selected: typeof files
if (all) { if (all) {
selected = files selected = files
@@ -55,36 +47,32 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
type: 'checkbox', type: 'checkbox',
message: 'What files should be saved?', message: 'What files should be saved?',
name: 'names', name: 'names',
choices: files.map((file) => ({ choices: files.map((f) => ({
value: file.name, value: f.name,
name: `${file.name} - ${file.type} - ${pretty(file.size, { binary: true })}`, name: `${f.name} - ${pretty(f.data.length, { binary: true })}`,
checked: true, checked: true,
})), })),
}, },
]) ])
selected = files.filter((file) => names.includes(file.name)) selected = files.filter((f) => names.includes(f.name))
} }
if (!selected.length) throw new Error('No files selected') if (!selected.length) throw new Error('No files selected')
await Promise.all( await Promise.all(
selected.map(async (file) => { selected.map(async (f) => {
let filename = resolve(file.name) let filename = resolve(f.name)
try { try {
// If exists -> prepend timestamp to not overwrite the current file
await access(filename, constants.R_OK) await access(filename, constants.R_OK)
filename = resolve(`${Date.now()}-${file.name}`) filename = resolve(`${Date.now()}-${f.name}`)
} catch {} } catch {}
await writeFile(filename, file.contents) await writeFile(filename, f.data)
console.log(`Saved: ${basename(filename)}`) console.log(`Saved: ${basename(filename)}`)
}) })
) )
break break
case 'text': case 'text':
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(() => { console.log(content.data)
throw couldNotDecrypt
})
console.log(plaintext)
break break
default:
throw new Error('Unknown content type')
} }
} }
+24 -27
View File
@@ -1,46 +1,43 @@
import { readFile, stat } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { basename } from 'node:path' import { basename } from 'node:path'
import { encode } from '@msgpack/msgpack'
import mime from 'mime' import mime from 'mime'
import { AES, Hex } from 'occulto' import { encrypt, generateKey, deriveKey, randomBytes, getServer, create, compress } from '@cryptgeon/shared'
import { Adapters } from '../shared/adapters.js'
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string } export type UploadOptions = { views?: number; expiration?: number; password?: string }
export async function upload(input: string | string[], options: UploadOptions): Promise<string> { export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
const { password, ...noteOptions } = options const { password, ...noteOptions } = options
const derived = options.password ? await AES.derive(options.password) : undefined
const key = derived ? derived[0] : await AES.generateKey()
let contents: string let key: Uint8Array
let type: NoteMeta['type'] let extra = new Uint8Array()
if (typeof input === 'string') { if (password) {
contents = await Adapters.Text.encrypt(input, key) const salt = randomBytes(16)
type = 'text' key = deriveKey(password, salt)
extra = encode({ salt, N: 32768, r: 8, p: 1 })
} else { } else {
const files: FileDTO[] = await Promise.all( key = generateKey()
}
let inner: Uint8Array
if (typeof input === 'string') {
inner = encode({ type: 'text', data: input })
} else {
const files = await Promise.all(
input.map(async (path) => { input.map(async (path) => {
const data = new Uint8Array(await readFile(path)) const data = new Uint8Array(await readFile(path))
const stats = await stat(path)
const extension = path.substring(path.indexOf('.') + 1) const extension = path.substring(path.indexOf('.') + 1)
const type = mime.getType(extension) ?? 'application/octet-stream' const type = mime.getType(extension) ?? 'application/octet-stream'
return { return { name: basename(path), mime: type, size: data.length, data }
name: basename(path),
size: stats.size,
contents: data,
type,
} satisfies FileDTO
}) })
) )
contents = await Adapters.Files.encrypt(files, key) inner = encode({ type: 'files', data: files })
type = 'file'
} }
// Create the actual note and upload it. const data = encrypt(compress(inner), key)
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } } const result = await create({ meta: { ...noteOptions, extra }, data })
const result = await API.create(note) let url = `${getServer()}/note/${result.id}`
let url = `${API.getOptions().server}/note/${result.id}` if (!password) url += `#${Buffer.from(key).toString('hex')}`
if (!derived) url += `#${Hex.encode(key)}`
return url return url
} }
+21 -15
View File
@@ -5,7 +5,7 @@ import prettyBytes from 'pretty-bytes'
import { download } from './actions/download.js' import { download } from './actions/download.js'
import { upload } from './actions/upload.js' import { upload } from './actions/upload.js'
import { API } from './shared/api.js' import { setServer, status } from '@cryptgeon/shared'
import { parseFile, parseNumber } from './utils/parsers.js' import { parseFile, parseNumber } from './utils/parsers.js'
import { getStdin } from './utils/stdin.js' import { getStdin } from './utils/stdin.js'
import { checkConstrains, exit } from './utils/utils.js' import { checkConstrains, exit } from './utils/utils.js'
@@ -21,7 +21,8 @@ const views = new Option('-v --views <number>', 'Amount of views before getting
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber) const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
// Node 18 guard // Node 18 guard
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required') const major = Number(process.version.slice(1).split('.')[0])
if (!Number.isFinite(major) || major < 18) exit('Node 18 or higher is required')
// @ts-ignore // @ts-ignore
const version: string = VERSION const version: string = VERSION
@@ -33,15 +34,12 @@ program
.description('show information about the server') .description('show information about the server')
.addOption(server) .addOption(server)
.action(async (options) => { .action(async (options) => {
API.setOptions({ server: options.server }) setServer(options.server!)
const response = await API.status() const response = await status()
const formatted = { const formatted = Object.fromEntries(
...response, Object.entries({ ...response, max_size: prettyBytes(response.max_size) })
max_size: prettyBytes(response.max_size), .filter(([key]) => !key.startsWith('theme_'))
} )
for (const key of Object.keys(formatted)) {
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
}
console.table(formatted) console.table(formatted)
}) })
@@ -54,11 +52,15 @@ send
.addOption(minutes) .addOption(minutes)
.addOption(password) .addOption(password)
.action(async (files, options) => { .action(async (files, options) => {
API.setOptions({ server: options.server }) setServer(options.server!)
await checkConstrains(options) await checkConstrains(options)
options.password ||= await getStdin() options.password ||= await getStdin()
try { try {
const url = await upload(files, { views: options.views, expiration: options.minutes, password: options.password }) const url = await upload(files, {
...(options.views !== undefined ? { views: options.views } : {}),
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
password: options.password,
})
console.log(`Note created:\n\n${url}`) console.log(`Note created:\n\n${url}`)
} catch { } catch {
exit('Could not create note') exit('Could not create note')
@@ -72,11 +74,15 @@ send
.addOption(minutes) .addOption(minutes)
.addOption(password) .addOption(password)
.action(async (text, options) => { .action(async (text, options) => {
API.setOptions({ server: options.server }) setServer(options.server!)
await checkConstrains(options) await checkConstrains(options)
options.password ||= await getStdin() options.password ||= await getStdin()
try { try {
const url = await upload(text, { views: options.views, expiration: options.minutes, password: options.password }) const url = await upload(text, {
...(options.views !== undefined ? { views: options.views } : {}),
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
password: options.password,
})
console.log(`Note created:\n\n${url}`) console.log(`Note created:\n\n${url}`)
} catch { } catch {
exit('Could not create note') exit('Could not create note')
-2
View File
@@ -1,4 +1,2 @@
export * from './actions/download.js' export * from './actions/download.js'
export * from './actions/upload.js' export * from './actions/upload.js'
export * from './shared/adapters.js'
export * from './shared/api.js'
-61
View File
@@ -1,61 +0,0 @@
import { AES, Bytes, type TypedArray } from 'occulto'
import type { EncryptedFileDTO, FileDTO } from './api'
abstract class CryptAdapter<T> {
abstract encrypt(plaintext: T, key: TypedArray): Promise<string>
abstract decrypt(ciphertext: string, key: TypedArray): Promise<T>
}
class CryptTextAdapter implements CryptAdapter<string> {
async encrypt(plaintext: string, key: TypedArray) {
return await AES.encrypt(Bytes.encode(plaintext), key)
}
async decrypt(ciphertext: string, key: TypedArray) {
return Bytes.decode(await AES.decrypt(ciphertext, key))
}
}
class CryptBlobAdapter implements CryptAdapter<TypedArray> {
async encrypt(plaintext: TypedArray, key: TypedArray) {
return await AES.encrypt(plaintext, key)
}
async decrypt(ciphertext: string, key: TypedArray) {
return await AES.decrypt(ciphertext, key)
// const plaintext = await AES.decrypt(ciphertext, key)
// return new Blob([plaintext], { type: 'application/octet-stream' })
}
}
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
async encrypt(plaintext: FileDTO[], key: TypedArray) {
const adapter = new CryptBlobAdapter()
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.encrypt(file.contents, key),
}))
return JSON.stringify(await Promise.all(data))
}
async decrypt(ciphertext: string, key: TypedArray) {
const adapter = new CryptBlobAdapter()
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
const files: FileDTO[] = await Promise.all(
data.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.decrypt(file.contents, key),
}))
)
return files
}
}
export const Adapters = {
Text: new CryptTextAdapter(),
Blob: new CryptBlobAdapter(),
Files: new CryptFilesAdapter(),
}
-141
View File
@@ -1,141 +0,0 @@
import type { KeyData, TypedArray } from 'occulto'
export type NoteMeta = {
type: 'text' | 'file'
derivation?: KeyData
}
export type Note = {
contents: string
meta: NoteMeta
views?: number
expiration?: number
}
export type NoteInfo = Pick<Note, 'meta'>
export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: TypedArray
}
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string
}
type ClientOptions = {
server: string
}
type CallOptions = {
url: string
method: string
body?: any
}
export class PayloadToLargeError extends Error {}
export let client: ClientOptions = {
server: '',
}
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',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
if (response.status === 413) throw new PayloadToLargeError()
else throw new Error('API call failed')
}
return response.json()
}
async function create(note: Note) {
const { meta, ...rest } = note
const body: NoteCreate = {
...rest,
meta: JSON.stringify(meta),
}
const data = await call({
url: 'notes/',
method: 'post',
body,
})
return data as { id: string }
}
async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
})
const { contents, meta } = data
const note = {
contents,
meta: JSON.parse(meta),
} satisfies NotePublic
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
}
async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
})
const { meta } = data
const note = {
meta: JSON.parse(meta),
} satisfies NoteInfo
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
}
export type Status = {
version: string
max_size: number
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
theme_home_link: boolean
}
async function status() {
const data = await call({
url: 'status/',
method: 'get',
})
return data as Status
}
export const API = {
setOptions,
getOptions,
create,
get,
info,
status,
}
-2
View File
@@ -1,2 +0,0 @@
export * from './adapters.js'
export * from './api.js'
+7 -9
View File
@@ -1,5 +1,5 @@
import { exit as exitNode } from 'node:process' import { exit as exitNode } from 'node:process'
import { API } from '../shared/api.js' import { status } from '@cryptgeon/shared'
export function exit(message: string) { export function exit(message: string) {
console.error(message) console.error(message)
@@ -7,13 +7,11 @@ export function exit(message: string) {
} }
export async function checkConstrains(constrains: { views?: number; minutes?: number }) { export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
const { views, minutes } = constrains if (!constrains.views && !constrains.minutes) constrains.views = 1
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
if (!views && !minutes) constrains.views = 1
const response = await API.status() const response = await status()
if (views && views > response.max_views) if (constrains.views && constrains.views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`) exit(`Only a maximum of ${response.max_views} views allowed. ${constrains.views} given.`)
if (minutes && minutes > response.max_expiration) if (constrains.minutes && constrains.minutes > response.max_expiration)
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`) exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${constrains.minutes} given.`)
} }
+5 -4
View File
@@ -1,13 +1,14 @@
{ {
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"target": "es2022", "target": "esnext",
"module": "es2022", "module": "esnext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"declaration": true, "declaration": true,
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"strict": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true
} },
"exclude": ["vite.config.ts"]
} }
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite-plus";
import pkg from "./package.json" with { type: "json" };
export default defineConfig({
pack: {
entry: ["src/index.ts", "src/cli.ts"],
dts: true,
minify: true,
format: ["esm"],
target: "es2023",
deps: { alwaysBundle: ["**"] },
define: { VERSION: JSON.stringify(pkg.version) },
},
});
+1 -6
View File
@@ -8,18 +8,14 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check --tsconfig tsconfig.json", "check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker-rseidelsohn --summary > licenses.csv", "licenses": "license-checker-rseidelsohn --summary > licenses.csv",
"locale:download": "node scripts/locale.js",
"test:prepare": "pnpm run build" "test:prepare": "pnpm run build"
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@lokalise/node-api": "^13.2.1",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1", "@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@zerodevx/svelte-toast": "^0.9.6", "@zerodevx/svelte-toast": "^0.9.6",
"adm-zip": "^0.5.17",
"dotenv": "^17.4.2",
"license-checker-rseidelsohn": "^5.0.1", "license-checker-rseidelsohn": "^5.0.1",
"svelte": "^5.55.9", "svelte": "^5.55.9",
"svelte-check": "^4.4.8", "svelte-check": "^4.4.8",
@@ -29,9 +25,8 @@
"vite": "^8.0.14" "vite": "^8.0.14"
}, },
"dependencies": { "dependencies": {
"@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^5.2.7", "@fontsource/fira-mono": "^5.2.7",
"cryptgeon": "workspace:*",
"occulto": "^2.0.6",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"uqr": "^0.1.3" "uqr": "^0.1.3"
} }
-59
View File
@@ -1,59 +0,0 @@
import { LokaliseApi } from '@lokalise/node-api'
import AdmZip from 'adm-zip'
import dotenv from 'dotenv'
import https from 'https'
dotenv.config()
function exit(msg) {
console.error(msg)
process.exit(1)
}
const apiKey = process.env.LOKALISE_API_KEY
const project_id = process.env.LOKALISE_PROJECT
if (!apiKey) exit('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
if (!project_id) exit('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
const client = new LokaliseApi({ apiKey })
const WGet = (url) =>
new Promise((done) => {
https
.get(url, (res) => {
const data = []
res
.on('data', (chunk) => {
data.push(chunk)
})
.on('end', () => {
let buffer = Buffer.concat(data)
done(buffer)
})
})
.on('error', (err) => {
console.log('download error:', err)
})
})
async function download() {
// For details see: https://app.lokalise.com/api2docs/curl/#transition-download-files-post
const download = await client.files().download(project_id, {
format: 'json',
indentation: 'tab',
json_unescaped_slashes: true,
original_filenames: false,
bundle_structure: '%LANG_ISO%.%FORMAT%',
export_sort: 'first_added',
export_empty_as: 'skip',
add_newline_eof: true,
replace_breaks: false,
})
const buffered = await WGet(download.bundle_url)
const zip = new AdmZip(buffered)
zip.extractAllTo('./locales', true)
}
download().catch((e) => {
console.error(e)
process.exit(1)
})
+20 -3
View File
@@ -1,8 +1,25 @@
import { API, type Status } from 'cryptgeon/shared' import { status as apiStatus } from '@cryptgeon/shared'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
export const status = writable<null | Status>(null) export type StatusInfo = {
version: string
max_size: number
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_page_title: string
theme_favicon: string
theme_new_note_notice: boolean
theme_home_link: boolean
}
export const status = writable<null | StatusInfo>(null)
export async function init() { export async function init() {
status.set(await API.status()) status.set((await apiStatus()) as StatusInfo)
} }
@@ -4,10 +4,9 @@
import { status } from '$lib/stores/status' import { status } from '$lib/stores/status'
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextInput from '$lib/ui/TextInput.svelte' import TextInput from '$lib/ui/TextInput.svelte'
import type { Note } from 'cryptgeon/shared'
interface Props { interface Props {
note: Note note: { views: number; expiration: number }
timeExpiration?: boolean timeExpiration?: boolean
customPassword?: string | null customPassword?: string | null
} }
@@ -3,7 +3,7 @@
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte' import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from 'cryptgeon/shared' import type { FileDTO } from '@cryptgeon/shared'
interface Props { interface Props {
label?: string label?: string
@@ -16,9 +16,9 @@
async function fileToDTO(file: File): Promise<FileDTO> { async function fileToDTO(file: File): Promise<FileDTO> {
return { return {
name: file.name, name: file.name,
mime: file.type,
size: file.size, size: file.size,
type: file.type, data: new Uint8Array(await file.arrayBuffer()),
contents: new Uint8Array(await file.arrayBuffer()),
} }
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import type { FileDTO } from 'cryptgeon/shared' import type { FileDTO } from '@cryptgeon/shared'
interface Props { interface Props {
files: FileDTO[] files: FileDTO[]
@@ -12,7 +12,7 @@
let previewUrls: string[] = $state([]) let previewUrls: string[] = $state([])
$effect(() => { $effect(() => {
const urls = files.map((f) => URL.createObjectURL(new Blob([f.contents], { type: f.type }))) const urls = files.map((f) => URL.createObjectURL(new Blob([f.data.slice(0)], { type: f.mime })))
previewUrls = urls previewUrls = urls
return () => { return () => {
for (const url of urls) URL.revokeObjectURL(url) for (const url of urls) URL.revokeObjectURL(url)
@@ -36,12 +36,12 @@
<div class="files-grid"> <div class="files-grid">
{#each files as entry, index} {#each files as entry, index}
<div class="file-preview"> <div class="file-preview">
{#if isImage(entry.type)} {#if isImage(entry.mime)}
<img src={previewUrls[index]} class="preview-img" alt={entry.name} /> <img src={previewUrls[index]} class="preview-img" alt={entry.name} />
{:else} {:else}
<div class="file-icon"> <div class="file-icon">
<div class="file-extension"> <div class="file-extension">
{entry.name.split('.').pop()?.toUpperCase() || entry.type} {entry.name.split('.').pop()?.toUpperCase() || entry.mime}
</div> </div>
</div> </div>
{/if} {/if}
+10 -8
View File
@@ -1,5 +1,8 @@
<script lang="ts" module> <script lang="ts" module>
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any } export type DecryptedNote = {
meta: { type: 'text' | 'file' }
contents: any
}
function saveAs(file: File) { function saveAs(file: File) {
const url = window.URL.createObjectURL(file) const url = window.URL.createObjectURL(file)
@@ -20,7 +23,7 @@
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils' import { copy } from '$lib/utils'
import type { FileDTO, NotePublic } from 'cryptgeon/shared' import type { FileDTO } from '@cryptgeon/shared'
interface Props { interface Props {
note: DecryptedNote note: DecryptedNote
@@ -32,9 +35,8 @@
let files: FileDTO[] = $state([]) let files: FileDTO[] = $state([])
async function downloadFile(file: FileDTO) { async function downloadFile(file: FileDTO) {
// @ts-ignore const f = new File([file.data.slice(0)], file.name, {
const f = new File([file.contents], file.name, { type: file.mime,
type: file.type,
}) })
saveAs(f) saveAs(f)
} }
@@ -78,12 +80,12 @@
<button onclick={() => downloadFile(file)}> <button onclick={() => downloadFile(file)}>
<b>↓ {file.name}</b> <b>↓ {file.name}</b>
</button> </button>
<small> {file.type} - {prettyBytes(file.size)}</small> <small> {file.mime} - {prettyBytes(file.size ?? file.data.length)}</small>
</div> </div>
{#if file.type.startsWith('image/')} {#if file.mime.startsWith('image/')}
{#key file.name} {#key file.name}
<img <img
src={URL.createObjectURL(new File([file.contents], file.name, { type: file.type }))} src={URL.createObjectURL(new File([file.data.slice(0)], file.name, { type: file.mime }))}
alt={file.name} alt={file.name}
class="preview" class="preview"
/> />
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/ui/Icon.svelte' import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils' import { copy as copyFN } from '$lib/utils'
import { getRandomBytes, Hex } from 'occulto' import { randomBytes, bytesToHex } from '@cryptgeon/shared'
import type { HTMLInputAttributes } from 'svelte/elements' import type { HTMLInputAttributes } from 'svelte/elements'
interface Props { interface Props {
@@ -35,7 +35,7 @@
} }
async function randomFN() { async function randomFN() {
value = Hex.encode(await getRandomBytes(32)) value = bytesToHex(randomBytes(32))
} }
</script> </script>
+40 -38
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { AES, Hex } from 'occulto' import {
deriveKey, generateKey, encrypt, randomBytes,
bytesToHex, encode, compress,
create as apiCreate,
type FileDTO, type ServerNote
} from '@cryptgeon/shared'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import { blur } from 'svelte/transition' import { blur } from 'svelte/transition'
@@ -14,14 +19,8 @@
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte' import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte' import TextArea from '$lib/ui/TextArea.svelte'
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared'
let note: Note = $state({ let note: { views: number; expiration: number } = $state({ views: 1, expiration: 60 })
contents: '',
meta: { type: 'text' },
views: 1,
expiration: 60,
})
let files: FileDTO[] = $state([]) let files: FileDTO[] = $state([])
let result: NoteResult | null = $state(null) let result: NoteResult | null = $state(null)
let advanced = $state(false) let advanced = $state(false)
@@ -31,6 +30,7 @@
let description = $state('') let description = $state('')
let loading: string | null = $state(null) let loading: string | null = $state(null)
let isPasting = $state(false) let isPasting = $state(false)
let textContent = $state('')
$effect(() => { $effect(() => {
if (!advanced) { if (!advanced) {
@@ -50,13 +50,7 @@
}) })
$effect(() => { $effect(() => {
note.meta.type = isFile ? 'file' : 'text' if (!isFile) textContent = ''
})
$effect(() => {
if (!isFile) {
note.contents = ''
}
}) })
async function handlePaste(e: ClipboardEvent) { async function handlePaste(e: ClipboardEvent) {
@@ -98,11 +92,12 @@
const name = const name =
file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}` file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`
const renamed = new File([file], name, { type: file.type }) const renamed = new File([file], name, { type: file.type })
const data = new Uint8Array(await renamed.arrayBuffer())
return { return {
name: renamed.name, name: renamed.name,
mime: renamed.type,
size: renamed.size, size: renamed.size,
type: renamed.type, data,
contents: new Uint8Array(await renamed.arrayBuffer()),
} }
}) })
) )
@@ -122,40 +117,47 @@
try { try {
loading = $t('common.encrypting') loading = $t('common.encrypting')
const derived = customPassword && (await AES.derive(customPassword)) const salt = customPassword ? randomBytes(16) : null
const key = derived ? derived[0] : await AES.generateKey() const key = customPassword
? deriveKey(customPassword, salt!)
: generateKey()
const data: Note = { let inner: Uint8Array
contents: '',
meta: note.meta,
}
if (derived) data.meta.derivation = derived[1]
if (isFile) { if (isFile) {
if (files.length === 0) throw new EmptyContentError() if (files.length === 0) throw new EmptyContentError()
data.contents = await Adapters.Files.encrypt(files, key) inner = encode({ type: 'files', data: files })
} else { } else {
if (note.contents === '') throw new EmptyContentError() if (textContent === '') throw new EmptyContentError()
data.contents = await Adapters.Text.encrypt(note.contents, key) inner = encode({ type: 'text', data: textContent })
}
const originalSize =inner.byteLength
const compressed = compress(inner)
const compresseedSize= compressed.byteLength
console.debug({originalSize, compresseedSize, ratio: originalSize/compresseedSize})
const data = encrypt(compress(inner), key)
const extra = customPassword
? encode({ salt: salt!, N: 32768, r: 8, p: 1 })
: new Uint8Array()
const serverNote: ServerNote = {
meta: {
...(timeExpiration ? { expiration: parseInt(note.expiration as any) } : { views: parseInt(note.views as any) }),
extra,
},
data,
} }
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
else data.views = parseInt(note.views as any)
loading = $t('common.uploading') loading = $t('common.uploading')
const response = await API.create(data) const response = await apiCreate(serverNote)
result = { result = {
id: response.id, id: response.id,
password: customPassword ? undefined : Hex.encode(key), password: customPassword ? undefined : bytesToHex(key),
} }
notify.success($t('home.messages.note_created')) notify.success($t('home.messages.note_created'))
} catch (e) { } catch (e) {
if (e instanceof PayloadToLargeError) {
notify.error($t('home.errors.note_too_big'))
} else if (e instanceof EmptyContentError) {
notify.error($t('home.errors.empty_content'))
} else {
console.error(e) console.error(e)
notify.error($t('home.errors.note_error')) notify.error($t('home.errors.note_error'))
}
} finally { } finally {
loading = null loading = null
} }
@@ -183,7 +185,7 @@
<TextArea <TextArea
data-testid="text-field" data-testid="text-field"
label={$t('common.note')} label={$t('common.note')}
bind:value={note.contents} bind:value={textContent}
placeholder="..." placeholder="..."
/> />
{/if} {/if}
@@ -45,17 +45,6 @@
</span> </span>
</AboutParagraph> </AboutParagraph>
<AboutParagraph title="translations">
<span
>translations are managed on <a
href="https://lokalise.com/"
target="_blank"
rel="noopener noreferrer">Lokalise</a
>, which granted an open source license to use the paid version. If you are interested in
helping translating don't hesitate to contact me!
</span>
</AboutParagraph>
<AboutParagraph title="attribution"> <AboutParagraph title="attribution">
<span> <span>
icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { AES, Hex } from 'occulto' import { deriveKey, hexToBytes, decrypt, decode, decompress, info, get as apiGet, type FileDTO } from '@cryptgeon/shared'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
@@ -7,7 +7,6 @@
import Loader from '$lib/ui/Loader.svelte' import Loader from '$lib/ui/Loader.svelte'
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte' import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
import TextInput from '$lib/ui/TextInput.svelte' import TextInput from '$lib/ui/TextInput.svelte'
import { Adapters, API, type NoteMeta } from 'cryptgeon/shared'
import type { PageData } from './$types' import type { PageData } from './$types'
interface Props { interface Props {
@@ -20,7 +19,7 @@
let password: string | null = $state<string | null>(null) let password: string | null = $state<string | null>(null)
let note: DecryptedNote | null = $state(null) let note: DecryptedNote | null = $state(null)
let exists = $state(false) let exists = $state(false)
let meta: NoteMeta | null = $state(null) let hasExtra = $state(false)
let loading: string | null = $state(null) let loading: string | null = $state(null)
let error: string | null = $state(null) let error: string | null = $state(null)
@@ -28,13 +27,16 @@
let valid = $derived(!!password?.length) let valid = $derived(!!password?.length)
onMount(async () => { onMount(async () => {
// Check if note exists
try { try {
loading = $t('common.loading') loading = $t('common.loading')
password = window.location.hash.slice(1) password = window.location.hash.slice(1)
const note = await API.info(id) const meta = await info(id)
meta = note.meta if (meta) {
hasExtra = !!meta.extra?.length
exists = true exists = true
} else {
exists = false
}
} catch { } catch {
exists = false exists = false
} finally { } finally {
@@ -42,9 +44,6 @@
} }
}) })
/**
* Get the actual contents of the note and decrypt it.
*/
async function show(e: SubmitEvent) { async function show(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
try { try {
@@ -53,24 +52,41 @@
return return
} }
// Load note
error = null error = null
loading = $t('common.downloading') loading = $t('common.downloading')
const data = await API.get(id) const serverNote = await apiGet(id)
if (!serverNote) {
error = $t('show.errors.not_found')
return
}
loading = $t('common.decrypting') loading = $t('common.decrypting')
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation)) let key: Uint8Array
const key = derived ? derived[0] : Hex.decode(password!) if (hasExtra && serverNote.meta.extra && serverNote.meta.extra.length > 0) {
switch (data.meta.type) { const derivation = decode(serverNote.meta.extra) as any
key = deriveKey(password!, new Uint8Array(derivation.salt))
} else {
key = hexToBytes(password!)
}
const decrypted = decrypt(serverNote.data, key)
const content = decode(decompress(decrypted)) as any
switch (content.type) {
case 'text': case 'text':
note = { note = {
meta: { type: 'text' }, meta: { type: 'text' },
contents: await Adapters.Text.decrypt(data.contents, key), contents: content.data,
} }
break break
case 'file': case 'files':
const files = (content.data as any[]).map((f: any) => ({
...f,
data: f.data instanceof Uint8Array ? f.data : new Uint8Array(f.data as any),
}))
note = { note = {
meta: { type: 'file' }, meta: { type: 'file' },
contents: await Adapters.Files.decrypt(data.contents, key), contents: files,
} }
break break
default: default:
@@ -94,7 +110,7 @@
<form onsubmit={show}> <form onsubmit={show}>
<fieldset> <fieldset>
<p>{$t('show.explanation')}</p> <p>{$t('show.explanation')}</p>
{#if meta?.derivation} {#if hasExtra}
<TextInput <TextInput
data-testid="show-note-password" data-testid="show-note-password"
type="password" type="password"
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@cryptgeon/shared",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"lz4js": "^0.2.0"
},
"devDependencies": {
"@tsconfig/strictest": "catalog:",
"@types/lz4js": "^0.2.2",
"typescript": "catalog:",
"vitest": "^4.1.11"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { encode, decode } from '@msgpack/msgpack'
import { setServer, getServer, create, info, get, status } from './api'
const server = 'http://example.test'
const created = encode({ id: 'abc123' })
const metaOut = encode({ meta: { views: 3, extra: Buffer.from('040506','hex') } })
const dataOut = encode({ meta: { views: 0 },data: Buffer.from('090909','hex') })
function mockFetch(body: Uint8Array) {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
json: async () => ({}),
})
}
function copyBuffer(buf: Uint8Array) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
}
describe('api client', () => {
it('setServer trims trailing slashes', () => {
setServer('http://x.test///')
expect(getServer()).toBe('http://x.test')
})
it('create POSTs msgpack note and returns id', async () => {
setServer(server)
const fetchMock = mockFetch(created)
vi.stubGlobal('fetch', fetchMock)
const note = { meta: { views: 5 },data: Buffer.from('010203','hex') }
const result = await create(note)
const url = fetchMock.mock.calls[0]![0]!
const init = fetchMock.mock.calls[0]![1]!
expect(url).toBe(server + '/api/v3/notes')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({ 'content-type': 'application/msgpack' })
const sent = decode(new Uint8Array(copyBuffer(init.body))) as { meta?: { views?: number } }
expect(sent?.meta?.views).toBe(5)
expect(result).toEqual({ id: 'abc123' })
vi.unstubAllGlobals()
})
it('info GETs meta', async () => {
setServer(server)
const fetchMock = mockFetch(metaOut)
vi.stubGlobal('fetch', fetchMock)
const result = await info('id1')
expect(result?.views).toBe(3)
vi.unstubAllGlobals()
})
it('get DELETEs and parses data', async () => {
setServer(server)
const fetchMock = mockFetch(dataOut)
vi.stubGlobal('fetch', fetchMock)
const result = await get('id2')
expect(result?.meta?.views).toBe(0)
const init = fetchMock.mock.calls[0]![1]!
expect(init.method).toBe('DELETE')
vi.unstubAllGlobals()
})
it('status GETs JSON config', async () => {
setServer(server)
const fetchMock = mockFetch(new Uint8Array())
vi.stubGlobal('fetch', fetchMock)
await status()
const url = fetchMock.mock.calls[0]![0]!
expect(url).toBe(server + '/api/v3/status')
vi.unstubAllGlobals()
})
})
+58
View File
@@ -0,0 +1,58 @@
import { encode, decode } from "@msgpack/msgpack";
import type { ServerNote, Status } from "./types.js";
let server = "";
export function setServer(url: string) {
server = url.replace(/\/+$/, "");
}
export function getServer() {
return server;
}
function api(path: string) {
return `${server}/api/v3/${path}`;
}
export async function create(note: ServerNote): Promise<{ id: string }> {
const res = await fetch(api("notes"), {
method: "POST",
headers: { "content-type": "application/msgpack" },
body: encode(note),
});
if (!res.ok) throw new Error("create failed");
const buf = await res.arrayBuffer();
const data = decode(new Uint8Array(buf)) as any;
if (typeof data?.id !== "string") throw new Error("invalid response");
return { id: data.id };
}
export async function info(id: string): Promise<ServerNote["meta"] | null> {
const res = await fetch(api(`notes/${id}`));
if (!res.ok) return null;
const buf = await res.arrayBuffer();
const data = decode(new Uint8Array(buf)) as any;
const meta = data?.meta as ServerNote["meta"] | undefined;
if (!meta) return null;
if (meta.extra && !(meta.extra instanceof Uint8Array)) meta.extra = new Uint8Array(meta.extra as any);
return meta;
}
export async function get(id: string): Promise<ServerNote | null> {
const res = await fetch(api(`notes/${id}`), { method: "DELETE" });
if (!res.ok) return null;
const buf = await res.arrayBuffer();
const data = decode(new Uint8Array(buf)) as any;
const meta = data.meta as ServerNote["meta"];
if (meta?.extra && !(meta.extra instanceof Uint8Array)) meta.extra = new Uint8Array(meta.extra as any);
const d = data.data;
return { meta, data: d instanceof Uint8Array ? d : new Uint8Array(d) } satisfies ServerNote;
}
export async function status(): Promise<Status> {
const res = await fetch(api("status"));
if (!res.ok) throw new Error("status failed");
return res.json();
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { compress, decompress, utf8ToBytes } from "./index";
describe("compression", () => {
it("round-trips small text", () => {
const data = utf8ToBytes("hello world");
const compressed = compress(data);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
it("round-trips highly compressible data", () => {
const data = utf8ToBytes("a".repeat(10_000));
const compressed = compress(data);
expect(compressed.length).toBeLessThan(data.length);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
it("round-trips arbitrary bytes", () => {
const data = new Uint8Array([0, 128, 255, 1, 2, 3, 200, 100]);
const compressed = compress(data);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
});
+9
View File
@@ -0,0 +1,9 @@
import LZ4 from "lz4js";
export function compress(data: Uint8Array): Uint8Array {
return LZ4.compress(data);
}
export function decompress(data: Uint8Array): Uint8Array {
return LZ4.decompress(data);
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { deriveKey, encrypt, decrypt, generateKey, utf8ToBytes, randomBytes } from "./crypto";
describe("crypto", () => {
it("encrypts and decrypts with generated key", () => {
const data = utf8ToBytes("hello world");
const key = generateKey();
const enc = encrypt(data, key);
const dec = decrypt(enc, key);
expect(dec).toEqual(data);
});
it("encrypts and decrypts with derived key", () => {
const data = utf8ToBytes("secret message");
const salt = randomBytes(16);
const key = deriveKey("password123", salt);
const enc = encrypt(data, key);
const dec = decrypt(enc, key);
expect(dec).toEqual(data);
});
it("derived key has same length as generated", () => {
const salt = randomBytes(16);
expect(deriveKey("test", salt).length).toBe(generateKey().length);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { managedNonce, randomBytes } from "@noble/ciphers/utils.js";
import { scrypt } from "@noble/hashes/scrypt.js";
export {
bytesToUtf8,
utf8ToBytes,
hexToBytes,
bytesToHex,
randomBytes,
} from "@noble/ciphers/utils.js";
const N = 2 ** 15;
const KEY_SIZE = 32;
export function generateKey(): Uint8Array {
return randomBytes(KEY_SIZE);
}
export function deriveKey(password: string, salt: Uint8Array): Uint8Array {
return scrypt(password, salt, { N, r: 8, p: 1, dkLen: KEY_SIZE });
}
export function encrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
const chacha = managedNonce(xchacha20poly1305)(key);
return chacha.encrypt(data);
}
export function decrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
const chacha = managedNonce(xchacha20poly1305)(key);
return chacha.decrypt(data);
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./crypto.js";
export * from "./types.js";
export * from "./api.js";
export * from "./compression.js";
export { encode, decode } from "@msgpack/msgpack";
+38
View File
@@ -0,0 +1,38 @@
export type NoteMeta = {
expiration?: number;
views?: number;
extra?: Uint8Array;
};
export type ServerNote = {
meta: NoteMeta;
data: Uint8Array;
};
export type NoteContent =
| { type: "text"; data: string }
| { type: "files"; data: FileDTO[] };
export type FileDTO = {
name: string;
mime: string;
size: number;
data: Uint8Array;
};
export type Status = {
version: string;
max_size: number;
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_page_title: string;
theme_favicon: string;
theme_new_note_notice: boolean;
theme_home_link: boolean;
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});
+2275 -2507
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1,6 +1,11 @@
packages: packages:
- "packages/**" - "packages/**"
catalog:
vite-plus: ^0.3.0
typescript: ^7.0.2
"@tsconfig/strictest": ^2.0.8
allowBuilds: allowBuilds:
esbuild: true esbuild: true
+5 -2
View File
@@ -33,7 +33,10 @@ async function createNote(page: Page, options: CreatePage): Promise<void> {
await fileChooser.setFiles(options.files) await fileChooser.setFiles(options.files)
} }
if (options.views || options.expiration || options.password) await page.getByTestId('switch-advanced').click() if (options.views || options.expiration || options.password) {
await page.getByTestId('switch-advanced').waitFor({ state: 'visible', timeout: 10000 })
await page.getByTestId('switch-advanced').click()
}
if (options.views) { if (options.views) {
await page.getByTestId('field-views').fill(options.views.toString()) await page.getByTestId('field-views').fill(options.views.toString())
} }
@@ -97,7 +100,7 @@ export async function checkLinkDoesNotExist(page: Page, link: string) {
} }
export async function CLI(...args: string[]) { export async function CLI(...args: string[]) {
return await exec('./packages/cli/dist/cli.cjs', args, { return await exec('./packages/cli/dist/cli.mjs', args, {
env: { env: {
...process.env, ...process.env,
CRYPTGEON_SERVER: 'http://localhost:3000', CRYPTGEON_SERVER: 'http://localhost:3000',
+8 -6
View File
@@ -1,10 +1,9 @@
#!/usr/bin/env node #!/usr/bin/env node
import shelljs from 'shelljs' import { readFileSync, writeFileSync } from 'node:fs'
import { execSync } from 'node:child_process' import { execSync } from 'node:child_process'
const VERSION = process.argv[2] const VERSION = process.argv[2]
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
const semver = const semver =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm
if (!semver.test(VERSION)) { if (!semver.test(VERSION)) {
@@ -12,9 +11,12 @@ if (!semver.test(VERSION)) {
process.exit(1) process.exit(1)
} }
// CLI function sed(file, pattern, replacement) {
shelljs.sed('-i', /"version": ".*"/, `"version": "${process.argv[2]}"`, './packages/cli/package.json') const content = readFileSync(file, 'utf-8')
writeFileSync(file, content.replace(pattern, replacement))
}
sed('./packages/cli/package.json', /"version": ".*"/, `"version": "${VERSION}"`)
sed('./packages/backend/Cargo.toml', /^version = ".*"$/m, `version = "${VERSION}"`)
// Backend
shelljs.sed('-i', /^version = ".*"$/m, `version = "${process.argv[2]}"`, './packages/backend/Cargo.toml')
execSync('cargo check -p cryptgeon', { cwd: './packages/backend' }) execSync('cargo check -p cryptgeon', { cwd: './packages/backend' })