mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-09-27 13:01:46 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c33f2f34c |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -10,35 +10,32 @@ jobs:
|
|||||||
cli:
|
cli:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v4
|
||||||
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
|
pnpm install --frozen-lockfile
|
||||||
pnpm --filter cryptgeon build
|
pnpm run build
|
||||||
|
|
||||||
- name: Publish to npm
|
- run: npm publish
|
||||||
run: |
|
working-directory: ./packages/cli
|
||||||
DIST_TAG=latest
|
|
||||||
if [[ "${GITHUB_REF_NAME}" == *"-"* ]]; then
|
|
||||||
DIST_TAG=rc
|
|
||||||
fi
|
|
||||||
pnpm publish --filter cryptgeon --tag "${DIST_TAG}" --no-git-checks
|
|
||||||
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@v7
|
- uses: actions/checkout@v4
|
||||||
- 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
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
@@ -22,22 +22,19 @@ 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@v7
|
- uses: actions/upload-artifact@v4
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
|
|||||||
-196
@@ -5,183 +5,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased] — v3 (major rewrite)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- New shared TypeScript package `@cryptgeon/shared` as single source of truth for crypto, content codec and API client (crypto + compression + payload + types).
|
|
||||||
- Shared payload codec: `packContent` / `unpackContent` (encode → LZ4 → XChaCha20-Poly1305 and reverse).
|
|
||||||
- New `pg`-backend storage of note hashes in the cache.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Encryption from AES to **XChaCha20-Poly1305** (client-side); dropped `occulto`.
|
|
||||||
- All API bodies switched to **MessagePack**.
|
|
||||||
- Frontend migrated to SvelteKit + `@cryptgeon/shared`.
|
|
||||||
- CLI rebuilt with `vite-plus` (bundles all deps) and imports from `@cryptgeon/shared`.
|
|
||||||
|
|
||||||
### Breaking changes
|
|
||||||
|
|
||||||
- Endpoints moved to `/api/v3/notes/` and `/api/v3/status`; health check to `/healthz`.
|
|
||||||
- `meta.extra` holds client-opaque data (e.g. scrypt derivation params), size-limited (`EXTRA_SIZE_LIMIT`, default 512 bytes).
|
|
||||||
- Inner payload is msgpack: `{ type: "text", data }` or `{ type: "files", data: [{ name, mime, size, data }] }`.
|
|
||||||
- Env renames: `REDIS` → `CACHE`, `REDIS_PREFIX` → `CACHE_PREFIX`; new `EXTRA_SIZE_LIMIT`.
|
|
||||||
- Docker `redis` service → `cache`; healthcheck → `http://127.0.0.1:8000/healthz`; image stays `valkey/valkey:7-alpine` (swap for any RESP-compatible).
|
|
||||||
- Storage switched to cache hashes with atomic `HINCRBY` view counting; the per-note lock (`lock.rs`) is removed.
|
|
||||||
- Notes can have **both** `views` and `expiration` set simultaneously.
|
|
||||||
- v2 notes are **not migrated**: flush the cache before deploying v3; v2/v3 notes are not interoperable.
|
|
||||||
|
|
||||||
## [2.9.3] - 2026-06-25
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Basic file drag-and-drop support.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Publish the Docker image to GitHub Container Registry (ghcr).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- #207: keep audio/other file mime types intact.
|
|
||||||
- Localization key typo `note_to_big` → `note_too_big`.
|
|
||||||
|
|
||||||
## [2.9.2] - 2026-06-07
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Image paste support.
|
|
||||||
- Czech translation.
|
|
||||||
- `THEME_HOME_LINK` environment variable.
|
|
||||||
- Docker compose: prevent anonymous volume creation.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Replace Redis with Valkey in docker-compose files.
|
|
||||||
- Rust 2024 edition compat, watchexec and axum 0.8 updates.
|
|
||||||
- Switched license checker package.
|
|
||||||
- Frontend cleanup and readme/docs cleanup.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Updated dependencies (ring, npm_and_yarn group).
|
|
||||||
|
|
||||||
## [2.9.1] - 2025-02-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Docs about running Redis in RAM-only mode.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Password eye toggle not working.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Updated dependencies.
|
|
||||||
|
|
||||||
## [2.9.0] - 2025-01-18
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Frontend rework: migrate to Svelte 5.
|
|
||||||
- Update Redis documentation link in compose.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fix race condition on the delete endpoint by introducing locks to guarantee the view counter.
|
|
||||||
|
|
||||||
## [2.8.4] - 2025-01-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Chinese (zh-TW) translations.
|
|
||||||
- Basic auth example (nginx).
|
|
||||||
|
|
||||||
## [2.8.3] - 2024-09-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Options to add an imprint: `IMPRINT_URL`, `IMPRINT_HTML`.
|
|
||||||
|
|
||||||
## [2.8.2] - 2024-09-20
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Raycast extension links.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Add `type="button"` to form elements.
|
|
||||||
- Bump pnpm version.
|
|
||||||
|
|
||||||
## [2.8.1] - 2024-09-02
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Move shared package into the CLI.
|
|
||||||
- Add a guide.
|
|
||||||
|
|
||||||
## [2.8.0] - 2024-08-27
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Migrate backend from actix to axum (major refactor).
|
|
||||||
- More robust config, body limit via axum.
|
|
||||||
- Use container for test pipeline; skip size/expiration quirks in Safari.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Typos in English localization.
|
|
||||||
|
|
||||||
## [2.7.0] - 2024-08-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Better programmatic access to the shared client.
|
|
||||||
- Redis TLS feature, dynamically-linked and native musl targets.
|
|
||||||
- French blog post and improved French translations.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Bump redis crate to 0.25.2.
|
|
||||||
|
|
||||||
## [2.6.1] - 2024-05-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Polish translation.
|
|
||||||
|
|
||||||
## [2.6.0] - 2024-03-24
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `ALLOW_FILES` flag.
|
|
||||||
- `NEW_NOTE_NOTICE` → `THEME_NEW_NOTE_NOTICE` theme flag.
|
|
||||||
- French translation update.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Reset form when clicking the logo after creating a note.
|
|
||||||
|
|
||||||
## [2.5.1] - 2024-03-04
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Reset translation.
|
|
||||||
- German (`de`) translation update.
|
|
||||||
|
|
||||||
## [2.5.0] - 2024-03-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Expose internal shared functionality for external/programmatic usage.
|
|
||||||
- German translation updates.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Updated dependencies (zerocopy).
|
|
||||||
|
|
||||||
## [2.4.0] - 2023-11-01
|
## [2.4.0] - 2023-11-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -189,23 +12,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Removed HTML sanitation, display the original message as string
|
- Removed HTML sanitation, display the original message as string
|
||||||
- Links are now displayed under the note in a separate section
|
- Links are now displayed under the note in a separate section
|
||||||
|
|
||||||
## [2.3.3] - 2023-08-15
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Maintenance.
|
|
||||||
- Updated dependencies.
|
|
||||||
|
|
||||||
## [2.3.2] - 2023-08-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Spanish readme (`README_ES.md`).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Translation and grammar fixes (en, de, de, es).
|
|
||||||
|
|
||||||
## [2.3.1] - 2023-06-23
|
## [2.3.1] - 2023-06-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -224,8 +30,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- Moved to monorepo.
|
- Moved to monorepo.
|
||||||
|
|
||||||
## [2.2.0] - 2023-01-14
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Default port is now 8000, not 5000.
|
- Default port is now 8000, not 5000.
|
||||||
|
|||||||
+2
-2
@@ -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 cache)
|
- docker or [colima](https://github.com/abiosoft/colima) (for redis)
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ pnpm install
|
|||||||
pnpm run dev
|
pnpm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,614 @@
|
|||||||
|
{
|
||||||
|
"info": {
|
||||||
|
"_postman_id": "3aaeac19-4eac-4911-b3c8-912b17a48634",
|
||||||
|
"name": "Cryptgeon",
|
||||||
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
|
},
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Notes",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Preview",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/:id",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ":id"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "{{NOTE_ID}}",
|
||||||
|
"description": "Id of the Note"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "This endpoint is to query wether a note exists, without actually opening it. No view limits are used here, as contents of the note are not available, only the `meta` field is returned, which is public."
|
||||||
|
},
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"name": "200",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/:id",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ":id"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "{{NOTE_ID}}",
|
||||||
|
"description": "Id of the Note"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "OK",
|
||||||
|
"code": 200,
|
||||||
|
"_postman_previewlanguage": "json",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:24:29 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": "{}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "404",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/:id",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ":id"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "{{NOTE_ID}}",
|
||||||
|
"description": "Id of the Note"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "Not Found",
|
||||||
|
"code": 404,
|
||||||
|
"_postman_previewlanguage": "plain",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:25:26 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Create",
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"exec": [
|
||||||
|
"const jsonData = pm.response.json();",
|
||||||
|
"pm.collectionVariables.set('NOTE_ID', jsonData.id)",
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"type": "text/javascript"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"name": "Simple",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "OK",
|
||||||
|
"code": 200,
|
||||||
|
"_postman_previewlanguage": "json",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:31:54 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "5 Minutes",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"expiration\": 5,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "OK",
|
||||||
|
"code": 200,
|
||||||
|
"_postman_previewlanguage": "json",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:31:54 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "3 Views",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [],
|
||||||
|
"body": {
|
||||||
|
"mode": "raw",
|
||||||
|
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 3,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
|
||||||
|
"options": {
|
||||||
|
"raw": {
|
||||||
|
"language": "json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "OK",
|
||||||
|
"code": 200,
|
||||||
|
"_postman_previewlanguage": "json",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:31:54 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Read",
|
||||||
|
"request": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/:id",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ":id"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "{{NOTE_ID}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "This endpoint gets the actual contents of a note. It's a `DELETE` endpoint, es it decreases the `view` counter, and deletes the note if `0` is reached."
|
||||||
|
},
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"name": "200",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/:id",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ":id"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "{{NOTE_ID}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "OK",
|
||||||
|
"code": 200,
|
||||||
|
"_postman_previewlanguage": "json",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:59:07 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": "{\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\",\n \"contents\": \"Some encrypted content\"\n}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "404",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "DELETE",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/notes/:id",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["notes", ":id"],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "id",
|
||||||
|
"value": "{{NOTE_ID}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "Not Found",
|
||||||
|
"code": 404,
|
||||||
|
"_postman_previewlanguage": "plain",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:59:15 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Status",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Get server status",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/status/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["status", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"name": "200",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/status/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["status", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "OK",
|
||||||
|
"code": 200,
|
||||||
|
"_postman_previewlanguage": "json",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "connection",
|
||||||
|
"value": "close"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-type",
|
||||||
|
"value": "application/json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Tue, 23 May 2023 05:56:45 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": "{\n \"version\": \"2.3.0-beta.4\",\n \"max_size\": 10485760,\n \"max_views\": 100,\n \"max_expiration\": 360,\n \"allow_advanced\": true,\n \"theme_image\": \"\",\n \"theme_text\": \"\",\n \"theme_page_title\": \"\",\n \"theme_favicon\": \"\"\n}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Health Check",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/live/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["live", ""]
|
||||||
|
},
|
||||||
|
"description": "Return `200` for healthy service. `503` if service is unavailable."
|
||||||
|
},
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"name": "Healthy",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/live/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["live", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "OK",
|
||||||
|
"code": 200,
|
||||||
|
"_postman_previewlanguage": "plain",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Thu, 22 Jun 2023 20:17:58 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Service Unavilable",
|
||||||
|
"originalRequest": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{BASE}}/live/",
|
||||||
|
"host": ["{{BASE}}"],
|
||||||
|
"path": ["live", ""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "Service Unavailable",
|
||||||
|
"code": 503,
|
||||||
|
"_postman_previewlanguage": "plain",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "transfer-encoding",
|
||||||
|
"value": "chunked"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "content-encoding",
|
||||||
|
"value": "gzip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "vary",
|
||||||
|
"value": "accept-encoding"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "date",
|
||||||
|
"value": "Thu, 22 Jun 2023 20:18:55 GMT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cookie": [],
|
||||||
|
"body": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"event": [
|
||||||
|
{
|
||||||
|
"listen": "prerequest",
|
||||||
|
"script": {
|
||||||
|
"type": "text/javascript",
|
||||||
|
"exec": [""]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"listen": "test",
|
||||||
|
"script": {
|
||||||
|
"type": "text/javascript",
|
||||||
|
"exec": [""]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"variable": [
|
||||||
|
{
|
||||||
|
"key": "BASE",
|
||||||
|
"value": "http://localhost:3000/api",
|
||||||
|
"type": "default"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "NOTE_ID",
|
||||||
|
"value": "",
|
||||||
|
"type": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -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 CACHE="redis://cache/"
|
ENV REDIS="redis://redis/"
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
ENTRYPOINT [ "/app/cryptgeon" ]
|
ENTRYPOINT [ "/app/cryptgeon" ]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<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/>
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ _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
|
||||||
|
|
||||||
@@ -55,12 +58,12 @@ There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cry
|
|||||||
|
|
||||||
each note has a generated <code>id (256bit)</code> and <code>key 256(bit)</code>. The
|
each note has a generated <code>id (256bit)</code> and <code>key 256(bit)</code>. The
|
||||||
<code>id</code>
|
<code>id</code>
|
||||||
is used to save & retrieve the note. the note is then encrypted with XChaCha20-Poly1305 on the
|
is used to save & retrieve the note. the note is then encrypted with aes in gcm mode on the
|
||||||
client side with the <code>key</code> and then sent to the server. data is stored in memory and
|
client side with the <code>key</code> and then sent to the server. data is stored in memory and
|
||||||
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 cache 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 Redis instance can run into race conditions, where a note might be retrieved more than the view count allows.
|
||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||
@@ -68,32 +71,31 @@ of the notes even if it tried to.
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `CACHE` | `redis://cache/` | Cache URL (valkey or redis) to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
| `REDIS` | `redis://redis/` | Redis URL 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> Payloads are raw bytes (msgpack + cipher), so the frontend shows the full limit. |
|
| `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_. |
|
||||||
| `CACHE_PREFIX` | `""` | Optional prefix for all cache keys. Useful when sharing a cache instance with other apps via ACL namespaces. |
|
| `REDIS_PREFIX` | `""` | Optional prefix for all Redis keys. Useful when sharing a Redis instance with other apps via ACL namespaces. |
|
||||||
| `EXTRA_SIZE_LIMIT` | `512` | Maximum size in bytes of the opaque `extra` payload (e.g. key derivation params) stored on the note metadata. |
|
| `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 |
|
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
|
||||||
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
|
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
||||||
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
|
||||||
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
|
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
|
||||||
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
|
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
|
||||||
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
|
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
|
||||||
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
> ℹ️ `https` is required otherwise browsers will not support the cryptographic functions.
|
> ℹ️ `https` is required otherwise browsers will not support the cryptographic functions.
|
||||||
|
|
||||||
> ℹ️ There is a health endpoint available at `/healthz`. It returns either 200 or 503.
|
> ℹ️ There is a health endpoint available at `/api/health/`. It returns either 200 or 503.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -102,22 +104,24 @@ Docker is the easiest way. There is the [official image here](https://hub.docker
|
|||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# docker-compose.yml
|
||||||
|
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
image: valkey/valkey:7-alpine
|
image: redis: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: redis-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://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
# https://redis.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:
|
||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
environment:
|
environment:
|
||||||
# Size limit for a single note.
|
# Size limit for a single note.
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
@@ -126,7 +130,7 @@ services:
|
|||||||
|
|
||||||
# Optional health checks
|
# Optional health checks
|
||||||
# healthcheck:
|
# healthcheck:
|
||||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
|
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
||||||
# interval: 1m
|
# interval: 1m
|
||||||
# timeout: 3s
|
# timeout: 3s
|
||||||
# retries: 2
|
# retries: 2
|
||||||
|
|||||||
+29
-30
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<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
|
||||||
@@ -21,6 +22,8 @@ _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
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/RE
|
|||||||
|
|
||||||
Se genera una <code>id (256bit)</code> y una <code>llave 256(bit)</code> para cada nota. La
|
Se genera una <code>id (256bit)</code> y una <code>llave 256(bit)</code> para cada nota. La
|
||||||
<code>id</code>
|
<code>id</code>
|
||||||
se usa para guardar y recuperar la nota. Después la nota es encriptada con XChaCha20-Poly1305 del lado del cliente y por último se envía al servidor. La información es almacenada en memoria y nunca persiste en el disco. El servidor nunca ve la llave de encriptación por lo que no puede desencriptar el contenido de las notas aunque lo intentara.
|
se usa para guardar y recuperar la nota. Después la nota es encriptada con la <code>llave</code> y con aes en modo gcm del lado del cliente y por último se envía al servidor. La información es almacenada en memoria y nunca persiste en el disco. El servidor nunca ve la llave de encriptación por lo que no puede desencriptar el contenido de las notas aunque lo intentara.
|
||||||
|
|
||||||
## Capturas de pantalla
|
## Capturas de pantalla
|
||||||
|
|
||||||
@@ -56,32 +59,26 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con XCha
|
|||||||
|
|
||||||
## Variables de entorno
|
## Variables de entorno
|
||||||
|
|
||||||
| Variable | Default | Descripción |
|
| Variable | Default | Descripción |
|
||||||
| ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `CACHE` | `redis://cache/` | URL de caché (valkey o redis) a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
||||||
| `SIZE_LIMIT` | `1 KiB` | Tamaño máximo del cuerpo. Valores aceptados según [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> Los payloads son bytes crudos (msgpack + cifrado), por lo que el frontend muestra el límite completo. |
|
| `SIZE_LIMIT` | `1 KiB` | Tamaño máximo. Valores aceptados según la [unidad byte](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> El frontend mostrará ese número, incluyendo el ~35% de sobrecarga de codificación. |
|
||||||
| `MAX_VIEWS` | `100` | Número máximo de vistas. |
|
| `MAX_VIEWS` | `100` | Número máximo de vistas. |
|
||||||
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
|
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
|
||||||
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
|
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
|
||||||
| `ALLOW_FILES` | `true` | Permitir subir archivos. Si es `false`, los usuarios solo podrán crear notas de texto. |
|
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es útil para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
|
||||||
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Útil para reducir el tamaño del link. _No afecta el nivel de encriptación_. |
|
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
|
||||||
| `CACHE_PREFIX` | `""` | Prefijo opcional para las claves de caché. Útil al compartir una instancia con otras apps vía namespaces ACL. |
|
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
|
||||||
| `EXTRA_SIZE_LIMIT` | `512` | Tamaño máximo en bytes del payload `extra` opaco (p. ej. parámetros de derivación de clave) guardado en los metadatos de la nota. |
|
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
|
||||||
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
|
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
|
||||||
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
|
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
|
||||||
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
|
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. |
|
||||||
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título. |
|
|
||||||
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
|
|
||||||
| `THEME_NEW_NOTE_NOTICE` | `true` | Mostrar el mensaje sobre cómo se almacenan las notas en memoria (pueden ser expulsadas) al crear una nueva nota. |
|
|
||||||
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. |
|
|
||||||
| `IMPRINT_URL` | `""` | URL personalizada para un imprint alojado en otro sitio. Debe ser accesible públicamente. Tiene prioridad sobre `IMPRINT_HTML`. |
|
|
||||||
| `IMPRINT_HTML` | `""` | Alternativa a `IMPRINT_URL` para especificar el HTML a mostrar en `/imprint`. Usa solo `IMPRINT_HTML` o `IMPRINT_URL`, no ambos. |
|
|
||||||
|
|
||||||
## Despliegue
|
## Despliegue
|
||||||
|
|
||||||
> ℹ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación.
|
> ℹ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación.
|
||||||
|
|
||||||
> ℹ️ Hay un endpoint para verificar el estado, lo encontramos en `/healthz`. Regresa un código 200 o 503.
|
> ℹ️ Hay un endpoint para verificar el estado, lo encontramos en `/api/health/`. Regresa un código 200 o 503.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -90,22 +87,24 @@ Docker es la manera más fácil. Aquí encontramos [la imagen oficial](https://h
|
|||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# docker-compose.yml
|
||||||
|
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
image: valkey/valkey:7-alpine
|
image: redis: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: redis-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://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
# --maxmemory 1g --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:
|
||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
environment:
|
environment:
|
||||||
# Size limit for a single note.
|
# Size limit for a single note.
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
@@ -114,7 +113,7 @@ services:
|
|||||||
|
|
||||||
# Optional health checks
|
# Optional health checks
|
||||||
# healthcheck:
|
# healthcheck:
|
||||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
|
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
||||||
# interval: 1m
|
# interval: 1m
|
||||||
# timeout: 3s
|
# timeout: 3s
|
||||||
# retries: 2
|
# retries: 2
|
||||||
|
|||||||
+29
-33
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
<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)
|
||||||
@@ -20,6 +21,8 @@
|
|||||||
_加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器
|
_加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器
|
||||||
|
|
||||||
> 🌍 如果你想翻译此项目请随时与我联系.
|
> 🌍 如果你想翻译此项目请随时与我联系.
|
||||||
|
>
|
||||||
|
> 感谢 [Lokalise](https://lokalise.com/) 提供免费的平台服务支持
|
||||||
|
|
||||||
## 演示示例
|
## 演示示例
|
||||||
|
|
||||||
@@ -36,7 +39,7 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
|
|||||||
|
|
||||||
加密鸽会为每条笔记都生成一个独立的 <code>id (256bit)</code> 和 <code>key 256(bit)</code>。
|
加密鸽会为每条笔记都生成一个独立的 <code>id (256bit)</code> 和 <code>key 256(bit)</code>。
|
||||||
|
|
||||||
其中<code>id</code>用于保存和提取密信, 在这之后这封密信将会被客户端使用 XChaCha20-Poly1305 加密算法和`key`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。
|
其中<code>id</code>用于保存和提取密信, 在这之后这封密信将会被客户端使用 AES 算法的 GCM 模式和`key`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。
|
||||||
|
|
||||||
## 屏幕截图
|
## 屏幕截图
|
||||||
|
|
||||||
@@ -44,26 +47,16 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
|
|||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量名称 | 默认值 | 描述 |
|
| 变量名称 | 默认值 | 描述 |
|
||||||
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ---------------- | ---------------- | --------------------------------------------------------------------------------- |
|
||||||
| `CACHE` | `redis://cache/` | 缓存(valkey 或 redis)连接 URL。[连接参数](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
| `REDIS` | `redis://redis/` | Redis 连接 URL。 |
|
||||||
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。可通过 [字节单位](https://docs.rs/byte-unit/) 查看支持的值。负载是原始字节(msgpack + 加密),因此前端显示完整限制。 |
|
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
|
||||||
| `MAX_VIEWS` | `100` | 密信最多查看次数限制。 |
|
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
|
||||||
| `MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟)。 |
|
| `MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
|
||||||
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块。 |
|
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
|
||||||
| `ALLOW_FILES` | `true` | 是否允许上传文件。为 `false` 时用户只能创建文本密信。 |
|
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
|
||||||
| `ID_LENGTH` | `32` | 设置密信 `id` 的字节大小。默认 `32` 字节,可用于缩短链接长度。_不影响加密强度_。 |
|
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
|
||||||
| `CACHE_PREFIX` | `""` | 缓存键可选前缀。与其它应用通过 ACL namespace 共享缓存实例时有用。 |
|
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 |
|
||||||
| `EXTRA_SIZE_LIMIT` | `512` | 不透明 `extra` 负载(如密钥派生参数)的最大字节数,存于密信元数据。 |
|
|
||||||
| `VERBOSITY` | `warn` | 后端日志级别。可能值见 [env_logger](https://docs.rs/env_logger/latest/env_logger/#enabling-logging)。 |
|
|
||||||
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,需可公开访问。 |
|
|
||||||
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
|
|
||||||
| `THEME_PAGE_TITLE` | `""` | 自定义页面标题。 |
|
|
||||||
| `THEME_FAVICON` | `""` | 自定义 favicon 地址,需可公开访问。 |
|
|
||||||
| `THEME_NEW_NOTE_NOTICE` | `true` | 创建新笔记后显示“笔记存于内存可能被清除”的提示。 |
|
|
||||||
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 |
|
|
||||||
| `IMPRINT_URL` | `""` | 托管在其它位置的印页 URL,需可公开访问。优先于 `IMPRINT_HTML`。 |
|
|
||||||
| `IMPRINT_HTML` | `""` | `IMPRINT_URL` 的替代:指定 `/imprint` 展示的 HTML。`IMPRINT_HTML` 与 `IMPRINT_URL` 只应指定其一。 | |
|
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -77,23 +70,24 @@ Docker 是最简单的部署方式。这里是[官方镜像的地址](https://hu
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# docker-compose.yml
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
image: valkey/valkey:7-alpine
|
image: redis: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: redis-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://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
# https://redis.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:
|
||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
environment:
|
environment:
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
ports:
|
ports:
|
||||||
@@ -114,27 +108,29 @@ services:
|
|||||||
- 域名 `example.org`
|
- 域名 `example.org`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
image: valkey/valkey:7-alpine
|
image: redis: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: redis-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://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
# https://redis.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:
|
||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- proxy
|
- proxy
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# For a production file see: README.md
|
# For a production file see: README.md
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
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:
|
||||||
- cache
|
- redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 3000:8000
|
- 3000:8000
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/healthz']
|
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/']
|
||||||
interval: 1m
|
interval: 1m
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 2
|
retries: 2
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
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
|
||||||
@@ -11,9 +11,9 @@ services:
|
|||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
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/healthz"]
|
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
||||||
# interval: 1m
|
# interval: 1m
|
||||||
# timeout: 3s
|
# timeout: 3s
|
||||||
# retries: 2
|
# retries: 2
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
# Roadmap
|
|
||||||
|
|
||||||
## Todo
|
|
||||||
|
|
||||||
- Add remaining shared tooling to the pnpm catalog (`vite`, `tsdown`).
|
|
||||||
- Move formatting, linting and type-checking + git hooks onto `vite-plus` (oxlint, oxfmt, vitest).
|
|
||||||
- Re-add CSP (`Content-Security-Policy`) wired into the axum router (was in `csp.rs`, removed as unused).
|
|
||||||
|
|
||||||
## Unified payload (drop the text/file union)
|
|
||||||
|
|
||||||
> Follow-up iteration of the inner payload, parked as `v3.1` (not part of core v3).
|
|
||||||
|
|
||||||
Everything becomes a **file**. Text is just a `FileDTO` with `inline: true`. The `{ type: "text" } | { type: "files" }` union is removed.
|
|
||||||
|
|
||||||
```
|
|
||||||
# Inner layer (encrypted, client-only)
|
|
||||||
{ files: [
|
|
||||||
{ name: string, mime: string, size: number, data: bytes, inline?: boolean }
|
|
||||||
] }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `FileDTO` gains `inline?: boolean` (default `false`).
|
|
||||||
- `inline: true` = the file was authored inline at compose time (e.g. an empty text file the user typed into). Purely a **client/UI hint** — rides inside the encrypted inner payload, the server never sees it.
|
|
||||||
- `inline: true` files render as an **editable text editor**; the rest render as binary file cards (upload/download).
|
|
||||||
- Default composer state = one empty `inline:true` text file the user edits. No `isFile` toggle.
|
|
||||||
|
|
||||||
### Impact by area
|
|
||||||
|
|
||||||
- **Server / wire protocol / `api.ts`**: unchanged. Still an opaque encrypted `data` blob in the outer msgpack envelope.
|
|
||||||
- **Shared codec**: `NoteContent` becomes `{ files: FileDTO[] }`; drop the union + `switch(type)` in `unpackContent`. `packContent(input: FileDTO[], password?)`. Breaking inner-msgpack schema → ok, pre-release.
|
|
||||||
- **Frontend (`Create.svelte` — biggest)**: one `files: FileDTO[]` model; default empty `inline` text file; editor binds a string, encodes to bytes on submit; add real files via upload (`inline:false`).
|
|
||||||
- **CLI**: `send text "x"` → `files:[{ name:'note.txt', mime:'text/plain', data:utf8, inline:true }]`. `send file a b` → drop `type` union. `open`/download prints text files, saves the rest.
|
|
||||||
- **Tests**: `payload.test.ts` rewritten to `{ files:[...] }`; playwright `switch-file`/`text-field` composer specs collapse + rework.
|
|
||||||
|
|
||||||
### Watches
|
|
||||||
|
|
||||||
- text↔bytes round-trip in the editor (encoding, line-endings);
|
|
||||||
- `size` must be set from the _encoded_ bytes (matches `SIZE_LIMIT` / preview) — recompute after text→bytes;
|
|
||||||
- pasted binary files keep `inline:false`; only inline-authored text is `inline:true`.
|
|
||||||
|
|
||||||
### Payload pipeline
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
subgraph WRITE["CLIENT — encode / compress / encrypt"]
|
|
||||||
A[Text or Files] --> B{password?}
|
|
||||||
B -->|yes| C1[deriveKey password+salt<br>extra=encode salt,N,r,p]
|
|
||||||
B -->|no| C2[generateKey random 32B]
|
|
||||||
C2 --> D[URL fragment hex key]
|
|
||||||
C1 --> E
|
|
||||||
D --> E
|
|
||||||
A --> F[encode inner files]
|
|
||||||
F -->|encode content| G[inner msgpack]
|
|
||||||
G --> H[LZ4 compress]
|
|
||||||
H --> I[XChaCha20 encrypt]
|
|
||||||
I -->|data| J[POST msgpack meta+data]
|
|
||||||
C1 -->|extra| J
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph SERVER["SERVER — agnostic"]
|
|
||||||
J --> K{hash store: views, expiration, extra, data}
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph READ["CLIENT — read"]
|
|
||||||
L[meta/extra from PREVIEW] --> M{extra present?}
|
|
||||||
M -->|yes| N[deriveKey pw+salt]
|
|
||||||
M -->|no| O[key from URL hex fragment]
|
|
||||||
N --> P[DELETE get envelope data]
|
|
||||||
O --> P
|
|
||||||
P --> Q[XChaCha20 decrypt]
|
|
||||||
Q --> R[LZ4 decompress]
|
|
||||||
R --> S[msgpack decode files]
|
|
||||||
S -->|inline:true| T[edit / render text]
|
|
||||||
S -->|inline:false| U[save files]
|
|
||||||
end
|
|
||||||
```
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
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
|
||||||
@@ -12,9 +13,9 @@ services:
|
|||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
|
|||||||
+20
-15
@@ -25,26 +25,27 @@ This is a tiny guide to install cryptgeon on (probably) any unix system (and may
|
|||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yaml
|
# docker-compose.yaml
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:2.6
|
image: traefik:2.6
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- '80:80'
|
||||||
- "443:443"
|
- '443:443'
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./traefik.yaml:/etc/traefik/traefik.yaml:ro
|
- ./traefik.yaml:/etc/traefik/traefik.yaml:ro
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- 'traefik.enable=true'
|
||||||
|
|
||||||
# HTTP to HTTPS redirection
|
# HTTP to HTTPS redirection
|
||||||
- "traefik.http.routers.http_catchall.rule=HostRegexp(`{any:.+}`)"
|
- 'traefik.http.routers.http_catchall.rule=HostRegexp(`{any:.+}`)'
|
||||||
- "traefik.http.routers.http_catchall.entrypoints=insecure"
|
- 'traefik.http.routers.http_catchall.entrypoints=insecure'
|
||||||
- "traefik.http.routers.http_catchall.middlewares=https_redirect"
|
- 'traefik.http.routers.http_catchall.middlewares=https_redirect'
|
||||||
- "traefik.http.middlewares.https_redirect.redirectscheme.scheme=https"
|
- 'traefik.http.middlewares.https_redirect.redirectscheme.scheme=https'
|
||||||
- "traefik.http.middlewares.https_redirect.redirectscheme.permanent=true"
|
- 'traefik.http.middlewares.https_redirect.redirectscheme.permanent=true'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
@@ -61,15 +62,15 @@ api:
|
|||||||
# Define HTTP and HTTPS entrypoint
|
# Define HTTP and HTTPS entrypoint
|
||||||
entryPoints:
|
entryPoints:
|
||||||
insecure:
|
insecure:
|
||||||
address: ":80"
|
address: ':80'
|
||||||
secure:
|
secure:
|
||||||
address: ":443"
|
address: ':443'
|
||||||
|
|
||||||
# Dynamic configuration will come from docker labels
|
# Dynamic configuration will come from docker labels
|
||||||
providers:
|
providers:
|
||||||
docker:
|
docker:
|
||||||
endpoint: "unix:///var/run/docker.sock"
|
endpoint: 'unix:///var/run/docker.sock'
|
||||||
network: "proxy"
|
network: 'proxy'
|
||||||
exposedByDefault: false
|
exposedByDefault: false
|
||||||
|
|
||||||
# Enable acme with http file challenge
|
# Enable acme with http file challenge
|
||||||
@@ -99,12 +100,14 @@ Create another docker-compose.yaml file in another folder. We will assume that t
|
|||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
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
|
||||||
@@ -116,10 +119,10 @@ services:
|
|||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
environment:
|
environment:
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
networks:
|
networks:
|
||||||
@@ -152,6 +155,8 @@ docker-compose up -d
|
|||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yaml
|
# docker-compose.yaml
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
watchtower:
|
watchtower:
|
||||||
image: containrrr/watchtower
|
image: containrrr/watchtower
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ Assumptions:
|
|||||||
- Domain name `example.org`.
|
- Domain name `example.org`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
cache:
|
redis:
|
||||||
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
|
||||||
@@ -26,10 +28,10 @@ services:
|
|||||||
- /data
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- proxy
|
- proxy
|
||||||
@@ -58,7 +60,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
|
|
||||||
cache:
|
redis:
|
||||||
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
|
||||||
@@ -70,9 +72,9 @@ services:
|
|||||||
- /data
|
- /data
|
||||||
|
|
||||||
cryptgeon:
|
cryptgeon:
|
||||||
image: cupcakearmy/cryptgeon:v3
|
image: cupcakearmy/cryptgeon
|
||||||
depends_on:
|
depends_on:
|
||||||
- cache
|
- redis
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
|
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
|
||||||
|
|||||||
+9
-10
@@ -1,23 +1,22 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:docker": "docker compose -f docker-compose.dev.yaml up cache",
|
"dev:docker": "docker compose -f docker-compose.dev.yaml up redis",
|
||||||
"dev:packages": "pnpm --parallel run dev",
|
"dev:packages": "pnpm --parallel run dev",
|
||||||
"dev": "pnpm --parallel run /dev/",
|
"dev": "run-p 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": "docker compose -f docker-compose.dev.yaml up",
|
"test:server": "run-s docker:up",
|
||||||
"test:dl-browsers": "playwright install",
|
"test:dl-browsers": "playwright install",
|
||||||
"test:prepare": "pnpm run build && pnpm run docker:build",
|
"test:prepare": "run-p test:dl-browsers build docker:build",
|
||||||
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
|
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.60.0",
|
||||||
"@types/node": "^24.13.3"
|
"@types/node": "^24.12.4",
|
||||||
|
"npm-run-all": "^4.1.5",
|
||||||
|
"shelljs": "^0.8.5"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.5.0",
|
"packageManager": "pnpm@11.5.0"
|
||||||
"engines": {
|
|
||||||
"node": ">=22"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1
-21
@@ -252,7 +252,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
version = "3.0.0-rc.3"
|
version = "2.9.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"bs62",
|
"bs62",
|
||||||
@@ -261,7 +261,6 @@ dependencies = [
|
|||||||
"lazy_static",
|
"lazy_static",
|
||||||
"redis",
|
"redis",
|
||||||
"ring",
|
"ring",
|
||||||
"rmp-serde",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1005,25 +1004,6 @@ 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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
version = "3.0.0-rc.3"
|
version = "2.9.3"
|
||||||
authors = ["cupcakearmy <hi@nicco.io>"]
|
authors = ["cupcakearmy <hi@nicco.io>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
@@ -20,7 +20,6 @@ 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"
|
||||||
|
|||||||
@@ -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 CACHE_PREFIX: String = std::env::var("CACHE_PREFIX")
|
pub static ref REDIS_PREFIX: String = std::env::var("REDIS_PREFIX")
|
||||||
.unwrap_or("".to_string())
|
.unwrap_or("".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -50,10 +50,6 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
use axum::{
|
||||||
|
http::HeaderValue,
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use ring::rand::SecureRandom;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
const CSP_POLICY: &str = "default-src 'self'; script-src 'nonce-{nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; connect-src 'self'";
|
||||||
|
|
||||||
|
fn index_html() -> &'static str {
|
||||||
|
static HTML: OnceLock<String> = OnceLock::new();
|
||||||
|
HTML.get_or_init(|| {
|
||||||
|
let path = format!("{}index.html", *crate::config::FRONTEND_PATH);
|
||||||
|
std::fs::read_to_string(&path).expect("Failed to read index.html for CSP injection")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_nonce() -> String {
|
||||||
|
let rng = ring::rand::SystemRandom::new();
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
rng.fill(&mut bytes).expect("Failed to generate CSP nonce");
|
||||||
|
bs62::encode_data(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn spa_fallback() -> Response {
|
||||||
|
let nonce = generate_nonce();
|
||||||
|
let csp = CSP_POLICY.replace("{nonce}", &nonce);
|
||||||
|
let html = index_html().replace("<script>", &format!("<script nonce=\"{}\">", nonce));
|
||||||
|
|
||||||
|
let mut response = Html(html).into_response();
|
||||||
|
response
|
||||||
|
.headers_mut()
|
||||||
|
.insert("Content-Security-Policy", HeaderValue::from_str(&csp).unwrap());
|
||||||
|
response
|
||||||
|
}
|
||||||
@@ -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_cache() {
|
if store::can_reach_redis() {
|
||||||
return (StatusCode::OK,);
|
return (StatusCode::OK,);
|
||||||
} else {
|
} else {
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE,);
|
return (StatusCode::SERVICE_UNAVAILABLE,);
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SharedState {
|
||||||
|
pub locks: LockMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
|
||||||
@@ -1,21 +1,27 @@
|
|||||||
|
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,
|
||||||
normalize_path::NormalizePathLayer,
|
normalize_path::NormalizePathLayer,
|
||||||
services::{ServeDir, ServeFile},
|
services::ServeDir,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
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;
|
||||||
@@ -24,30 +30,32 @@ mod store;
|
|||||||
async fn main() {
|
async fn main() {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
if !store::can_reach_cache() {
|
let shared_state = SharedState {
|
||||||
println!("cannot reach cache");
|
locks: Arc::new(Mutex::new(HashMap::new())),
|
||||||
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::view))
|
.route("/{id}", delete(note::delete))
|
||||||
.route("/{id}", get(note::preview));
|
.route("/{id}", get(note::preview));
|
||||||
let health_routes = Router::new().route("/healthz", get(health::report_health));
|
let health_routes = Router::new().route("/live", 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 v3_routes = Router::new()
|
let api_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 serve_dir =
|
|
||||||
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(
|
||||||
.fallback_service(serve_dir)
|
ServeDir::new(config::FRONTEND_PATH.to_string())
|
||||||
|
.not_found_service(axum::Router::new().fallback(csp::spa_fallback)),
|
||||||
|
)
|
||||||
.layer(DefaultBodyLimit::max(*config::LIMIT))
|
.layer(DefaultBodyLimit::max(*config::LIMIT))
|
||||||
.layer(
|
.layer(
|
||||||
CompressionLayer::new()
|
CompressionLayer::new()
|
||||||
@@ -55,7 +63,8 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@@ -5,35 +5,22 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::config;
|
use crate::config;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct NoteMeta {
|
pub struct Note {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
pub meta: String,
|
||||||
|
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, Deserialize, Clone)]
|
#[derive(Serialize)]
|
||||||
pub struct CreateRequest {
|
pub struct NoteInfo {
|
||||||
pub meta: NoteMeta,
|
pub meta: String,
|
||||||
pub data: Vec<u8>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize)]
|
||||||
pub struct CreateResponse {
|
pub struct NotePublic {
|
||||||
pub id: String,
|
pub meta: 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 {
|
||||||
@@ -45,5 +32,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));
|
||||||
}
|
}
|
||||||
result
|
return result;
|
||||||
}
|
}
|
||||||
+125
-113
@@ -2,143 +2,155 @@ use axum::{
|
|||||||
extract::Path,
|
extract::Path,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
body::Bytes,
|
Json,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::SystemTime;
|
use std::{sync::Arc, time::SystemTime};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::note::{CreateRequest, generate_id};
|
use crate::note::{generate_id, Note, NoteInfo};
|
||||||
use crate::store;
|
use crate::store;
|
||||||
use crate::config;
|
use crate::{config, lock::SharedState};
|
||||||
|
|
||||||
use super::{CreateResponse, MetaResponse, NoteResponse, NoteMeta};
|
use super::NotePublic;
|
||||||
|
|
||||||
pub fn now() -> u64 {
|
pub fn now() -> u32 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs()
|
.as_secs() as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct NoteParams {
|
pub struct OneNoteParams {
|
||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(body: Bytes) -> Response {
|
pub async fn preview(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response {
|
||||||
let req: CreateRequest = match rmp_serde::from_slice(&body) {
|
let note = store::get(&id);
|
||||||
Ok(r) => r,
|
|
||||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid msgpack").into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if req.meta.views.is_none() && req.meta.expiration.is_none() {
|
match note {
|
||||||
return (StatusCode::BAD_REQUEST, "At least views or expiration must be set").into_response();
|
Ok(Some(n)) => (StatusCode::OK, Json(NoteInfo { meta: n.meta })).into_response(),
|
||||||
}
|
|
||||||
|
|
||||||
if req.meta.extra.len() > *config::EXTRA_SIZE_LIMIT {
|
|
||||||
return (StatusCode::BAD_REQUEST, "Extra data too large").into_response();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut meta = req.meta;
|
|
||||||
|
|
||||||
if !*config::ALLOW_ADVANCED {
|
|
||||||
meta.views = Some(1);
|
|
||||||
meta.expiration = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
match meta.views {
|
|
||||||
Some(v) => {
|
|
||||||
if v > *config::MAX_VIEWS || v < 1 {
|
|
||||||
return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let expiration_ts = match meta.expiration {
|
|
||||||
Some(e) => {
|
|
||||||
if e > *config::MAX_EXPIRATION || e < 1 {
|
|
||||||
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
|
|
||||||
}
|
|
||||||
Some(now() + (e as u64 * 60))
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn preview(Path(NoteParams { id }): Path<NoteParams>) -> Response {
|
|
||||||
match store::get_meta(&id) {
|
|
||||||
Ok(Some((views, expiration, extra))) => {
|
|
||||||
let meta = NoteMeta {
|
|
||||||
views: views.map(|v| v as u32),
|
|
||||||
expiration: expiration.map(|e| e as u32),
|
|
||||||
extra,
|
|
||||||
};
|
|
||||||
let resp = MetaResponse { meta };
|
|
||||||
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
|
||||||
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
|
||||||
}
|
|
||||||
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
|
Ok(None) => (StatusCode::NOT_FOUND).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 view(Path(NoteParams { id }): Path<NoteParams>) -> Response {
|
#[derive(Serialize, Deserialize)]
|
||||||
let (views, expiration, extra) = match store::get_meta(&id) {
|
struct CreateResponse {
|
||||||
Ok(Some(v)) => v,
|
id: String,
|
||||||
_ => return (StatusCode::NOT_FOUND).into_response(),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let has_views = views.is_some();
|
pub async fn create(Json(mut n): Json<Note>) -> Response {
|
||||||
|
// let mut n = note.into_inner();
|
||||||
|
let id = generate_id();
|
||||||
|
// let bad_req = HttpResponse::BadRequest().finish();
|
||||||
|
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 {
|
||||||
|
n.views = Some(1);
|
||||||
|
n.expiration = None;
|
||||||
|
}
|
||||||
|
match n.views {
|
||||||
|
Some(v) => {
|
||||||
|
if v > *config::MAX_VIEWS || v < 1 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
|
||||||
|
}
|
||||||
|
n.expiration = None; // views overrides expiration
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
match n.expiration {
|
||||||
|
Some(e) => {
|
||||||
|
if e > *config::MAX_EXPIRATION || e < 1 {
|
||||||
|
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
|
||||||
|
}
|
||||||
|
let expiration = now() + (e * 60);
|
||||||
|
n.expiration = Some(expiration);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if has_views {
|
pub async fn delete(
|
||||||
let remaining = match store::decrement_views(&id) {
|
Path(OneNoteParams { id }): Path<OneNoteParams>,
|
||||||
Ok(r) => r,
|
state: axum::extract::State<SharedState>,
|
||||||
Err(_) => return (StatusCode::NOT_FOUND).into_response(),
|
) -> Response {
|
||||||
};
|
let mut locks_map = state.locks.lock().await;
|
||||||
|
let lock = locks_map
|
||||||
|
.entry(id.clone())
|
||||||
|
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||||
|
.clone();
|
||||||
|
drop(locks_map);
|
||||||
|
let _guard = lock.lock().await;
|
||||||
|
|
||||||
let data = match store::get_data(&id) {
|
let note = store::get(&id);
|
||||||
Ok(Some(d)) => d,
|
match note {
|
||||||
_ => return (StatusCode::NOT_FOUND).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
};
|
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
|
||||||
|
Ok(Some(note)) => {
|
||||||
|
let mut changed = note.clone();
|
||||||
|
if changed.views == None && changed.expiration == None {
|
||||||
|
return (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();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match store::set(&id, &changed.clone()) {
|
||||||
|
Err(e) => {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
if remaining <= 0 {
|
let n = now();
|
||||||
let _ = store::del(&id);
|
match changed.expiration {
|
||||||
|
Some(e) => {
|
||||||
|
if e < n {
|
||||||
|
match store::del(&id.clone()) {
|
||||||
|
Ok(_) => return (StatusCode::BAD_REQUEST).into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(NotePublic {
|
||||||
|
contents: changed.contents,
|
||||||
|
meta: changed.meta,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
let data = match store::get_data(&id) {
|
|
||||||
Ok(Some(d)) => d,
|
|
||||||
_ => return (StatusCode::NOT_FOUND).into_response(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let meta = NoteMeta {
|
|
||||||
views: None,
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,83 +1,71 @@
|
|||||||
|
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 CACHE_URL: String = std::env::var("CACHE")
|
static ref REDIS_CLIENT: String = std::env::var("REDIS")
|
||||||
.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: &str) -> String {
|
fn prefixed(id: &String) -> String {
|
||||||
format!("{}{}", config::CACHE_PREFIX.as_str(), id)
|
format!("{}{}", config::REDIS_PREFIX.as_str(), id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn conn() -> Result<redis::Connection, &'static str> {
|
fn get_connection() -> Result<redis::Connection, &'static str> {
|
||||||
let client =
|
let client =
|
||||||
redis::Client::open(CACHE_URL.to_string()).map_err(|_| "Unable to connect to cache")?;
|
redis::Client::open(REDIS_CLIENT.to_string()).map_err(|_| "Unable to connect to redis")?;
|
||||||
client.get_connection().map_err(|_| "Unable to connect to cache")
|
client
|
||||||
|
.get_connection()
|
||||||
|
.map_err(|_| "Unable to connect to redis")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_reach_cache() -> bool {
|
pub fn can_reach_redis() -> bool {
|
||||||
conn().is_ok()
|
let conn = get_connection();
|
||||||
|
return match conn {
|
||||||
|
Ok(_) => true,
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(id: &str, data: &[u8], views: Option<i64>, expiration: Option<u64>, extra: &[u8]) -> Result<(), &'static str> {
|
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
|
||||||
let key = prefixed(id);
|
let key = prefixed(id);
|
||||||
let mut c = conn()?;
|
let serialized = serde_json::to_string(¬e.clone()).unwrap();
|
||||||
|
let mut conn = get_connection()?;
|
||||||
c.hset::<_, _, _, ()>(&key, "data", data).map_err(|_| "Unable to set note")?;
|
|
||||||
c.hset::<_, _, _, ()>(&key, "extra", extra).map_err(|_| "Unable to set note")?;
|
|
||||||
|
|
||||||
if let Some(v) = views {
|
|
||||||
c.hset::<_, _, _, ()>(&key, "views", v).map_err(|_| "Unable to set note")?;
|
|
||||||
}
|
|
||||||
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")?;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
conn.set::<_, _, ()>(key.as_str(), serialized)
|
||||||
|
.map_err(|_| "Unable to set note in redis")?;
|
||||||
|
match note.expiration {
|
||||||
|
Some(e) => {
|
||||||
|
let seconds = e - now();
|
||||||
|
conn.expire::<_, ()>(key.as_str(), seconds as i64)
|
||||||
|
.map_err(|_| "Unable to set expiration on note")?
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
};
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_meta(id: &str) -> Result<Option<(Option<i64>, Option<u64>, Vec<u8>)>, &'static str> {
|
pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
|
||||||
let key = prefixed(id);
|
let key = prefixed(id);
|
||||||
let mut c = conn()?;
|
let mut conn = get_connection()?;
|
||||||
|
let value: Option<String> = conn.get(key.as_str()).map_err(|_| "Could not load note in redis")?;
|
||||||
let exists: bool = c.exists::<_, bool>(&key).map_err(|_| "Cache error")?;
|
match value {
|
||||||
if !exists {
|
None => return Ok(None),
|
||||||
return Ok(None);
|
Some(s) => {
|
||||||
|
let deserialize: Note = serde_json::from_str(&s).unwrap();
|
||||||
|
return Ok(Some(deserialize));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
pub fn del(id: &String) -> Result<(), &'static str> {
|
||||||
let key = prefixed(id);
|
let key = prefixed(id);
|
||||||
let mut c = conn()?;
|
let mut conn = get_connection()?;
|
||||||
let data: Option<Vec<u8>> = c.hget::<_, _, Option<Vec<u8>>>(&key, "data").map_err(|_| "Cache error")?;
|
conn.del::<_, ()>(key.as_str()).map_err(|_| "Unable to delete note in redis")?;
|
||||||
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(())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { build } from 'tsup'
|
||||||
|
import pkg from './package.json' with { type: 'json' }
|
||||||
|
|
||||||
|
const watch = process.argv.slice(2)[0] === '--watch'
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
|
||||||
|
dts: true,
|
||||||
|
minify: true,
|
||||||
|
format: ['esm', 'cjs'],
|
||||||
|
target: 'es2020',
|
||||||
|
clean: true,
|
||||||
|
define: { VERSION: `"${pkg.version}"` },
|
||||||
|
watch,
|
||||||
|
})
|
||||||
+23
-19
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cryptgeon",
|
"name": "cryptgeon",
|
||||||
"version": "3.0.0-rc.3",
|
"version": "2.9.3",
|
||||||
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -9,35 +9,39 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.mjs"
|
".": "./dist/index.js",
|
||||||
|
"./shared": {
|
||||||
|
"import": "./dist/shared/shared.js",
|
||||||
|
"types": "./dist/shared/shared.d.ts"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"types": "./dist/index.d.mts",
|
"types": "./dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"cryptgeon": "./dist/cli.mjs"
|
"cryptgeon": "./dist/cli.cjs"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vp pack",
|
"bin": "run-s build package",
|
||||||
"dev": "vp pack --watch",
|
"build": "tsc && node build.js",
|
||||||
"prepublishOnly": "pnpm run build"
|
"dev": "node build.js --watch",
|
||||||
|
"prepublishOnly": "run-s build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commander-js/extra-typings": "^15.0.0",
|
"@commander-js/extra-typings": "^12.1.0",
|
||||||
"@cryptgeon/shared": "workspace:*",
|
"@types/inquirer": "^9.0.9",
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@types/mime": "^4.0.0",
|
||||||
"@tsconfig/strictest": "catalog:",
|
"@types/node": "^20.19.41",
|
||||||
"@types/inquirer": "^9.0.10",
|
"commander": "^12.1.0",
|
||||||
"@types/node": "^22.20.1",
|
"inquirer": "^9.3.8",
|
||||||
"commander": "^15.0.0",
|
|
||||||
"inquirer": "^14.2.1",
|
|
||||||
"mime": "^4.1.0",
|
"mime": "^4.1.0",
|
||||||
"pretty-bytes": "^7.1.3",
|
"occulto": "^2.0.6",
|
||||||
"typescript": "catalog:",
|
"pretty-bytes": "^6.1.1",
|
||||||
"vite-plus": "catalog:"
|
"tsup": "^8.5.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +1,51 @@
|
|||||||
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 { decode } from '@msgpack/msgpack'
|
import { AES, Hex } from 'occulto'
|
||||||
import pretty from 'pretty-bytes'
|
import pretty from 'pretty-bytes'
|
||||||
import { deriveKey, setServer, info, get, unpackContent } from '@cryptgeon/shared'
|
import { Adapters } from '../shared/adapters.js'
|
||||||
|
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) {
|
||||||
setServer(url.origin)
|
API.setOptions({ server: url.origin })
|
||||||
const id = url.pathname.split('/')[2]
|
const id = url.pathname.split('/')[2]
|
||||||
if (!id) throw new Error('Invalid URL')
|
const preview = await API.info(id).catch(() => {
|
||||||
const meta = await info(id)
|
throw new Error('Note does not exist or is expired')
|
||||||
if (!meta) throw new Error('Note does not exist or is expired')
|
})
|
||||||
|
|
||||||
let key: Uint8Array
|
// Password
|
||||||
if (meta.extra && meta.extra.length > 0) {
|
let password: string
|
||||||
|
const derivation = preview?.meta.derivation
|
||||||
|
if (derivation) {
|
||||||
if (suggestedPassword) {
|
if (suggestedPassword) {
|
||||||
const derivation = decode(meta.extra) as any
|
password = suggestedPassword
|
||||||
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',
|
||||||
|
},
|
||||||
])
|
])
|
||||||
const derivation = decode(meta.extra) as any
|
password = response.password
|
||||||
key = deriveKey(response.password, new Uint8Array(derivation.salt))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const hex = url.hash.slice(1)
|
password = url.hash.slice(1)
|
||||||
key = new Uint8Array(Buffer.from(hex, 'hex'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const note = await get(id)
|
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
|
||||||
if (!note) throw new Error('Could not load note')
|
const note = await API.get(id)
|
||||||
|
|
||||||
const content = unpackContent(note.data, key)
|
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
|
||||||
|
switch (note.meta.type) {
|
||||||
|
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
|
||||||
@@ -46,32 +55,36 @@ 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((f) => ({
|
choices: files.map((file) => ({
|
||||||
value: f.name,
|
value: file.name,
|
||||||
name: `${f.name} - ${pretty(f.data.length, { binary: true })}`,
|
name: `${file.name} - ${file.type} - ${pretty(file.size, { binary: true })}`,
|
||||||
checked: true,
|
checked: true,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
selected = files.filter((f) => names.includes(f.name))
|
selected = files.filter((file) => names.includes(file.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 (f) => {
|
selected.map(async (file) => {
|
||||||
let filename = resolve(f.name)
|
let filename = resolve(file.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()}-${f.name}`)
|
filename = resolve(`${Date.now()}-${file.name}`)
|
||||||
} catch {}
|
} catch {}
|
||||||
await writeFile(filename, f.data)
|
await writeFile(filename, file.contents)
|
||||||
console.log(`Saved: ${basename(filename)}`)
|
console.log(`Saved: ${basename(filename)}`)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
break
|
break
|
||||||
case 'text':
|
case 'text':
|
||||||
console.log(content.data)
|
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(() => {
|
||||||
|
throw couldNotDecrypt
|
||||||
|
})
|
||||||
|
console.log(plaintext)
|
||||||
break
|
break
|
||||||
default:
|
|
||||||
throw new Error('Unknown content type')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,46 @@
|
|||||||
import { readFile } from 'node:fs/promises'
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
import { basename } from 'node:path'
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
import mime from 'mime'
|
import mime from 'mime'
|
||||||
import { getServer, create, packContent, type FileDTO } from '@cryptgeon/shared'
|
import { AES, Hex } from 'occulto'
|
||||||
|
import { Adapters } from '../shared/adapters.js'
|
||||||
|
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
|
||||||
|
|
||||||
export type UploadOptions = { views?: number; expiration?: number; password?: string }
|
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { 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()
|
||||||
|
|
||||||
const payload = packContent(
|
let contents: string
|
||||||
typeof input === 'string'
|
let type: NoteMeta['type']
|
||||||
? { type: 'text', text: input }
|
if (typeof input === 'string') {
|
||||||
: { type: 'files', files: await fileDTOSfromPaths(input) },
|
contents = await Adapters.Text.encrypt(input, key)
|
||||||
password
|
type = 'text'
|
||||||
)
|
} else {
|
||||||
|
const files: FileDTO[] = await Promise.all(
|
||||||
|
input.map(async (path) => {
|
||||||
|
const data = new Uint8Array(await readFile(path))
|
||||||
|
const stats = await stat(path)
|
||||||
|
const extension = path.substring(path.indexOf('.') + 1)
|
||||||
|
const type = mime.getType(extension) ?? 'application/octet-stream'
|
||||||
|
return {
|
||||||
|
name: basename(path),
|
||||||
|
size: stats.size,
|
||||||
|
contents: data,
|
||||||
|
type,
|
||||||
|
} satisfies FileDTO
|
||||||
|
})
|
||||||
|
)
|
||||||
|
contents = await Adapters.Files.encrypt(files, key)
|
||||||
|
type = 'file'
|
||||||
|
}
|
||||||
|
|
||||||
const result = await create({ meta: { ...noteOptions, extra: payload.extra }, data: payload.data })
|
// Create the actual note and upload it.
|
||||||
let url = `${getServer()}/note/${result.id}`
|
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
|
||||||
if (!password) url += `#${Buffer.from(payload.key).toString('hex')}`
|
const result = await API.create(note)
|
||||||
|
let url = `${API.getOptions().server}/note/${result.id}`
|
||||||
|
if (!derived) url += `#${Hex.encode(key)}`
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fileDTOSfromPaths(paths: string[]): Promise<FileDTO[]> {
|
|
||||||
return Promise.all(
|
|
||||||
paths.map(async (path) => {
|
|
||||||
const extension = path.substring(path.indexOf('.') + 1)
|
|
||||||
const data = new Uint8Array(await readFile(path))
|
|
||||||
return {
|
|
||||||
name: basename(path),
|
|
||||||
mime: mime.getType(extension) ?? 'application/octet-stream',
|
|
||||||
size: data.length,
|
|
||||||
data,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+15
-21
@@ -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 { setServer, status } from '@cryptgeon/shared'
|
import { API } from './shared/api.js'
|
||||||
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,8 +21,7 @@ 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
|
||||||
const major = Number(process.version.slice(1).split('.')[0])
|
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required')
|
||||||
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
|
||||||
@@ -34,12 +33,15 @@ program
|
|||||||
.description('show information about the server')
|
.description('show information about the server')
|
||||||
.addOption(server)
|
.addOption(server)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
setServer(options.server!)
|
API.setOptions({ server: options.server })
|
||||||
const response = await status()
|
const response = await API.status()
|
||||||
const formatted = Object.fromEntries(
|
const formatted = {
|
||||||
Object.entries({ ...response, max_size: prettyBytes(response.max_size) })
|
...response,
|
||||||
.filter(([key]) => !key.startsWith('theme_'))
|
max_size: prettyBytes(response.max_size),
|
||||||
)
|
}
|
||||||
|
for (const key of Object.keys(formatted)) {
|
||||||
|
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
|
||||||
|
}
|
||||||
console.table(formatted)
|
console.table(formatted)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,15 +54,11 @@ send
|
|||||||
.addOption(minutes)
|
.addOption(minutes)
|
||||||
.addOption(password)
|
.addOption(password)
|
||||||
.action(async (files, options) => {
|
.action(async (files, options) => {
|
||||||
setServer(options.server!)
|
API.setOptions({ server: options.server })
|
||||||
await checkConstrains(options)
|
await checkConstrains(options)
|
||||||
options.password ||= await getStdin()
|
options.password ||= await getStdin()
|
||||||
try {
|
try {
|
||||||
const url = await upload(files, {
|
const url = await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
|
||||||
...(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')
|
||||||
@@ -74,15 +72,11 @@ send
|
|||||||
.addOption(minutes)
|
.addOption(minutes)
|
||||||
.addOption(password)
|
.addOption(password)
|
||||||
.action(async (text, options) => {
|
.action(async (text, options) => {
|
||||||
setServer(options.server!)
|
API.setOptions({ server: options.server })
|
||||||
await checkConstrains(options)
|
await checkConstrains(options)
|
||||||
options.password ||= await getStdin()
|
options.password ||= await getStdin()
|
||||||
try {
|
try {
|
||||||
const url = await upload(text, {
|
const url = await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
|
||||||
...(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')
|
||||||
|
|||||||
@@ -1,2 +1,4 @@
|
|||||||
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'
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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(),
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './adapters.js'
|
||||||
|
export * from './api.js'
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { exit as exitNode } from 'node:process'
|
import { exit as exitNode } from 'node:process'
|
||||||
import { status } from '@cryptgeon/shared'
|
import { API } from '../shared/api.js'
|
||||||
|
|
||||||
export function exit(message: string) {
|
export function exit(message: string) {
|
||||||
console.error(message)
|
console.error(message)
|
||||||
@@ -7,11 +7,13 @@ export function exit(message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
|
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
|
||||||
if (!constrains.views && !constrains.minutes) constrains.views = 1
|
const { views, minutes } = constrains
|
||||||
|
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
|
||||||
|
if (!views && !minutes) constrains.views = 1
|
||||||
|
|
||||||
const response = await status()
|
const response = await API.status()
|
||||||
if (constrains.views && constrains.views > response.max_views)
|
if (views && views > response.max_views)
|
||||||
exit(`Only a maximum of ${response.max_views} views allowed. ${constrains.views} given.`)
|
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
|
||||||
if (constrains.minutes && constrains.minutes > response.max_expiration)
|
if (minutes && minutes > response.max_expiration)
|
||||||
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${constrains.minutes} given.`)
|
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
{
|
{
|
||||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "esnext",
|
"target": "es2022",
|
||||||
"module": "esnext",
|
"module": "es2022",
|
||||||
"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"]
|
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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) },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -8,14 +8,18 @@
|
|||||||
"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",
|
||||||
@@ -25,8 +29,9 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
@@ -1,25 +1,8 @@
|
|||||||
import { status as apiStatus } from '@cryptgeon/shared'
|
import { API, type Status } from 'cryptgeon/shared'
|
||||||
import { writable } from 'svelte/store'
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
export type StatusInfo = {
|
export const status = writable<null | Status>(null)
|
||||||
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 apiStatus()) as StatusInfo)
|
status.set(await API.status())
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
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: { views: number; expiration: number }
|
note: Note
|
||||||
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,
|
||||||
data: new Uint8Array(await file.arrayBuffer()),
|
type: file.type,
|
||||||
|
contents: new Uint8Array(await file.arrayBuffer()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
import { status } from '$lib/stores/status'
|
import { status } from '$lib/stores/status'
|
||||||
|
|
||||||
// Payload is raw bytes (msgpack + cipher), no base64 padding overhead.
|
// Due to encoding overhead (~35%) with base64
|
||||||
|
// https://en.wikipedia.org/wiki/Base64
|
||||||
|
const overhead = 1 / 1.35
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{#if $status !== null}
|
{#if $status !== null}
|
||||||
{prettyBytes($status.max_size, { binary: true })}
|
{prettyBytes($status.max_size * overhead, { binary: true })}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('common.loading')}
|
{$_('common.loading')}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -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.data.slice(0)], { type: f.mime })))
|
const urls = files.map((f) => URL.createObjectURL(new Blob([f.contents], { type: f.type })))
|
||||||
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.mime)}
|
{#if isImage(entry.type)}
|
||||||
<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.mime}
|
{entry.name.split('.').pop()?.toUpperCase() || entry.type}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
export type DecryptedNote = {
|
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
|
||||||
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)
|
||||||
@@ -23,7 +20,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 } from '@cryptgeon/shared'
|
import type { FileDTO, NotePublic } from 'cryptgeon/shared'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
note: DecryptedNote
|
note: DecryptedNote
|
||||||
@@ -34,9 +31,10 @@
|
|||||||
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
|
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
|
||||||
let files: FileDTO[] = $state([])
|
let files: FileDTO[] = $state([])
|
||||||
|
|
||||||
async function downloadFile(file: FileDTO) {
|
async function downloadFile(file: FileDTO) {
|
||||||
const f = new File([file.data.slice(0)], file.name, {
|
// @ts-ignore
|
||||||
type: file.mime,
|
const f = new File([file.contents], file.name, {
|
||||||
|
type: file.type,
|
||||||
})
|
})
|
||||||
saveAs(f)
|
saveAs(f)
|
||||||
}
|
}
|
||||||
@@ -80,12 +78,12 @@ async function downloadFile(file: FileDTO) {
|
|||||||
<button onclick={() => downloadFile(file)}>
|
<button onclick={() => downloadFile(file)}>
|
||||||
<b>↓ {file.name}</b>
|
<b>↓ {file.name}</b>
|
||||||
</button>
|
</button>
|
||||||
<small> {file.mime} - {prettyBytes(file.size ?? file.data.length)}</small>
|
<small> {file.type} - {prettyBytes(file.size)}</small>
|
||||||
</div>
|
</div>
|
||||||
{#if file.mime.startsWith('image/')}
|
{#if file.type.startsWith('image/')}
|
||||||
{#key file.name}
|
{#key file.name}
|
||||||
<img
|
<img
|
||||||
src={URL.createObjectURL(new File([file.data.slice(0)], file.name, { type: file.mime }))}
|
src={URL.createObjectURL(new File([file.contents], file.name, { type: file.type }))}
|
||||||
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 { randomBytes, bytesToHex } from '@cryptgeon/shared'
|
import { getRandomBytes, Hex } from 'occulto'
|
||||||
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 = bytesToHex(randomBytes(32))
|
value = Hex.encode(await getRandomBytes(32))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { AES, Hex } from 'occulto'
|
||||||
bytesToHex,
|
|
||||||
create as apiCreate,
|
|
||||||
packContent,
|
|
||||||
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'
|
||||||
|
|
||||||
@@ -19,8 +14,14 @@
|
|||||||
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: { views: number; expiration: number } = $state({ views: 1, expiration: 60 })
|
let note: Note = $state({
|
||||||
|
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)
|
||||||
@@ -30,7 +31,6 @@
|
|||||||
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,7 +50,13 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!isFile) textContent = ''
|
note.meta.type = isFile ? 'file' : 'text'
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!isFile) {
|
||||||
|
note.contents = ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handlePaste(e: ClipboardEvent) {
|
async function handlePaste(e: ClipboardEvent) {
|
||||||
@@ -92,12 +98,11 @@
|
|||||||
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,
|
||||||
data,
|
type: renamed.type,
|
||||||
|
contents: new Uint8Array(await renamed.arrayBuffer()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -117,34 +122,40 @@
|
|||||||
try {
|
try {
|
||||||
loading = $t('common.encrypting')
|
loading = $t('common.encrypting')
|
||||||
|
|
||||||
|
const derived = customPassword && (await AES.derive(customPassword))
|
||||||
|
const key = derived ? derived[0] : await AES.generateKey()
|
||||||
|
|
||||||
|
const data: Note = {
|
||||||
|
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()
|
||||||
} else if (textContent === '') {
|
data.contents = await Adapters.Files.encrypt(files, key)
|
||||||
throw new EmptyContentError()
|
} else {
|
||||||
}
|
if (note.contents === '') throw new EmptyContentError()
|
||||||
|
data.contents = await Adapters.Text.encrypt(note.contents, key)
|
||||||
const payload = packContent(
|
|
||||||
isFile ? { type: 'files', files } : { type: 'text', text: textContent },
|
|
||||||
customPassword || undefined
|
|
||||||
)
|
|
||||||
const serverNote: ServerNote = {
|
|
||||||
meta: {
|
|
||||||
...(timeExpiration ? { expiration: parseInt(note.expiration as any) } : { views: parseInt(note.views as any) }),
|
|
||||||
extra: payload.extra,
|
|
||||||
},
|
|
||||||
data: payload.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 apiCreate(serverNote)
|
const response = await API.create(data)
|
||||||
result = {
|
result = {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
password: customPassword ? undefined : bytesToHex(payload.key),
|
password: customPassword ? undefined : Hex.encode(key),
|
||||||
}
|
}
|
||||||
notify.success($t('home.messages.note_created'))
|
notify.success($t('home.messages.note_created'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
if (e instanceof PayloadToLargeError) {
|
||||||
notify.error($t('home.errors.note_error'))
|
notify.error($t('home.errors.note_too_big'))
|
||||||
|
} else if (e instanceof EmptyContentError) {
|
||||||
|
notify.error($t('home.errors.empty_content'))
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
notify.error($t('home.errors.note_error'))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading = null
|
loading = null
|
||||||
}
|
}
|
||||||
@@ -172,7 +183,7 @@
|
|||||||
<TextArea
|
<TextArea
|
||||||
data-testid="text-field"
|
data-testid="text-field"
|
||||||
label={$t('common.note')}
|
label={$t('common.note')}
|
||||||
bind:value={textContent}
|
bind:value={note.contents}
|
||||||
placeholder="..."
|
placeholder="..."
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -45,7 +45,18 @@
|
|||||||
</span>
|
</span>
|
||||||
</AboutParagraph>
|
</AboutParagraph>
|
||||||
|
|
||||||
<AboutParagraph title="attribution">
|
<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">
|
||||||
<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
|
||||||
<a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
|
<a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { deriveKey, hexToBytes, decode, info, get as apiGet, unpackContent, type FileDTO } from '@cryptgeon/shared'
|
import { AES, Hex } from 'occulto'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
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 {
|
||||||
@@ -19,7 +20,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 hasExtra = $state(false)
|
let meta: NoteMeta | null = $state(null)
|
||||||
|
|
||||||
let loading: string | null = $state(null)
|
let loading: string | null = $state(null)
|
||||||
let error: string | null = $state(null)
|
let error: string | null = $state(null)
|
||||||
@@ -27,16 +28,13 @@
|
|||||||
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 meta = await info(id)
|
const note = await API.info(id)
|
||||||
if (meta) {
|
meta = note.meta
|
||||||
hasExtra = !!meta.extra?.length
|
exists = true
|
||||||
exists = true
|
|
||||||
} else {
|
|
||||||
exists = false
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
exists = false
|
exists = false
|
||||||
} finally {
|
} finally {
|
||||||
@@ -44,6 +42,9 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
@@ -52,42 +53,26 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load note
|
||||||
error = null
|
error = null
|
||||||
loading = $t('common.downloading')
|
loading = $t('common.downloading')
|
||||||
const serverNote = await apiGet(id)
|
const data = await API.get(id)
|
||||||
if (!serverNote) {
|
|
||||||
error = $t('show.errors.not_found')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading = $t('common.decrypting')
|
loading = $t('common.decrypting')
|
||||||
let key: Uint8Array
|
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
|
||||||
if (hasExtra && serverNote.meta.extra && serverNote.meta.extra.length > 0) {
|
const key = derived ? derived[0] : Hex.decode(password!)
|
||||||
const derivation = decode(serverNote.meta.extra) as any
|
switch (data.meta.type) {
|
||||||
key = deriveKey(password!, new Uint8Array(derivation.salt))
|
|
||||||
} else {
|
|
||||||
key = hexToBytes(password!)
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = unpackContent(serverNote.data, key)
|
|
||||||
|
|
||||||
switch (content.type) {
|
|
||||||
case 'text':
|
case 'text':
|
||||||
note = {
|
note = {
|
||||||
meta: { type: 'text' },
|
meta: { type: 'text' },
|
||||||
contents: content.data,
|
contents: await Adapters.Text.decrypt(data.contents, key),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'file':
|
||||||
|
note = {
|
||||||
|
meta: { type: 'file' },
|
||||||
|
contents: await Adapters.Files.decrypt(data.contents, key),
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
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 = {
|
|
||||||
meta: { type: 'file' },
|
|
||||||
contents: files,
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
error = $t('show.errors.unsupported_type')
|
error = $t('show.errors.unsupported_type')
|
||||||
return
|
return
|
||||||
@@ -109,7 +94,7 @@ break
|
|||||||
<form onsubmit={show}>
|
<form onsubmit={show}>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<p>{$t('show.explanation')}</p>
|
<p>{$t('show.explanation')}</p>
|
||||||
{#if hasExtra}
|
{#if meta?.derivation}
|
||||||
<TextInput
|
<TextInput
|
||||||
data-testid="show-note-password"
|
data-testid="show-note-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import LZ4 from "lz4js";
|
|
||||||
|
|
||||||
export function compress(data: Uint8Array): Uint8Array {
|
|
||||||
return LZ4.compress(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decompress(data: Uint8Array): Uint8Array {
|
|
||||||
return LZ4.decompress(data);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from "./crypto.js";
|
|
||||||
export * from "./types.js";
|
|
||||||
export * from "./api.js";
|
|
||||||
export * from "./compression.js";
|
|
||||||
export * from "./payload.js";
|
|
||||||
export { encode, decode } from "@msgpack/msgpack";
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { packContent, unpackContent } from "./payload";
|
|
||||||
import { bytesToUtf8, utf8ToBytes } from "./crypto";
|
|
||||||
|
|
||||||
describe("payload", () => {
|
|
||||||
it("round-trips a text note through the full pipeline", () => {
|
|
||||||
const { data, extra, key } = packContent({ type: "text", text: "hello world" });
|
|
||||||
expect(extra.length).toBe(0);
|
|
||||||
const content = unpackContent(data, key);
|
|
||||||
expect(content).toEqual({ type: "text", data: "hello world" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips with a password and sets extra", () => {
|
|
||||||
const { data, extra, key } = packContent({ type: "text", text: "secret" }, "pw123");
|
|
||||||
expect(extra.length).toBeGreaterThan(0);
|
|
||||||
const content = unpackContent(data, key);
|
|
||||||
expect(content).toEqual({ type: "text", data: "secret" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips files (FileDTO)", () => {
|
|
||||||
const file = { name: "a.txt", mime: "text/plain", size: 5, data: utf8ToBytes("hello") };
|
|
||||||
const { data, key } = packContent({ type: "files", files: [file] });
|
|
||||||
const content = unpackContent(data, key);
|
|
||||||
expect(content.type).toBe("files");
|
|
||||||
if (content.type === "files") {
|
|
||||||
expect(content.data).toHaveLength(1);
|
|
||||||
expect(content.data[0]!.name).toBe("a.txt");
|
|
||||||
expect(bytesToUtf8(content.data[0]!.data)).toBe("hello");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { encode, decode } from "@msgpack/msgpack";
|
|
||||||
import { compress, decompress } from "./compression.js";
|
|
||||||
import {
|
|
||||||
deriveKey,
|
|
||||||
encrypt,
|
|
||||||
decrypt,
|
|
||||||
generateKey,
|
|
||||||
randomBytes,
|
|
||||||
} from "./crypto.js";
|
|
||||||
import type { FileDTO, NoteContent } from "./types.js";
|
|
||||||
|
|
||||||
export type NoteInput =
|
|
||||||
| { type: "text"; text: string }
|
|
||||||
| { type: "files"; files: FileDTO[] };
|
|
||||||
|
|
||||||
export type PackResult = {
|
|
||||||
data: Uint8Array;
|
|
||||||
extra: Uint8Array;
|
|
||||||
key: Uint8Array;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function packContent(input: NoteInput, password?: string): PackResult {
|
|
||||||
let key: Uint8Array;
|
|
||||||
let extra: Uint8Array;
|
|
||||||
if (password) {
|
|
||||||
const salt = randomBytes(16);
|
|
||||||
key = deriveKey(password, salt);
|
|
||||||
extra = encode({ salt, N: 32768, r: 8, p: 1 });
|
|
||||||
} else {
|
|
||||||
key = generateKey();
|
|
||||||
extra = new Uint8Array();
|
|
||||||
}
|
|
||||||
|
|
||||||
const content: NoteContent =
|
|
||||||
input.type === "text"
|
|
||||||
? { type: "text", data: input.text }
|
|
||||||
: { type: "files", data: input.files };
|
|
||||||
|
|
||||||
const encoded = encrypt(compress(encode(content)), key);
|
|
||||||
return { data: encoded, extra, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unpackContent(data: Uint8Array, key: Uint8Array): NoteContent {
|
|
||||||
const content = decode(decompress(decrypt(data, key))) as NoteContent;
|
|
||||||
if (content.type !== "text" && content.type !== "files") {
|
|
||||||
throw new Error("Unknown content type");
|
|
||||||
}
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"noEmit": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: {
|
|
||||||
environment: "node",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Generated
+2773
-2551
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
+2
-5
@@ -33,10 +33,7 @@ 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) {
|
if (options.views || options.expiration || options.password) await page.getByTestId('switch-advanced').click()
|
||||||
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())
|
||||||
}
|
}
|
||||||
@@ -100,7 +97,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.mjs', args, {
|
return await exec('./packages/cli/dist/cli.cjs', args, {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
CRYPTGEON_SERVER: 'http://localhost:3000',
|
CRYPTGEON_SERVER: 'http://localhost:3000',
|
||||||
|
|||||||
+6
-8
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { readFileSync, writeFileSync } from 'node:fs'
|
import shelljs from 'shelljs'
|
||||||
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)) {
|
||||||
@@ -11,12 +12,9 @@ if (!semver.test(VERSION)) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sed(file, pattern, replacement) {
|
// CLI
|
||||||
const content = readFileSync(file, 'utf-8')
|
shelljs.sed('-i', /"version": ".*"/, `"version": "${process.argv[2]}"`, './packages/cli/package.json')
|
||||||
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' })
|
||||||
Reference in New Issue
Block a user