mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-09-27 04:51:45 +00:00
Compare commits
105
Commits
v2.9.0
...
f6ea6376e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6ea6376e1 | ||
|
|
19e7899309 | ||
|
|
9e69730bfd | ||
|
|
b5e1c4b42a | ||
|
|
7660dd7c30 | ||
|
|
685784c360 | ||
|
|
cc2c45221b | ||
|
|
b113d253a9 | ||
|
|
c99c62cf00 | ||
|
|
7ef162bd83 | ||
|
|
282fdb97d1 | ||
|
|
4dfed492bc | ||
|
|
5084ed59f0 | ||
|
|
055a48a38d | ||
|
|
d1126ace82 | ||
|
|
b58bf8af6d | ||
|
|
a57ead61b9 | ||
|
|
0d597edfee | ||
|
|
7653409473 | ||
|
|
f05707e033 | ||
|
|
8df6b6c7b5 | ||
|
|
2dc9d6aa16 | ||
|
|
98c56f6fb9 | ||
|
|
3f7bb6f32e | ||
|
|
6de97039d3 | ||
|
|
a0bca0a1a7 | ||
|
|
41d0f0bfa2 | ||
|
|
ba7669514e | ||
|
|
8a3831fd05 | ||
|
|
56f8498530 | ||
|
|
c3c6e96774 | ||
|
|
b5c5629cca | ||
|
|
74f387f920 | ||
|
|
b7c20d6f59 | ||
|
|
b0737030b2 | ||
|
|
918640e54c | ||
|
|
7a68422d67 | ||
|
|
8162db4364 | ||
|
|
f36ae73b42 | ||
|
|
2ded578141 | ||
|
|
82900adef8 | ||
|
|
8871a6b90d | ||
|
|
77b7ae1de6 | ||
|
|
be617383f4 | ||
|
|
3ff1f8202b | ||
|
|
e81dde63ff | ||
|
|
4f5f829c81 | ||
|
|
c78845ca7a | ||
|
|
dd213d6b41 | ||
|
|
849ab7e4c5 | ||
|
|
6a4d40e7be | ||
|
|
2f4ebb90e6 | ||
|
|
e0d22283c8 | ||
|
|
8034b1a501 | ||
|
|
062054f450 | ||
|
|
1f180a2e53 | ||
|
|
6b29c6f069 | ||
|
|
40f3559533 | ||
|
|
ac686e1935 | ||
|
|
2cb984405f | ||
|
|
8a000ce131 | ||
|
|
38987efc8a | ||
|
|
c3e475c16d | ||
|
|
5ff4d42aa6 | ||
|
|
9c4fbc30f9 | ||
|
|
f55925de86 | ||
|
|
afbe6aac0f | ||
|
|
e14abeee74 | ||
|
|
f5823fb533 | ||
|
|
3e3d528d5a | ||
|
|
84ec85e96f | ||
|
|
905bce0072 | ||
|
|
e8c3b53e49 | ||
|
|
d40bbd25ea | ||
|
|
339c3ac445 | ||
|
|
e27915db06 | ||
|
|
d400cfc8e6 | ||
|
|
198accddeb | ||
|
|
3cbe9fabf2 | ||
|
|
2923aab916 | ||
|
|
b5102dc647 | ||
|
|
12d8b87cc1 | ||
|
|
f42662812f | ||
|
|
a551b16216 | ||
|
|
09840dcf0a | ||
|
|
24e99b84e0 | ||
|
|
690b955d5d | ||
|
|
9b0155dc9a | ||
|
|
0a56c4c572 | ||
|
|
f6bf8c656c | ||
|
|
1a243cc96a | ||
|
|
482795dd9a | ||
|
|
2907e7c002 | ||
|
|
57001e90a4 | ||
|
|
4cc9d8a758 | ||
|
|
d652c4ee1e | ||
|
|
096be03966 | ||
|
|
c53cde6886 | ||
|
|
0fa5a35dae | ||
|
|
ebbb4efa04 | ||
|
|
a248440bfd | ||
|
|
a1db60d159 | ||
|
|
c2653bee84 | ||
|
|
a2d2acc5de | ||
|
|
4cc821150d |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -10,35 +10,38 @@ jobs:
|
|||||||
cli:
|
cli:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v2
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
pnpm install --frozen-lockfile
|
pnpm install
|
||||||
pnpm run build
|
pnpm --filter cryptgeon build
|
||||||
|
|
||||||
- run: npm publish
|
- name: Publish to npm
|
||||||
working-directory: ./packages/cli
|
run: |
|
||||||
|
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@v3
|
- uses: actions/checkout@v7
|
||||||
- uses: docker/setup-qemu-action@v2
|
- uses: docker/setup-qemu-action@v4
|
||||||
- uses: docker/setup-buildx-action@v2
|
- uses: docker/setup-buildx-action@v4
|
||||||
with:
|
|
||||||
install: true
|
|
||||||
- name: Docker Labels
|
- name: Docker Labels
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: cupcakearmy/cryptgeon
|
images: cupcakearmy/cryptgeon
|
||||||
tags: |
|
tags: |
|
||||||
@@ -46,12 +49,12 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
- name: Login to DockerHub
|
- name: Login to DockerHub
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
|
|||||||
@@ -10,31 +10,37 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v6
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v7
|
||||||
with:
|
with:
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
node-version-file: '.nvmrc'
|
node-version-file: '.nvmrc'
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
- uses: docker/setup-qemu-action@v3
|
- uses: docker/setup-qemu-action@v4
|
||||||
- uses: docker/setup-buildx-action@v3
|
- 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: Frontend type check
|
||||||
|
run: pnpm --filter @cryptgeon/web check
|
||||||
|
|
||||||
- name: Run your tests
|
- name: Run your tests
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v7
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
with:
|
with:
|
||||||
name: playwright-report
|
name: playwright-report
|
||||||
|
|||||||
+200
@@ -5,6 +5,187 @@ 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).
|
||||||
|
- Cache-backed note storage using hashes (valkey/redis) with atomic view counting.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Encryption from AES to **XChaCha20-Poly1305** (client-side); dropped `occulto`.
|
||||||
|
- All API bodies switched to **MessagePack**.
|
||||||
|
- Frontend uses `@cryptgeon/shared` for crypto + payload codec (replacing the previous local `cryptgeon/shared`); heavy pack/unpack runs in a web worker.
|
||||||
|
- CLI rebuilt with `vite-plus` (bundles all deps) and imports from `@cryptgeon/shared`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- SPA fallback now serves the app (200) for client-side routes such as `/about` and `/note/<id>`, while unmatched `/api/*` paths still return 404.
|
||||||
|
|
||||||
|
### 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
|
||||||
@@ -12,6 +193,23 @@ 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
|
||||||
@@ -30,6 +228,8 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- [mise](https://mise.jdx.dev) — manages pnpm, rust, node (see `mise.toml`)
|
||||||
|
- docker or [colima](https://github.com/abiosoft/colima) (for cache)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mise install
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
End-to-end tests with Playwright.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm run test:prepare
|
||||||
|
pnpm run test:local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release
|
||||||
|
|
||||||
|
1. Update version across packages:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./version.mjs <semver>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create and push the tag:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag v<semver>
|
||||||
|
git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
The CI workflow publishes the CLI to npm and the Docker image to Docker Hub automatically.
|
||||||
@@ -1,614 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# FRONTEND
|
# FRONTEND
|
||||||
FROM node:22-alpine as client
|
FROM node:24-alpine AS client
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
@@ -11,7 +11,7 @@ RUN pnpm run build
|
|||||||
|
|
||||||
|
|
||||||
# BACKEND
|
# BACKEND
|
||||||
FROM rust:1.80-alpine as backend
|
FROM rust:1.95-alpine AS backend
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
RUN apk add --no-cache libc-dev openssl-dev alpine-sdk
|
RUN apk add --no-cache libc-dev openssl-dev alpine-sdk
|
||||||
COPY ./packages/backend ./
|
COPY ./packages/backend ./
|
||||||
@@ -19,12 +19,12 @@ RUN RUSTFLAGS="-Ctarget-feature=-crt-static" cargo build --release
|
|||||||
|
|
||||||
|
|
||||||
# RUNNER
|
# RUNNER
|
||||||
FROM alpine:3.19
|
FROM alpine:3
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache curl libgcc
|
RUN apk add --no-cache curl libgcc
|
||||||
COPY --from=backend /tmp/target/release/cryptgeon .
|
COPY --from=backend /tmp/target/release/cryptgeon .
|
||||||
COPY --from=client /tmp/packages/frontend/build ./frontend
|
COPY --from=client /tmp/packages/frontend/build ./frontend
|
||||||
ENV FRONTEND_PATH="./frontend"
|
ENV FRONTEND_PATH="./frontend"
|
||||||
ENV REDIS="redis://redis/"
|
ENV CACHE="redis://cache/"
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
ENTRYPOINT [ "/app/cryptgeon" ]
|
ENTRYPOINT [ "/app/cryptgeon" ]
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
||||||
<a href=""><img src="./.github/lokalise.png" height="50">
|
|
||||||
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
|
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|
||||||
@@ -23,8 +22,6 @@ _cryptgeon_ is a secure, open source sharing note or file service inspired by [_
|
|||||||
It includes a server, a web page and a CLI client.
|
It includes a server, a web page and a CLI client.
|
||||||
|
|
||||||
> 🌍 If you want to translate the project feel free to reach out to me.
|
> 🌍 If you want to translate the project feel free to reach out to me.
|
||||||
>
|
|
||||||
> Thanks to [Lokalise](https://lokalise.com/) for providing free access to their platform.
|
|
||||||
|
|
||||||
## Live Service / Demo
|
## Live Service / Demo
|
||||||
|
|
||||||
@@ -58,12 +55,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 aes in gcm mode on the
|
is used to save & retrieve the note. the note is then encrypted with XChaCha20-Poly1305 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 Redis instance can run into race conditions, where a note might be retrieved more than the view count allows.
|
> View counts are guaranteed with one running instance of cryptgeon. Multiple instances connected to the same cache instance can run into race conditions, where a note might be retrieved more than the view count allows.
|
||||||
|
|
||||||
## Screenshot
|
## Screenshot
|
||||||
|
|
||||||
@@ -71,28 +68,32 @@ of the notes even if it tried to.
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
| `CACHE` | `redis://cache/` | Cache URL (valkey or redis) to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
||||||
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
|
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> Payloads are raw bytes (msgpack + cipher), so the frontend shows the full limit. |
|
||||||
| `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_. |
|
||||||
| `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` |
|
| `CACHE_PREFIX` | `""` | Optional prefix for all cache keys. Useful when sharing a cache instance with other apps via ACL namespaces. |
|
||||||
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
| `EXTRA_SIZE_LIMIT` | `512` | Maximum size in bytes of the opaque `extra` payload (e.g. key derivation params) stored on the note metadata. |
|
||||||
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
| `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_PAGE_TITLE` | `""` | Custom text the page title |
|
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
||||||
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
||||||
| `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_PAGE_TITLE` | `""` | Custom text the page title |
|
||||||
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
|
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
||||||
| `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.|
|
| `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`. |
|
||||||
|
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
|
||||||
|
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
|
||||||
|
|
||||||
## Deployment
|
## 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 `/api/health/`. It returns either 200 or 503.
|
> ℹ️ There is a health endpoint available at `/healthz`. It returns either 200 or 503.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -101,19 +102,22 @@ 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:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
# Set a size limit. See link below on how to customise.
|
# Set a size limit. See link below on how to customise.
|
||||||
# https://redis.io/docs/manual/eviction/
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:v3
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
environment:
|
environment:
|
||||||
# Size limit for a single note.
|
# Size limit for a single note.
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
@@ -122,7 +126,7 @@ services:
|
|||||||
|
|
||||||
# Optional health checks
|
# Optional health checks
|
||||||
# healthcheck:
|
# healthcheck:
|
||||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
|
||||||
# interval: 1m
|
# interval: 1m
|
||||||
# timeout: 3s
|
# timeout: 3s
|
||||||
# retries: 2
|
# retries: 2
|
||||||
@@ -157,58 +161,18 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
|
|||||||
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
|
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
|
||||||
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
|
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
|
||||||
|
|
||||||
## Development
|
## Contributing
|
||||||
|
|
||||||
**Requirements**
|
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||||
|
|
||||||
- `pnpm`: `>=9`
|
|
||||||
- `node`: `>=22`
|
|
||||||
- `rust`: edition `2021`
|
|
||||||
|
|
||||||
**Install**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Also you need cargo watch if you don't already have it installed.
|
|
||||||
# https://lib.rs/crates/cargo-watch
|
|
||||||
cargo install cargo-watch
|
|
||||||
```
|
|
||||||
|
|
||||||
**Run**
|
|
||||||
|
|
||||||
Make sure you have docker running.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Running `pnpm run dev` in the root folder will start the following things:
|
|
||||||
|
|
||||||
- redis docker container
|
|
||||||
- rust backend
|
|
||||||
- client
|
|
||||||
- cli
|
|
||||||
|
|
||||||
You can see the app under [localhost:3000](http://localhost:3000).
|
|
||||||
|
|
||||||
> There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Tests are end to end tests written with Playwright.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run test:prepare
|
|
||||||
|
|
||||||
# Use the test or test:local script. The local version only runs in one browser for quicker development.
|
|
||||||
pnpm run test:local
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
Please refer to the security section [here](./SECURITY.md).
|
Please refer to the security section [here](./SECURITY.md).
|
||||||
|
|
||||||
|
## Usage of LLMs
|
||||||
|
|
||||||
|
Starting from V3, I used LLMs heavily to implement _my own ideas_. This means that the direction and architecture choices are human. A lot of the implementation that derives from that, is automated with an LLM.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_Attributions_
|
_Attributions_
|
||||||
|
|||||||
+44
-77
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
||||||
<a href=""><img src="./.github/lokalise.png" height="50">
|
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|
||||||
[EN](README.md) | [简体中文](README_zh-CN.md) | ES
|
[EN](README.md) | [简体中文](README_zh-CN.md) | ES
|
||||||
@@ -22,8 +21,6 @@ _cryptgeon_ es un servicio seguro y de código abierto para compartir notas o ar
|
|||||||
Incluye un servidor, una página web y una interfaz de línea de comandos (CLI, por sus siglas en inglés).
|
Incluye un servidor, una página web y una interfaz de línea de comandos (CLI, por sus siglas en inglés).
|
||||||
|
|
||||||
> 🌍 Si quieres traducir este proyecto no dudes en ponerte en contacto conmigo.
|
> 🌍 Si quieres traducir este proyecto no dudes en ponerte en contacto conmigo.
|
||||||
>
|
|
||||||
> Gracias a [Lokalise](https://lokalise.com/) por darnos acceso gratis a su plataforma.
|
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
@@ -43,7 +40,7 @@ Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/RE
|
|||||||
|
|
||||||
- enviar texto o archivos
|
- enviar texto o archivos
|
||||||
- el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente
|
- el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente
|
||||||
- restriccion de vistas o de tiempo
|
- restricción de vistas o de tiempo
|
||||||
- en memoria, sin persistencia
|
- en memoria, sin persistencia
|
||||||
- compatibilidad obligatoria con el modo oscuro
|
- compatibilidad obligatoria con el modo oscuro
|
||||||
|
|
||||||
@@ -51,7 +48,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 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.
|
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.
|
||||||
|
|
||||||
## Capturas de pantalla
|
## Capturas de pantalla
|
||||||
|
|
||||||
@@ -59,46 +56,56 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
|
|||||||
|
|
||||||
## Variables de entorno
|
## Variables de entorno
|
||||||
|
|
||||||
| Variable | Default | Descripción |
|
| Variable | Default | Descripción |
|
||||||
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
| `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) |
|
||||||
| `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. |
|
| `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. |
|
||||||
| `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. |
|
||||||
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
|
| `ALLOW_FILES` | `true` | Permitir subir archivos. Si es `false`, los usuarios solo podrán crear notas de texto. |
|
||||||
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
|
| `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_. |
|
||||||
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
|
| `CACHE_PREFIX` | `""` | Prefijo opcional para las claves de caché. Útil al compartir una instancia con otras apps vía namespaces ACL. |
|
||||||
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
|
| `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_PAGE_TITLE` | `""` | Texto personalizado para el título |
|
| `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_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
|
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
|
||||||
|
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
|
||||||
|
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título. |
|
||||||
|
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
|
||||||
|
| `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 encriptacó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 `/api/health/`. Regresa un código 200 o 503.
|
> ℹ️ Hay un endpoint para verificar el estado, lo encontramos en `/healthz`. Regresa un código 200 o 503.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
Docker es la manera más fácil. Aquí encontramos [la imágen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
|
Docker es la manera más fácil. Aquí encontramos [la imagen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# docker-compose.yml
|
||||||
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
# Set a size limit. See link below on how to customise.
|
# Set a size limit. See link below on how to customise.
|
||||||
# https://redis.io/docs/manual/eviction/
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
# --maxmemory 1g --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:v3
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
environment:
|
environment:
|
||||||
# Size limit for a single note.
|
# Size limit for a single note.
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
@@ -107,7 +114,7 @@ services:
|
|||||||
|
|
||||||
# Optional health checks
|
# Optional health checks
|
||||||
# healthcheck:
|
# healthcheck:
|
||||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
|
||||||
# interval: 1m
|
# interval: 1m
|
||||||
# timeout: 3s
|
# timeout: 3s
|
||||||
# retries: 2
|
# retries: 2
|
||||||
@@ -136,57 +143,17 @@ Hay una [guía](https://mariushosting.com/how-to-install-cryptgeon-on-your-synol
|
|||||||
- En inglés, por [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
|
- En inglés, por [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
|
||||||
- En alemán, por [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
|
- En alemán, por [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
|
||||||
|
|
||||||
## Desarrollo
|
## Contribuir
|
||||||
|
|
||||||
**Requisitos**
|
Ver [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||||
|
|
||||||
- `pnpm`: `>=6`
|
|
||||||
- `node`: `>=18`
|
|
||||||
- `rust`: edition `2021`
|
|
||||||
|
|
||||||
**Instalación**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# También necesitas cargo-watch, si no lo tienes instalado.
|
|
||||||
# https://lib.rs/crates/cargo-watch
|
|
||||||
cargo install cargo-watch
|
|
||||||
```
|
|
||||||
|
|
||||||
**Ejecutar**
|
|
||||||
|
|
||||||
Asegurate de que docker se esté ejecutando.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Ejecutando `pnpm run dev` en la carpeta raíz iniciará lo siguiente:
|
|
||||||
|
|
||||||
- redis docker container
|
|
||||||
- rust backend
|
|
||||||
- client
|
|
||||||
- cli
|
|
||||||
|
|
||||||
Puedes ver la app en [localhost:3000](http://localhost:3000).
|
|
||||||
|
|
||||||
> Existe una colección de Postman con algunas peticiones de ejemplo [disponible en el repo](./Cryptgeon.postman_collection.json)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
Los tests son end-to-end tests escritos con Playwright.
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run test:prepare
|
|
||||||
|
|
||||||
# Usa el script test o test:local. La versión local solo corre en el navegador para acelerar el desarrollo.
|
|
||||||
pnpm run test:local
|
|
||||||
```
|
|
||||||
|
|
||||||
## Seguridad
|
## Seguridad
|
||||||
|
|
||||||
Por favor dirigite a la sección de seguridad [aquí](./SECURITY.md).
|
Por favor dirígete a la sección de seguridad [aquí](./SECURITY.md).
|
||||||
|
|
||||||
|
## Uso de LLMs
|
||||||
|
|
||||||
|
A partir de la V3, utilicé LLMs de forma intensiva para implementar _mis propias ideas_. Esto significa que la dirección y las decisiones de arquitectura son humanas. Gran parte de la implementación que deriva de eso está automatizada con un LLM.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+49
-70
@@ -11,7 +11,6 @@
|
|||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
||||||
<a href=""><img src="./.github/lokalise.png" height="50">
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
[EN](README.md) | 简体中文 | [ES](README_ES.md)
|
[EN](README.md) | 简体中文 | [ES](README_ES.md)
|
||||||
@@ -21,8 +20,6 @@
|
|||||||
_加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器
|
_加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器
|
||||||
|
|
||||||
> 🌍 如果你想翻译此项目请随时与我联系.
|
> 🌍 如果你想翻译此项目请随时与我联系.
|
||||||
>
|
|
||||||
> 感谢 [Lokalise](https://lokalise.com/) 提供免费的平台服务支持
|
|
||||||
|
|
||||||
## 演示示例
|
## 演示示例
|
||||||
|
|
||||||
@@ -39,7 +36,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>用于保存和提取密信, 在这之后这封密信将会被客户端使用 AES 算法的 GCM 模式和`key`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。
|
其中<code>id</code>用于保存和提取密信, 在这之后这封密信将会被客户端使用 XChaCha20-Poly1305 加密算法和`key`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。
|
||||||
|
|
||||||
## 屏幕截图
|
## 屏幕截图
|
||||||
|
|
||||||
@@ -47,15 +44,26 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
|
|||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
| 变量名称 | 默认值 | 描述 |
|
| 变量名称 | 默认值 | 描述 |
|
||||||
| ----------------- | ---------------- | --------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `REDIS` | `redis://redis/` | Redis 连接 URL。 |
|
| `CACHE` | `redis://cache/` | 缓存(valkey 或 redis)连接 URL。[连接参数](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
||||||
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
|
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。可通过 [字节单位](https://docs.rs/byte-unit/) 查看支持的值。负载是原始字节(msgpack + 加密),因此前端显示完整限制。 |
|
||||||
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
|
| `MAX_VIEWS` | `100` | 密信最多查看次数限制。 |
|
||||||
| ` MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
|
| `MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟)。 |
|
||||||
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
|
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块。 |
|
||||||
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
|
| `ALLOW_FILES` | `true` | 是否允许上传文件。为 `false` 时用户只能创建文本密信。 |
|
||||||
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
|
| `ID_LENGTH` | `32` | 设置密信 `id` 的字节大小。默认 `32` 字节,可用于缩短链接长度。_不影响加密强度_。 |
|
||||||
|
| `CACHE_PREFIX` | `""` | 缓存键可选前缀。与其它应用通过 ACL namespace 共享缓存实例时有用。 |
|
||||||
|
| `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` 只应指定其一。 | |
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -69,16 +77,23 @@ Docker 是最简单的部署方式。这里是[官方镜像的地址](https://hu
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.yml
|
# docker-compose.yml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:v3
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
environment:
|
environment:
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
ports:
|
ports:
|
||||||
@@ -99,22 +114,27 @@ services:
|
|||||||
- 域名 `example.org`
|
- 域名 `example.org`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
restart: unless-stopped
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:v3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- proxy
|
- proxy
|
||||||
@@ -125,54 +145,13 @@ services:
|
|||||||
- traefik.http.routers.cryptgeon.tls.certresolver=le
|
- traefik.http.routers.cryptgeon.tls.certresolver=le
|
||||||
```
|
```
|
||||||
|
|
||||||
## 开发
|
## 贡献
|
||||||
|
|
||||||
**环境要求**
|
参见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
|
||||||
|
|
||||||
- `pnpm`: `>=6`
|
## LLM 的使用
|
||||||
- `node`: `>=14`
|
|
||||||
- `rust`: edition `2021`
|
|
||||||
|
|
||||||
**安装**
|
从 V3 开始,我大量使用 LLM 来实现_我自己的想法_。这意味着项目的方向和架构选择均由人类决定。由此衍生的大部分实现由 LLM 自动完成。
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm --prefix frontend install
|
|
||||||
|
|
||||||
# 你还需要安装CargoWatch.
|
|
||||||
# https://lib.rs/crates/cargo-watch
|
|
||||||
cargo install cargo-watch
|
|
||||||
```
|
|
||||||
|
|
||||||
**运行**
|
|
||||||
|
|
||||||
确保你的 Docker 正在运行
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
在根目录执行 `pnpm run dev` 会开启下列服务:
|
|
||||||
|
|
||||||
- 一个 redis docker 容器
|
|
||||||
- 无热重载的 rust 后端
|
|
||||||
- 可热重载的客户端
|
|
||||||
|
|
||||||
你可以通过 3000 端口进入该应用,即 [localhost:3000](http://localhost:3000).
|
|
||||||
|
|
||||||
## 测试
|
|
||||||
|
|
||||||
这些测试是用 Playwright 实现的一些端到端测试用例。
|
|
||||||
|
|
||||||
```sh
|
|
||||||
pnpm run test:prepare
|
|
||||||
docker compose up redis -d
|
|
||||||
pnpm run test:server
|
|
||||||
|
|
||||||
# 在另一个终端中:
|
|
||||||
# 使用test或者test:local script。为了更快的开发,本地版本只会在一个浏览器中运行。
|
|
||||||
pnpm run test:local
|
|
||||||
```
|
|
||||||
|
|
||||||
###### Attributions
|
###### Attributions
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -2,8 +2,16 @@
|
|||||||
# For a production file see: README.md
|
# For a production file see: README.md
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
@@ -11,14 +19,14 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
env_file: .env.dev
|
env_file: .env.dev
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- 3000:8000
|
- 3000:8000
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/']
|
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/healthz']
|
||||||
interval: 1m
|
interval: 1m
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 2
|
retries: 2
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
+17
-14
@@ -1,24 +1,27 @@
|
|||||||
services:
|
services:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:9-alpine
|
||||||
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
# Set a size limit. See link below on how to customise.
|
# Set a size limit. See link below on how to customise.
|
||||||
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
# https://valkey.io/topics/lru-cache/
|
||||||
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
# --maxmemory 1gb --maxmemory-policy allkeys-lru
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:3
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
environment:
|
environment:
|
||||||
# Size limit for a single note.
|
# Size limit for a single note.
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
ports:
|
ports:
|
||||||
- 80:8000
|
- 80:8000
|
||||||
|
healthcheck:
|
||||||
# Optional health checks
|
test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
|
||||||
# healthcheck:
|
interval: 1m
|
||||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
timeout: 3s
|
||||||
# interval: 1m
|
retries: 2
|
||||||
# timeout: 3s
|
start_period: 5s
|
||||||
# retries: 2
|
|
||||||
# start_period: 5s
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 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,14 +1,21 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:v3
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
+24
-22
@@ -25,27 +25,26 @@ 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:
|
||||||
@@ -62,15 +61,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
|
||||||
@@ -100,22 +99,27 @@ 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:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
restart: unless-stopped
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:v3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
environment:
|
environment:
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
networks:
|
networks:
|
||||||
@@ -148,8 +152,6 @@ 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
|
||||||
|
|||||||
+24
-11
@@ -9,22 +9,27 @@ Assumptions:
|
|||||||
- Domain name `example.org`.
|
- Domain name `example.org`.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
services:
|
services:
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
restart: unless-stopped
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:v3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- proxy
|
- proxy
|
||||||
@@ -53,13 +58,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
|
|
||||||
redis:
|
cache:
|
||||||
image: redis:7-alpine
|
image: valkey/valkey:7-alpine
|
||||||
|
# This is required to stay in RAM only.
|
||||||
|
command: valkey-server --save "" --appendonly no
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
cryptgeon:
|
cryptgeon:
|
||||||
image: cupcakearmy/cryptgeon
|
image: cupcakearmy/cryptgeon:v3
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- cache
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
|
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[tools]
|
||||||
|
pnpm = "11.5.0"
|
||||||
|
rust = "1.95"
|
||||||
|
watchexec = "latest"
|
||||||
|
# Node loaded below from .nvmrc
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
idiomatic_version_file_enable_tools = ["node"]
|
||||||
|
|
||||||
|
[env]
|
||||||
|
SIZE_LIMIT = "100mb"
|
||||||
+11
-9
@@ -1,21 +1,23 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:docker": "docker compose -f docker-compose.dev.yaml up redis",
|
"dev:docker": "docker compose -f docker-compose.dev.yaml up cache",
|
||||||
"dev:packages": "pnpm --parallel run dev",
|
"dev:packages": "pnpm --parallel run dev",
|
||||||
"dev": "run-p dev:*",
|
"dev": "pnpm --parallel run /dev/",
|
||||||
"docker:up": "docker compose -f docker-compose.dev.yaml up",
|
"docker:up": "docker compose -f docker-compose.dev.yaml up",
|
||||||
"docker:build": "docker compose -f docker-compose.dev.yaml build",
|
"docker:build": "docker compose -f docker-compose.dev.yaml build",
|
||||||
"test": "playwright test --project=chrome --project=firefox --project=safari",
|
"test": "playwright test --project=chrome --project=firefox --project=safari",
|
||||||
"test:local": "playwright test --project=chrome",
|
"test:local": "playwright test --project=chrome",
|
||||||
"test:server": "run-s docker:up",
|
"test:server": "docker compose -f docker-compose.dev.yaml up",
|
||||||
"test:prepare": "run-p build docker:build",
|
"test:dl-browsers": "playwright install",
|
||||||
|
"test:prepare": "pnpm run build && pnpm run docker:build",
|
||||||
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
|
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.46.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/node": "^22.5.0",
|
"@types/node": "^24.13.3"
|
||||||
"npm-run-all": "^4.1.5",
|
|
||||||
"shelljs": "^0.8.5"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.15.4"
|
"packageManager": "pnpm@11.5.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+627
-462
File diff suppressed because it is too large
Load Diff
+12
-11
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
version = "2.9.0"
|
version = "3.0.0"
|
||||||
authors = ["cupcakearmy <hi@nicco.io>"]
|
authors = ["cupcakearmy <hi@nicco.io>"]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.80"
|
rust-version = "1.95"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
@@ -11,17 +11,18 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
# Core
|
# Core
|
||||||
axum = "0.7.5"
|
axum = "0.8"
|
||||||
serde = { version = "1.0.208", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
tokio = { version = "1.39.3", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = "0.5.0"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.5.2", features = ["full"] }
|
tower-http = { version = "0.6", features = ["full"] }
|
||||||
redis = { version = "0.25.2", features = ["tls-native-tls"] }
|
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.16"
|
ring = "0.17"
|
||||||
bs62 = "0.1"
|
bs62 = "0.1"
|
||||||
byte-unit = "4"
|
byte-unit = "4"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"name": "@cryptgeon/backend",
|
"name": "@cryptgeon/backend",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cargo watch -x 'run --bin cryptgeon'",
|
"dev": "watchexec -r -e rs cargo run",
|
||||||
"build": "cargo build --release",
|
"build": "cargo build --release",
|
||||||
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
|
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
|
||||||
"test:prepare": "cargo build"
|
"test:prepare": "cargo build"
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ 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")
|
||||||
|
.unwrap_or("".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
|
pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
|
||||||
.unwrap_or("true".to_string())
|
.unwrap_or("true".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
@@ -46,6 +50,10 @@ pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
|
|||||||
.unwrap_or("".to_string())
|
.unwrap_or("".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
pub static ref EXTRA_SIZE_LIMIT: usize = std::env::var("EXTRA_SIZE_LIMIT")
|
||||||
|
.unwrap_or("512".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// THEME
|
// THEME
|
||||||
@@ -70,4 +78,8 @@ lazy_static! {
|
|||||||
.unwrap_or("true".to_string())
|
.unwrap_or("true".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
pub static ref THEME_HOME_LINK: bool = std::env::var("THEME_HOME_LINK")
|
||||||
|
.unwrap_or("true".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
use axum::{body::Body, extract::Request, http::HeaderValue, middleware::Next, response::Response};
|
|
||||||
|
|
||||||
const CUSTOM_HEADER_NAME: &str = "Content-Security-Policy";
|
|
||||||
const CUSTOM_HEADER_VALUE: &str = "default-src 'self'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; object-src 'none'; base-uri 'self'; connect-src 'self' data:; font-src 'self'; frame-src 'self'; img-src 'self'; manifest-src 'self'; media-src 'self'; worker-src 'none';";
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
static ref HEADER_VALUE: HeaderValue = HeaderValue::from_static(CUSTOM_HEADER_VALUE);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_csp_header(request: Request<Body>, next: Next) -> Response {
|
|
||||||
let mut response = next.run(request).await;
|
|
||||||
response
|
|
||||||
.headers_mut()
|
|
||||||
.append(CUSTOM_HEADER_NAME, HEADER_VALUE.clone());
|
|
||||||
response
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,9 @@ use crate::store;
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
|
||||||
pub async fn report_health() -> (StatusCode,) {
|
pub async fn report_health() -> (StatusCode,) {
|
||||||
if store::can_reach_redis() {
|
if store::can_reach_cache() {
|
||||||
return (StatusCode::OK,);
|
return (StatusCode::OK,);
|
||||||
} else {
|
} else {
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE,);
|
return (StatusCode::SERVICE_UNAVAILABLE,);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SharedState {
|
|
||||||
pub locks: LockMap,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
|
|
||||||
@@ -1,17 +1,9 @@
|
|||||||
use std::{collections::HashMap, sync::Arc};
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
|
||||||
extract::{DefaultBodyLimit, Request},
|
|
||||||
http::HeaderValue,
|
|
||||||
middleware::{self, Next},
|
|
||||||
response::Response,
|
|
||||||
routing::{delete, get, post},
|
|
||||||
Router, ServiceExt,
|
Router, ServiceExt,
|
||||||
|
extract::{DefaultBodyLimit, Request},
|
||||||
|
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,
|
||||||
@@ -23,45 +15,45 @@ use tower_http::{
|
|||||||
extern crate lazy_static;
|
extern crate lazy_static;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
mod csp;
|
|
||||||
mod health;
|
mod health;
|
||||||
mod lock;
|
|
||||||
mod note;
|
mod note;
|
||||||
mod status;
|
mod status;
|
||||||
mod store;
|
mod store;
|
||||||
|
|
||||||
|
async fn api_not_found() -> axum::http::StatusCode {
|
||||||
|
axum::http::StatusCode::NOT_FOUND
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
|
|
||||||
let shared_state = SharedState {
|
if !store::can_reach_cache() {
|
||||||
locks: Arc::new(Mutex::new(HashMap::new())),
|
println!("cannot reach cache");
|
||||||
};
|
panic!("cannot reach cache");
|
||||||
|
|
||||||
if !store::can_reach_redis() {
|
|
||||||
println!("cannot reach redis");
|
|
||||||
panic!("cannot reach redis");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let notes_routes = Router::new()
|
let notes_routes = Router::new()
|
||||||
.route("/", post(note::create))
|
.route("/", post(note::create))
|
||||||
.route("/:id", delete(note::delete))
|
.route("/{id}", delete(note::view))
|
||||||
.route("/:id", get(note::preview));
|
.route("/{id}", get(note::preview));
|
||||||
let health_routes = Router::new().route("/live", get(health::report_health));
|
let health_routes = Router::new().route("/healthz", get(health::report_health));
|
||||||
let status_routes = Router::new().route("/status", get(status::get_status));
|
let status_routes = Router::new().route("/status", get(status::get_status));
|
||||||
let api_routes = Router::new()
|
let v3_routes = Router::new()
|
||||||
.nest("/notes", notes_routes)
|
.nest("/notes", notes_routes)
|
||||||
.nest("/", health_routes)
|
.merge(status_routes);
|
||||||
.nest("/", status_routes);
|
|
||||||
|
let api_routes = Router::new()
|
||||||
|
.nest("/v3", v3_routes)
|
||||||
|
.fallback(api_not_found);
|
||||||
|
|
||||||
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
|
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
|
||||||
let serve_dir =
|
let serve_dir =
|
||||||
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
|
ServeDir::new(config::FRONTEND_PATH.to_string()).fallback(ServeFile::new(index));
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.nest("/api", api_routes)
|
.nest("/api", api_routes)
|
||||||
|
.merge(health_routes)
|
||||||
.fallback_service(serve_dir)
|
.fallback_service(serve_dir)
|
||||||
// Disabled for now, as svelte inlines scripts
|
|
||||||
// .layer(middleware::from_fn(csp::add_csp_header))
|
|
||||||
.layer(DefaultBodyLimit::max(*config::LIMIT))
|
.layer(DefaultBodyLimit::max(*config::LIMIT))
|
||||||
.layer(
|
.layer(
|
||||||
CompressionLayer::new()
|
CompressionLayer::new()
|
||||||
@@ -69,8 +61,7 @@ async fn main() {
|
|||||||
.deflate(true)
|
.deflate(true)
|
||||||
.gzip(true)
|
.gzip(true)
|
||||||
.zstd(true),
|
.zstd(true),
|
||||||
)
|
);
|
||||||
.with_state(shared_state);
|
|
||||||
|
|
||||||
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
|
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ mod model;
|
|||||||
mod routes;
|
mod routes;
|
||||||
|
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
pub use routes::*;
|
pub use routes::*;
|
||||||
@@ -5,22 +5,35 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::config;
|
use crate::config;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct Note {
|
pub struct NoteMeta {
|
||||||
pub meta: String,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub contents: String,
|
|
||||||
pub views: Option<u32>,
|
pub views: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub expiration: Option<u32>,
|
pub expiration: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
pub struct NoteInfo {
|
pub struct CreateRequest {
|
||||||
pub meta: String,
|
pub meta: NoteMeta,
|
||||||
|
pub data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct NotePublic {
|
pub struct CreateResponse {
|
||||||
pub meta: String,
|
pub id: String,
|
||||||
pub contents: String,
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct MetaResponse {
|
||||||
|
pub meta: NoteMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct NoteResponse {
|
||||||
|
pub meta: NoteMeta,
|
||||||
|
pub data: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_id() -> String {
|
pub fn generate_id() -> String {
|
||||||
@@ -32,5 +45,5 @@ pub fn generate_id() -> String {
|
|||||||
let _ = sr.fill(&mut id);
|
let _ = sr.fill(&mut id);
|
||||||
result.push_str(&bs62::encode_data(&id));
|
result.push_str(&bs62::encode_data(&id));
|
||||||
}
|
}
|
||||||
return result;
|
result
|
||||||
}
|
}
|
||||||
+102
-114
@@ -2,155 +2,143 @@ use axum::{
|
|||||||
extract::Path,
|
extract::Path,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
Json,
|
body::Bytes,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use std::{sync::Arc, time::SystemTime};
|
use std::time::SystemTime;
|
||||||
use tokio::sync::Mutex;
|
|
||||||
|
|
||||||
use crate::note::{generate_id, Note, NoteInfo};
|
use crate::note::{CreateRequest, generate_id};
|
||||||
use crate::store;
|
use crate::store;
|
||||||
use crate::{config, lock::SharedState};
|
use crate::config;
|
||||||
|
|
||||||
use super::NotePublic;
|
use super::{CreateResponse, MetaResponse, NoteResponse, NoteMeta};
|
||||||
|
|
||||||
pub fn now() -> u32 {
|
pub fn now() -> u64 {
|
||||||
SystemTime::now()
|
SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.as_secs() as u32
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct OneNoteParams {
|
pub struct NoteParams {
|
||||||
id: String,
|
id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn preview(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response {
|
pub async fn create(body: Bytes) -> Response {
|
||||||
let note = store::get(&id);
|
let req: CreateRequest = match rmp_serde::from_slice(&body) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid msgpack").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
match note {
|
if req.meta.views.is_none() && req.meta.expiration.is_none() {
|
||||||
Ok(Some(n)) => (StatusCode::OK, Json(NoteInfo { meta: n.meta })).into_response(),
|
return (StatusCode::BAD_REQUEST, "At least views or expiration must be set").into_response();
|
||||||
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
if req.meta.extra.len() > *config::EXTRA_SIZE_LIMIT {
|
||||||
struct CreateResponse {
|
return (StatusCode::BAD_REQUEST, "Extra data too large").into_response();
|
||||||
id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create(Json(mut n): Json<Note>) -> Response {
|
|
||||||
// let mut 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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut meta = req.meta;
|
||||||
|
|
||||||
if !*config::ALLOW_ADVANCED {
|
if !*config::ALLOW_ADVANCED {
|
||||||
n.views = Some(1);
|
meta.views = Some(1);
|
||||||
n.expiration = None;
|
meta.expiration = None;
|
||||||
}
|
}
|
||||||
match n.views {
|
|
||||||
|
match meta.views {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
if v > *config::MAX_VIEWS || v < 1 {
|
if v > *config::MAX_VIEWS || v < 1 {
|
||||||
return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
|
return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
|
||||||
}
|
}
|
||||||
n.expiration = None; // views overrides expiration
|
|
||||||
}
|
}
|
||||||
_ => {}
|
None => {}
|
||||||
}
|
}
|
||||||
match n.expiration {
|
|
||||||
|
let expiration_ts = match meta.expiration {
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
if e > *config::MAX_EXPIRATION || e < 1 {
|
if e > *config::MAX_EXPIRATION || e < 1 {
|
||||||
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
|
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
|
||||||
}
|
}
|
||||||
let expiration = now() + (e * 60);
|
Some(now() + (e as u64 * 60))
|
||||||
n.expiration = Some(expiration);
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = generate_id();
|
||||||
|
let views = meta.views.map(|v| v as i64);
|
||||||
|
|
||||||
|
match store::set(&id, &req.data, views, expiration_ts, &meta.extra) {
|
||||||
|
Ok(_) => {
|
||||||
|
let resp = CreateResponse { id };
|
||||||
|
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
||||||
|
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
match store::set(&id.clone(), &n.clone()) {
|
|
||||||
Ok(_) => (StatusCode::OK, Json(CreateResponse { id })).into_response(),
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(
|
pub async fn preview(Path(NoteParams { id }): Path<NoteParams>) -> Response {
|
||||||
Path(OneNoteParams { id }): Path<OneNoteParams>,
|
match store::get_meta(&id) {
|
||||||
state: axum::extract::State<SharedState>,
|
Ok(Some((views, expiration, extra))) => {
|
||||||
) -> Response {
|
let meta = NoteMeta {
|
||||||
let mut locks_map = state.locks.lock().await;
|
views: views.map(|v| v as u32),
|
||||||
let lock = locks_map
|
expiration: expiration.map(|e| e as u32),
|
||||||
.entry(id.clone())
|
extra,
|
||||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
};
|
||||||
.clone();
|
let resp = MetaResponse { meta };
|
||||||
drop(locks_map);
|
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
||||||
let _guard = lock.lock().await;
|
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
||||||
|
}
|
||||||
let note = store::get(&id);
|
|
||||||
match note {
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
|
||||||
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
|
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
|
||||||
Ok(Some(note)) => {
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
let mut changed = note.clone();
|
|
||||||
if changed.views == None && changed.expiration == None {
|
|
||||||
return (StatusCode::BAD_REQUEST).into_response();
|
|
||||||
}
|
|
||||||
match changed.views {
|
|
||||||
Some(v) => {
|
|
||||||
changed.views = Some(v - 1);
|
|
||||||
let id = id.clone();
|
|
||||||
if v <= 1 {
|
|
||||||
match store::del(&id) {
|
|
||||||
Err(e) => {
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
match store::set(&id, &changed.clone()) {
|
|
||||||
Err(e) => {
|
|
||||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
let n = now();
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn view(Path(NoteParams { id }): Path<NoteParams>) -> Response {
|
||||||
|
let (views, expiration, extra) = match store::get_meta(&id) {
|
||||||
|
Ok(Some(v)) => v,
|
||||||
|
_ => return (StatusCode::NOT_FOUND).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_views = views.is_some();
|
||||||
|
|
||||||
|
if has_views {
|
||||||
|
let remaining = match store::decrement_views(&id) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => return (StatusCode::NOT_FOUND).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = match store::get_data(&id) {
|
||||||
|
Ok(Some(d)) => d,
|
||||||
|
_ => return (StatusCode::NOT_FOUND).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if remaining <= 0 {
|
||||||
|
let _ = store::del(&id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta = NoteMeta {
|
||||||
|
views: Some(if remaining > 0 { remaining as u32 } else { 0 }),
|
||||||
|
expiration: expiration.map(|e| e as u32),
|
||||||
|
extra,
|
||||||
|
};
|
||||||
|
let resp = NoteResponse { meta, data };
|
||||||
|
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
||||||
|
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
||||||
|
} else {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub struct Status {
|
|||||||
pub theme_page_title: String,
|
pub theme_page_title: String,
|
||||||
pub theme_favicon: String,
|
pub theme_favicon: String,
|
||||||
pub theme_new_note_notice: bool,
|
pub theme_new_note_notice: bool,
|
||||||
|
pub theme_home_link: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_status() -> (StatusCode, Json<Status>) {
|
pub async fn get_status() -> (StatusCode, Json<Status>) {
|
||||||
@@ -33,6 +34,7 @@ pub async fn get_status() -> (StatusCode, Json<Status>) {
|
|||||||
imprint_url: config::IMPRINT_URL.to_string(),
|
imprint_url: config::IMPRINT_URL.to_string(),
|
||||||
imprint_html: config::IMPRINT_HTML.to_string(),
|
imprint_html: config::IMPRINT_HTML.to_string(),
|
||||||
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
|
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
|
||||||
|
theme_home_link: *config::THEME_HOME_LINK,
|
||||||
theme_image: config::THEME_IMAGE.to_string(),
|
theme_image: config::THEME_IMAGE.to_string(),
|
||||||
theme_text: config::THEME_TEXT.to_string(),
|
theme_text: config::THEME_TEXT.to_string(),
|
||||||
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
|
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
|
||||||
|
|||||||
@@ -1,63 +1,83 @@
|
|||||||
use redis;
|
|
||||||
use redis::Commands;
|
use redis::Commands;
|
||||||
|
|
||||||
use crate::note::now;
|
use crate::config;
|
||||||
use crate::note::Note;
|
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref REDIS_CLIENT: String = std::env::var("REDIS")
|
static ref CACHE_URL: String = std::env::var("CACHE")
|
||||||
.unwrap_or("redis://127.0.0.1/".to_string())
|
.unwrap_or("redis://127.0.0.1/".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_connection() -> Result<redis::Connection, &'static str> {
|
fn prefixed(id: &str) -> String {
|
||||||
|
format!("{}{}", config::CACHE_PREFIX.as_str(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn conn() -> Result<redis::Connection, &'static str> {
|
||||||
let client =
|
let client =
|
||||||
redis::Client::open(REDIS_CLIENT.to_string()).map_err(|_| "Unable to connect to redis")?;
|
redis::Client::open(CACHE_URL.to_string()).map_err(|_| "Unable to connect to cache")?;
|
||||||
client
|
client.get_connection().map_err(|_| "Unable to connect to cache")
|
||||||
.get_connection()
|
|
||||||
.map_err(|_| "Unable to connect to redis")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_reach_redis() -> bool {
|
pub fn can_reach_cache() -> bool {
|
||||||
let conn = get_connection();
|
conn().is_ok()
|
||||||
return match conn {
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(_) => false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
|
pub fn set(id: &str, data: &[u8], views: Option<i64>, expiration: Option<u64>, extra: &[u8]) -> Result<(), &'static str> {
|
||||||
let serialized = serde_json::to_string(¬e.clone()).unwrap();
|
let key = prefixed(id);
|
||||||
let mut conn = get_connection()?;
|
let mut c = conn()?;
|
||||||
|
|
||||||
conn.set(id, serialized)
|
c.hset::<_, _, _, ()>(&key, "data", data).map_err(|_| "Unable to set note")?;
|
||||||
.map_err(|_| "Unable to set note in redis")?;
|
c.hset::<_, _, _, ()>(&key, "extra", extra).map_err(|_| "Unable to set note")?;
|
||||||
match note.expiration {
|
|
||||||
Some(e) => {
|
|
||||||
let seconds = e - now();
|
|
||||||
conn.expire(id, seconds as i64)
|
|
||||||
.map_err(|_| "Unable to set expiration on notion")?
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
};
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
|
if let Some(v) = views {
|
||||||
let mut conn = get_connection()?;
|
c.hset::<_, _, _, ()>(&key, "views", v).map_err(|_| "Unable to set note")?;
|
||||||
let value: Option<String> = conn.get(id).map_err(|_| "Could not load note in redis")?;
|
}
|
||||||
match value {
|
if let Some(e) = expiration {
|
||||||
None => return Ok(None),
|
let now = std::time::SystemTime::now()
|
||||||
Some(s) => {
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
let deserialize: Note = serde_json::from_str(&s).unwrap();
|
.unwrap()
|
||||||
return Ok(Some(deserialize));
|
.as_secs();
|
||||||
}
|
let ttl = e.saturating_sub(now);
|
||||||
|
c.expire::<_, ()>(&key, ttl as i64).map_err(|_| "Unable to set expiration")?;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
pub fn del(id: &String) -> Result<(), &'static str> {
|
|
||||||
let mut conn = get_connection()?;
|
|
||||||
conn.del(id).map_err(|_| "Unable to delete note in redis")?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_meta(id: &str) -> Result<Option<(Option<i64>, Option<u64>, Vec<u8>)>, &'static str> {
|
||||||
|
let key = prefixed(id);
|
||||||
|
let mut c = conn()?;
|
||||||
|
|
||||||
|
let exists: bool = c.exists::<_, bool>(&key).map_err(|_| "Cache error")?;
|
||||||
|
if !exists {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let views: Option<i64> = c.hget::<_, _, Option<i64>>(&key, "views").map_err(|_| "Cache error")?;
|
||||||
|
let expiration: Option<u64> = c.hget::<_, _, Option<u64>>(&key, "expiration").map_err(|_| "Cache error")?;
|
||||||
|
let extra: Vec<u8> = c.hget::<_, _, Vec<u8>>(&key, "extra").unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(Some((views, expiration, extra)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_data(id: &str) -> Result<Option<Vec<u8>>, &'static str> {
|
||||||
|
let key = prefixed(id);
|
||||||
|
let mut c = conn()?;
|
||||||
|
let data: Option<Vec<u8>> = c.hget::<_, _, Option<Vec<u8>>>(&key, "data").map_err(|_| "Cache error")?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrement_views(id: &str) -> Result<i64, &'static str> {
|
||||||
|
let key = prefixed(id);
|
||||||
|
let mut c = conn()?;
|
||||||
|
let result: i64 = c.hincr::<_, _, _, i64>(&key, "views", -1).map_err(|_| "Cache error")?;
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn del(id: &str) -> Result<(), &'static str> {
|
||||||
|
let key = prefixed(id);
|
||||||
|
let mut c = conn()?;
|
||||||
|
c.del::<_, ()>(&key).map_err(|_| "Unable to delete note")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { build } from 'tsup'
|
|
||||||
import pkg from './package.json' with { type: 'json' }
|
|
||||||
|
|
||||||
const watch = process.argv.slice(2)[0] === '--watch'
|
|
||||||
|
|
||||||
await build({
|
|
||||||
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
|
|
||||||
dts: true,
|
|
||||||
minify: true,
|
|
||||||
format: ['esm', 'cjs'],
|
|
||||||
target: 'es2020',
|
|
||||||
clean: true,
|
|
||||||
define: { VERSION: `"${pkg.version}"` },
|
|
||||||
watch,
|
|
||||||
})
|
|
||||||
+21
-25
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cryptgeon",
|
"name": "cryptgeon",
|
||||||
"version": "2.9.0",
|
"version": "3.0.0",
|
||||||
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -9,39 +9,35 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.mjs"
|
||||||
"./shared": {
|
|
||||||
"import": "./dist/shared/shared.js",
|
|
||||||
"types": "./dist/shared/shared.d.ts"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.mts",
|
||||||
"bin": {
|
"bin": {
|
||||||
"cryptgeon": "./dist/cli.cjs"
|
"cryptgeon": "./dist/cli.mjs"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bin": "run-s build package",
|
"build": "vp pack",
|
||||||
"build": "tsc && node build.js",
|
"dev": "vp pack --watch",
|
||||||
"dev": "node build.js --watch",
|
"prepublishOnly": "pnpm run build"
|
||||||
"prepublishOnly": "run-s build"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commander-js/extra-typings": "^12.1.0",
|
"@commander-js/extra-typings": "^15.0.0",
|
||||||
"@types/inquirer": "^9.0.7",
|
"@cryptgeon/shared": "workspace:*",
|
||||||
"@types/mime": "^4.0.0",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"@types/node": "^20.11.24",
|
"@tsconfig/strictest": "catalog:",
|
||||||
"commander": "^12.1.0",
|
"@types/inquirer": "^9.0.10",
|
||||||
"inquirer": "^9.2.15",
|
"@types/node": "^22.20.1",
|
||||||
"mime": "^4.0.1",
|
"commander": "^15.0.0",
|
||||||
"occulto": "^2.0.6",
|
"inquirer": "^14.2.1",
|
||||||
"pretty-bytes": "^6.1.1",
|
"mime": "^4.1.0",
|
||||||
"tsup": "^8.2.4",
|
"pretty-bytes": "^7.1.3",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "catalog:",
|
||||||
|
"vite-plus": "catalog:"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,51 +1,42 @@
|
|||||||
import inquirer from 'inquirer'
|
import inquirer from 'inquirer'
|
||||||
import { access, constants, writeFile } from 'node:fs/promises'
|
import { access, constants, writeFile } from 'node:fs/promises'
|
||||||
import { basename, resolve } from 'node:path'
|
import { basename, resolve } from 'node:path'
|
||||||
import { AES, Hex } from 'occulto'
|
import { decode } from '@msgpack/msgpack'
|
||||||
import pretty from 'pretty-bytes'
|
import pretty from 'pretty-bytes'
|
||||||
import { Adapters } from '../shared/adapters.js'
|
import { deriveKey, setServer, info, get, unpackContent } from '@cryptgeon/shared'
|
||||||
import { API } from '../shared/api.js'
|
|
||||||
|
|
||||||
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
|
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
|
||||||
API.setOptions({ server: url.origin })
|
setServer(url.origin)
|
||||||
const id = url.pathname.split('/')[2]
|
const id = url.pathname.split('/')[2]
|
||||||
const preview = await API.info(id).catch(() => {
|
if (!id) throw new Error('Invalid URL')
|
||||||
throw new Error('Note does not exist or is expired')
|
const meta = await info(id)
|
||||||
})
|
if (!meta) throw new Error('Note does not exist or is expired')
|
||||||
|
|
||||||
// Password
|
let key: Uint8Array
|
||||||
let password: string
|
if (meta.extra && meta.extra.length > 0) {
|
||||||
const derivation = preview?.meta.derivation
|
|
||||||
if (derivation) {
|
|
||||||
if (suggestedPassword) {
|
if (suggestedPassword) {
|
||||||
password = suggestedPassword
|
const derivation = decode(meta.extra) as any
|
||||||
|
key = deriveKey(suggestedPassword, new Uint8Array(derivation.salt))
|
||||||
} else {
|
} else {
|
||||||
const response = await inquirer.prompt([
|
const response = await inquirer.prompt([
|
||||||
{
|
{ type: 'password', message: 'Note password', name: 'password' },
|
||||||
type: 'password',
|
|
||||||
message: 'Note password',
|
|
||||||
name: 'password',
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
password = response.password
|
const derivation = decode(meta.extra) as any
|
||||||
|
key = deriveKey(response.password, new Uint8Array(derivation.salt))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
password = url.hash.slice(1)
|
const hex = url.hash.slice(1)
|
||||||
|
key = new Uint8Array(Buffer.from(hex, 'hex'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
|
const note = await get(id)
|
||||||
const note = await API.get(id)
|
if (!note) throw new Error('Could not load note')
|
||||||
|
|
||||||
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
|
const content = unpackContent(note.data, key)
|
||||||
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
|
||||||
@@ -55,36 +46,32 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
|
|||||||
type: 'checkbox',
|
type: 'checkbox',
|
||||||
message: 'What files should be saved?',
|
message: 'What files should be saved?',
|
||||||
name: 'names',
|
name: 'names',
|
||||||
choices: files.map((file) => ({
|
choices: files.map((f) => ({
|
||||||
value: file.name,
|
value: f.name,
|
||||||
name: `${file.name} - ${file.type} - ${pretty(file.size, { binary: true })}`,
|
name: `${f.name} - ${pretty(f.data.length, { binary: true })}`,
|
||||||
checked: true,
|
checked: true,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
selected = files.filter((file) => names.includes(file.name))
|
selected = files.filter((f) => names.includes(f.name))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selected.length) throw new Error('No files selected')
|
if (!selected.length) throw new Error('No files selected')
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
selected.map(async (file) => {
|
selected.map(async (f) => {
|
||||||
let filename = resolve(file.name)
|
let filename = resolve(f.name)
|
||||||
try {
|
try {
|
||||||
// If exists -> prepend timestamp to not overwrite the current file
|
|
||||||
await access(filename, constants.R_OK)
|
await access(filename, constants.R_OK)
|
||||||
filename = resolve(`${Date.now()}-${file.name}`)
|
filename = resolve(`${Date.now()}-${f.name}`)
|
||||||
} catch {}
|
} catch {}
|
||||||
await writeFile(filename, file.contents)
|
await writeFile(filename, f.data)
|
||||||
console.log(`Saved: ${basename(filename)}`)
|
console.log(`Saved: ${basename(filename)}`)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
break
|
break
|
||||||
case 'text':
|
case 'text':
|
||||||
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(() => {
|
console.log(content.data)
|
||||||
throw couldNotDecrypt
|
|
||||||
})
|
|
||||||
console.log(plaintext)
|
|
||||||
break
|
break
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown content type')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,46 +1,38 @@
|
|||||||
import { readFile, stat } from 'node:fs/promises'
|
import { readFile } from 'node:fs/promises'
|
||||||
import { basename } from 'node:path'
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
import mime from 'mime'
|
import mime from 'mime'
|
||||||
import { AES, Hex } from 'occulto'
|
import { getServer, create, packContent, type FileDTO } from '@cryptgeon/shared'
|
||||||
import { Adapters } from '../shared/adapters.js'
|
|
||||||
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
|
|
||||||
|
|
||||||
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
|
export type UploadOptions = { views?: number; expiration?: number; password?: string }
|
||||||
|
|
||||||
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
|
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
|
||||||
const { password, ...noteOptions } = options
|
const { password, ...noteOptions } = options
|
||||||
const derived = options.password ? await AES.derive(options.password) : undefined
|
|
||||||
const key = derived ? derived[0] : await AES.generateKey()
|
|
||||||
|
|
||||||
let contents: string
|
const payload = packContent(
|
||||||
let type: NoteMeta['type']
|
typeof input === 'string'
|
||||||
if (typeof input === 'string') {
|
? { type: 'text', text: input }
|
||||||
contents = await Adapters.Text.encrypt(input, key)
|
: { type: 'files', files: await fileDTOSfromPaths(input) },
|
||||||
type = 'text'
|
password
|
||||||
} 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'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create the actual note and upload it.
|
const result = await create({ meta: { ...noteOptions, extra: payload.extra }, data: payload.data })
|
||||||
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
|
let url = `${getServer()}/note/${result.id}`
|
||||||
const result = await API.create(note)
|
if (!password) url += `#${Buffer.from(payload.key).toString('hex')}`
|
||||||
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
+22
-16
@@ -5,7 +5,7 @@ import prettyBytes from 'pretty-bytes'
|
|||||||
|
|
||||||
import { download } from './actions/download.js'
|
import { download } from './actions/download.js'
|
||||||
import { upload } from './actions/upload.js'
|
import { upload } from './actions/upload.js'
|
||||||
import { API } from './shared/api.js'
|
import { setServer, status } from '@cryptgeon/shared'
|
||||||
import { parseFile, parseNumber } from './utils/parsers.js'
|
import { parseFile, parseNumber } from './utils/parsers.js'
|
||||||
import { getStdin } from './utils/stdin.js'
|
import { getStdin } from './utils/stdin.js'
|
||||||
import { checkConstrains, exit } from './utils/utils.js'
|
import { checkConstrains, exit } from './utils/utils.js'
|
||||||
@@ -21,7 +21,8 @@ const views = new Option('-v --views <number>', 'Amount of views before getting
|
|||||||
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
|
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
|
||||||
|
|
||||||
// Node 18 guard
|
// Node 18 guard
|
||||||
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required')
|
const major = Number(process.version.slice(1).split('.')[0])
|
||||||
|
if (!Number.isFinite(major) || major < 18) exit('Node 18 or higher is required')
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const version: string = VERSION
|
const version: string = VERSION
|
||||||
@@ -33,15 +34,12 @@ program
|
|||||||
.description('show information about the server')
|
.description('show information about the server')
|
||||||
.addOption(server)
|
.addOption(server)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
API.setOptions({ server: options.server })
|
setServer(options.server!)
|
||||||
const response = await API.status()
|
const response = await status()
|
||||||
const formatted = {
|
const formatted = Object.fromEntries(
|
||||||
...response,
|
Object.entries({ ...response, max_size: prettyBytes(response.max_size) })
|
||||||
max_size: prettyBytes(response.max_size),
|
.filter(([key]) => !key.startsWith('theme_'))
|
||||||
}
|
)
|
||||||
for (const key of Object.keys(formatted)) {
|
|
||||||
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
|
|
||||||
}
|
|
||||||
console.table(formatted)
|
console.table(formatted)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -54,11 +52,15 @@ send
|
|||||||
.addOption(minutes)
|
.addOption(minutes)
|
||||||
.addOption(password)
|
.addOption(password)
|
||||||
.action(async (files, options) => {
|
.action(async (files, options) => {
|
||||||
API.setOptions({ server: options.server })
|
setServer(options.server!)
|
||||||
await checkConstrains(options)
|
await checkConstrains(options)
|
||||||
options.password ||= await getStdin()
|
options.password ||= await getStdin()
|
||||||
try {
|
try {
|
||||||
const url = await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
|
const url = await upload(files, {
|
||||||
|
...(options.views !== undefined ? { views: options.views } : {}),
|
||||||
|
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
|
||||||
|
password: options.password,
|
||||||
|
})
|
||||||
console.log(`Note created:\n\n${url}`)
|
console.log(`Note created:\n\n${url}`)
|
||||||
} catch {
|
} catch {
|
||||||
exit('Could not create note')
|
exit('Could not create note')
|
||||||
@@ -72,11 +74,15 @@ send
|
|||||||
.addOption(minutes)
|
.addOption(minutes)
|
||||||
.addOption(password)
|
.addOption(password)
|
||||||
.action(async (text, options) => {
|
.action(async (text, options) => {
|
||||||
API.setOptions({ server: options.server })
|
setServer(options.server!)
|
||||||
await checkConstrains(options)
|
await checkConstrains(options)
|
||||||
options.password ||= await getStdin()
|
options.password ||= await getStdin()
|
||||||
try {
|
try {
|
||||||
const url = await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
|
const url = await upload(text, {
|
||||||
|
...(options.views !== undefined ? { views: options.views } : {}),
|
||||||
|
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
|
||||||
|
password: options.password,
|
||||||
|
})
|
||||||
console.log(`Note created:\n\n${url}`)
|
console.log(`Note created:\n\n${url}`)
|
||||||
} catch {
|
} catch {
|
||||||
exit('Could not create note')
|
exit('Could not create note')
|
||||||
@@ -103,4 +109,4 @@ program
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
program.parse()
|
program.parse()
|
||||||
@@ -1,4 +1,2 @@
|
|||||||
export * from './actions/download.js'
|
export * from './actions/download.js'
|
||||||
export * from './actions/upload.js'
|
export * from './actions/upload.js'
|
||||||
export * from './shared/adapters.js'
|
|
||||||
export * from './shared/api.js'
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { AES, Bytes, type TypedArray } from 'occulto'
|
|
||||||
import type { EncryptedFileDTO, FileDTO } from './api'
|
|
||||||
|
|
||||||
abstract class CryptAdapter<T> {
|
|
||||||
abstract encrypt(plaintext: T, key: TypedArray): Promise<string>
|
|
||||||
abstract decrypt(ciphertext: string, key: TypedArray): Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
class CryptTextAdapter implements CryptAdapter<string> {
|
|
||||||
async encrypt(plaintext: string, key: TypedArray) {
|
|
||||||
return await AES.encrypt(Bytes.encode(plaintext), key)
|
|
||||||
}
|
|
||||||
async decrypt(ciphertext: string, key: TypedArray) {
|
|
||||||
return Bytes.decode(await AES.decrypt(ciphertext, key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CryptBlobAdapter implements CryptAdapter<TypedArray> {
|
|
||||||
async encrypt(plaintext: TypedArray, key: TypedArray) {
|
|
||||||
return await AES.encrypt(plaintext, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(ciphertext: string, key: TypedArray) {
|
|
||||||
return await AES.decrypt(ciphertext, key)
|
|
||||||
// const plaintext = await AES.decrypt(ciphertext, key)
|
|
||||||
// return new Blob([plaintext], { type: 'application/octet-stream' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
|
|
||||||
async encrypt(plaintext: FileDTO[], key: TypedArray) {
|
|
||||||
const adapter = new CryptBlobAdapter()
|
|
||||||
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
contents: await adapter.encrypt(file.contents, key),
|
|
||||||
}))
|
|
||||||
return JSON.stringify(await Promise.all(data))
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt(ciphertext: string, key: TypedArray) {
|
|
||||||
const adapter = new CryptBlobAdapter()
|
|
||||||
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
|
|
||||||
const files: FileDTO[] = await Promise.all(
|
|
||||||
data.map(async (file) => ({
|
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
type: file.type,
|
|
||||||
contents: await adapter.decrypt(file.contents, key),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Adapters = {
|
|
||||||
Text: new CryptTextAdapter(),
|
|
||||||
Blob: new CryptBlobAdapter(),
|
|
||||||
Files: new CryptFilesAdapter(),
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import type { KeyData, TypedArray } from 'occulto'
|
|
||||||
|
|
||||||
export type NoteMeta = {
|
|
||||||
type: 'text' | 'file'
|
|
||||||
derivation?: KeyData
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Note = {
|
|
||||||
contents: string
|
|
||||||
meta: NoteMeta
|
|
||||||
views?: number
|
|
||||||
expiration?: number
|
|
||||||
}
|
|
||||||
export type NoteInfo = Pick<Note, 'meta'>
|
|
||||||
export type NotePublic = Pick<Note, 'contents' | 'meta'>
|
|
||||||
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
|
|
||||||
|
|
||||||
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
|
|
||||||
contents: TypedArray
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
|
|
||||||
contents: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ClientOptions = {
|
|
||||||
server: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type CallOptions = {
|
|
||||||
url: string
|
|
||||||
method: string
|
|
||||||
body?: any
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PayloadToLargeError extends Error {}
|
|
||||||
|
|
||||||
export let client: ClientOptions = {
|
|
||||||
server: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOptions(options: Partial<ClientOptions>) {
|
|
||||||
client = { ...client, ...options }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOptions(): ClientOptions {
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
async function call(options: CallOptions) {
|
|
||||||
const url = client.server + '/api/' + options.url
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: options.method,
|
|
||||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
||||||
mode: 'cors',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 413) throw new PayloadToLargeError()
|
|
||||||
else throw new Error('API call failed')
|
|
||||||
}
|
|
||||||
return response.json()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function create(note: Note) {
|
|
||||||
const { meta, ...rest } = note
|
|
||||||
const body: NoteCreate = {
|
|
||||||
...rest,
|
|
||||||
meta: JSON.stringify(meta),
|
|
||||||
}
|
|
||||||
const data = await call({
|
|
||||||
url: 'notes/',
|
|
||||||
method: 'post',
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
return data as { id: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function get(id: string): Promise<NotePublic> {
|
|
||||||
const data = await call({
|
|
||||||
url: `notes/${id}`,
|
|
||||||
method: 'delete',
|
|
||||||
})
|
|
||||||
const { contents, meta } = data
|
|
||||||
const note = {
|
|
||||||
contents,
|
|
||||||
meta: JSON.parse(meta),
|
|
||||||
} satisfies NotePublic
|
|
||||||
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
|
|
||||||
return note
|
|
||||||
}
|
|
||||||
|
|
||||||
async function info(id: string): Promise<NoteInfo> {
|
|
||||||
const data = await call({
|
|
||||||
url: `notes/${id}`,
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
const { meta } = data
|
|
||||||
const note = {
|
|
||||||
meta: JSON.parse(meta),
|
|
||||||
} satisfies NoteInfo
|
|
||||||
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
|
|
||||||
return note
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Status = {
|
|
||||||
version: string
|
|
||||||
max_size: number
|
|
||||||
max_views: number
|
|
||||||
max_expiration: number
|
|
||||||
allow_advanced: boolean
|
|
||||||
allow_files: boolean
|
|
||||||
imprint_url: string
|
|
||||||
imprint_html: string
|
|
||||||
theme_image: string
|
|
||||||
theme_text: string
|
|
||||||
theme_favicon: string
|
|
||||||
theme_page_title: string
|
|
||||||
theme_new_note_notice: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
async function status() {
|
|
||||||
const data = await call({
|
|
||||||
url: 'status/',
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
return data as Status
|
|
||||||
}
|
|
||||||
|
|
||||||
export const API = {
|
|
||||||
setOptions,
|
|
||||||
getOptions,
|
|
||||||
create,
|
|
||||||
get,
|
|
||||||
info,
|
|
||||||
status,
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
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 { API } from '../shared/api.js'
|
import { status } from '@cryptgeon/shared'
|
||||||
|
|
||||||
export function exit(message: string) {
|
export function exit(message: string) {
|
||||||
console.error(message)
|
console.error(message)
|
||||||
@@ -7,13 +7,11 @@ export function exit(message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
|
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
|
||||||
const { views, minutes } = constrains
|
if (!constrains.views && !constrains.minutes) constrains.views = 1
|
||||||
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
|
|
||||||
if (!views && !minutes) constrains.views = 1
|
|
||||||
|
|
||||||
const response = await API.status()
|
const response = await status()
|
||||||
if (views && views > response.max_views)
|
if (constrains.views && constrains.views > response.max_views)
|
||||||
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
|
exit(`Only a maximum of ${response.max_views} views allowed. ${constrains.views} given.`)
|
||||||
if (minutes && minutes > response.max_expiration)
|
if (constrains.minutes && constrains.minutes > response.max_expiration)
|
||||||
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
|
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${constrains.minutes} given.`)
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
"target": "esnext",
|
||||||
"module": "es2022",
|
"module": "esnext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"strict": true,
|
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true
|
||||||
}
|
},
|
||||||
}
|
"exclude": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from "vite-plus";
|
||||||
|
import pkg from "./package.json" with { type: "json" };
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
pack: {
|
||||||
|
entry: ["src/index.ts", "src/cli.ts"],
|
||||||
|
dts: true,
|
||||||
|
minify: true,
|
||||||
|
format: ["esm"],
|
||||||
|
target: "es2023",
|
||||||
|
deps: { alwaysBundle: ["**"] },
|
||||||
|
define: { VERSION: JSON.stringify(pkg.version) },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
├─ MIT: 13
|
├─ MIT: 324
|
||||||
├─ ISC: 2
|
├─ ISC: 84
|
||||||
├─ BSD-3-Clause: 1
|
├─ Apache-2.0: 21
|
||||||
├─ (MPL-2.0 OR Apache-2.0): 1
|
├─ BlueOak-1.0.0: 14
|
||||||
├─ BSD-2-Clause: 1
|
├─ BSD-3-Clause: 6
|
||||||
|
├─ BSD-2-Clause: 4
|
||||||
|
├─ OFL-1.1: 1
|
||||||
|
├─ Python-2.0: 1
|
||||||
|
├─ CC-BY-4.0: 1
|
||||||
|
├─ UNKNOWN: 1
|
||||||
|
├─ MPL-2.0: 1
|
||||||
|
├─ CC-BY-3.0: 1
|
||||||
|
├─ CC0-1.0: 1
|
||||||
|
├─ (MIT AND CC-BY-3.0): 1
|
||||||
├─ 0BSD: 1
|
├─ 0BSD: 1
|
||||||
└─ Apache-2.0: 1
|
└─ (MIT OR CC0-1.0): 1
|
||||||
|
|
||||||
|
|||||||
|
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"note": "poznámka",
|
||||||
|
"file": "soubor",
|
||||||
|
"advanced": "pokročilé",
|
||||||
|
"create": "vytvořit",
|
||||||
|
"loading": "načítání",
|
||||||
|
"mode": "režim",
|
||||||
|
"views": "{n, plural, =0 {zobrazení} =1 {1 zobrazení} other {# zobrazení}}",
|
||||||
|
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minutách}}",
|
||||||
|
"max": "max",
|
||||||
|
"share_link": "sdílet odkaz",
|
||||||
|
"copy_clipboard": "zkopírovat do schránky",
|
||||||
|
"copied_to_clipboard": "zkopírováno do schránky",
|
||||||
|
"encrypting": "šifrování",
|
||||||
|
"decrypting": "dešifrování",
|
||||||
|
"uploading": "nahrávání",
|
||||||
|
"downloading": "stahování",
|
||||||
|
"qr_code": "QR kód",
|
||||||
|
"password": "heslo"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"intro": "Jednoduše posílejte <i>plně šifrované</i> a zabezpečené poznámky nebo soubory jediným kliknutím. Stačí vytvořit poznámku a sdílet odkaz.",
|
||||||
|
"explanation": "Poznámka vyprší a bude zničena po {n}.",
|
||||||
|
"new_note": "Nová poznámka",
|
||||||
|
"new_note_notice": "<b>Dostupnost:</b><br />Poznámka není zaručeně uchována, protože je uložena pouze v paměti RAM. Pokud se paměť zaplní, nejstarší poznámky budou automaticky smazány.<br />(Obvykle to nebývá problém, ale je dobré o tom vědět.)",
|
||||||
|
"errors": {
|
||||||
|
"note_too_big": "Poznámku nelze vytvořit. Je příliš velká.",
|
||||||
|
"note_error": "Poznámku nelze vytvořit. Zkuste to prosím znovu.",
|
||||||
|
"max": "Max: {n}",
|
||||||
|
"empty_content": "Poznámka je prázdná."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"note_created": "Poznámka byla vytvořena."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"explanation": "Ve výchozím nastavení se pro každou poznámku generuje bezpečné heslo. Pokud chcete, můžete si nastavit vlastní heslo, které nebude součástí odkazu.",
|
||||||
|
"custom_password": "Vlastní heslo"
|
||||||
|
},
|
||||||
|
"pasting": "Vkládání...",
|
||||||
|
"pasted_files": "Vložené soubory",
|
||||||
|
"remove": "Odstranit"
|
||||||
|
},
|
||||||
|
"show": {
|
||||||
|
"errors": {
|
||||||
|
"not_found": "Poznámka nebyla nalezena nebo již byla smazána.",
|
||||||
|
"decryption_failed": "Špatné heslo. Nelze dešifrovat. Odkaz je pravděpodobně poškozen. Poznámka byla zničena.",
|
||||||
|
"unsupported_type": "Nepodporovaný typ poznámky."
|
||||||
|
},
|
||||||
|
"explanation": "Klikněte níže pro zobrazení a následné smazání poznámky, pokud bylo dosaženo limitu zobrazení.",
|
||||||
|
"show_note": "Zobrazit poznámku",
|
||||||
|
"warning_will_not_see_again": "Tuto poznámku již <b>nebudete</b> moci znovu zobrazit.",
|
||||||
|
"download_all": "Stáhnout vše",
|
||||||
|
"links_found": "Odkazy nalezené v poznámce:"
|
||||||
|
},
|
||||||
|
"file_upload": {
|
||||||
|
"selected_files": "Vybrané soubory",
|
||||||
|
"no_files_selected": "Žádné soubory nevybrány",
|
||||||
|
"clear": "Vymazat"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "Neue Notiz",
|
"new_note": "Neue Notiz",
|
||||||
"new_note_notice": "<b>Wichtiger Hinweis zur Verfügbarkeit:</b><br />Es kann nicht garantiert werden, dass diese Notiz gespeichert wird, da diese <b>ausschließlich im Speicher</b> gehalten werden. Ist dieser voll, werden die ältesten Notizen entfernt.<br />(Wahrscheinlich gibt es keine derartigen Probleme, seien Sie nur vorgewarnt).",
|
"new_note_notice": "<b>Wichtiger Hinweis zur Verfügbarkeit:</b><br />Es kann nicht garantiert werden, dass diese Notiz gespeichert wird, da diese <b>ausschließlich im Speicher</b> gehalten werden. Ist dieser voll, werden die ältesten Notizen entfernt.<br />(Wahrscheinlich gibt es keine derartigen Probleme, seien Sie nur vorgewarnt).",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
|
"note_too_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
|
||||||
"note_error": "Notiz konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
"note_error": "Notiz konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "Notiz ist leer."
|
"empty_content": "Notiz ist leer."
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.",
|
"explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.",
|
||||||
"custom_password": "Benutzerdefiniertes Passwort"
|
"custom_password": "Benutzerdefiniertes Passwort"
|
||||||
}
|
},
|
||||||
|
"pasting": "Einfügen...",
|
||||||
|
"pasted_files": "Eingefügte Dateien",
|
||||||
|
"remove": "Entfernen"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "new note",
|
"new_note": "new note",
|
||||||
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
|
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "could not create note. note is too big",
|
"note_too_big": "could not create note. note is too big",
|
||||||
"note_error": "could not create note. please try again.",
|
"note_error": "could not create note. please try again.",
|
||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "note is empty."
|
"empty_content": "note is empty."
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.",
|
"explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.",
|
||||||
"custom_password": "custom password"
|
"custom_password": "custom password"
|
||||||
}
|
},
|
||||||
|
"pasting": "Pasting...",
|
||||||
|
"pasted_files": "Pasted Files",
|
||||||
|
"remove": "Remove"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "nueva nota",
|
"new_note": "nueva nota",
|
||||||
"new_note_notice": "<b>disponibilidad:</b><br />no se garantiza que la nota se almacene, ya que todo se guarda en la memoria RAM, si se llena se eliminarán las notas más antiguas.<br />(probablemente estará bien, solo está advertido.)",
|
"new_note_notice": "<b>disponibilidad:</b><br />no se garantiza que la nota se almacene, ya que todo se guarda en la memoria RAM, si se llena se eliminarán las notas más antiguas.<br />(probablemente estará bien, solo está advertido.)",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "no se pudo crear la nota. la nota es demasiado grande",
|
"note_too_big": "no se pudo crear la nota. la nota es demasiado grande",
|
||||||
"note_error": "No se ha podido crear la nota. Por favor, inténtelo de nuevo.",
|
"note_error": "No se ha podido crear la nota. Por favor, inténtelo de nuevo.",
|
||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "la nota está vacía."
|
"empty_content": "la nota está vacía."
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.",
|
"explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.",
|
||||||
"custom_password": "contraseña personalizada"
|
"custom_password": "contraseña personalizada"
|
||||||
}
|
},
|
||||||
|
"pasting": "Pegando...",
|
||||||
|
"pasted_files": "Archivos pegados",
|
||||||
|
"remove": "Eliminar"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "nouvelle note",
|
"new_note": "nouvelle note",
|
||||||
"new_note_notice": "<b>disponibilité :</b><br />il n'est pas garanti que la note reste stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)",
|
"new_note_notice": "<b>disponibilité :</b><br />il n'est pas garanti que la note reste stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "Impossible de créer une note. La note est trop grande.",
|
"note_too_big": "Impossible de créer une note. La note est trop grande.",
|
||||||
"note_error": "n'a pas pu créer de note. Veuillez réessayer.",
|
"note_error": "n'a pas pu créer de note. Veuillez réessayer.",
|
||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "La note est vide."
|
"empty_content": "La note est vide."
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.",
|
"explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.",
|
||||||
"custom_password": "mot de passe personnalisé"
|
"custom_password": "mot de passe personnalisé"
|
||||||
}
|
},
|
||||||
|
"pasting": "Collage...",
|
||||||
|
"pasted_files": "Fichiers collés",
|
||||||
|
"remove": "Supprimer"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"advanced": "avanzato",
|
"advanced": "avanzato",
|
||||||
"create": "crea",
|
"create": "crea",
|
||||||
"loading": "carica",
|
"loading": "carica",
|
||||||
"mode": "modalita",
|
"mode": "modalità",
|
||||||
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
|
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
|
||||||
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
|
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
|
||||||
"max": "max",
|
"max": "max",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "nuova nota",
|
"new_note": "nuova nota",
|
||||||
"new_note_notice": "<b>disponibilità:</b><br />la nota non è garantita per essere memorizzata come tutto è tenuto in ram, se si riempie le note più vecchie saranno rimosse.<br />(probabilmente andrà bene, basta essere avvertiti).",
|
"new_note_notice": "<b>disponibilità:</b><br />la nota non è garantita per essere memorizzata come tutto è tenuto in ram, se si riempie le note più vecchie saranno rimosse.<br />(probabilmente andrà bene, basta essere avvertiti).",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "impossibile creare una nota. la nota è troppo grande",
|
"note_too_big": "impossibile creare una nota. la nota è troppo grande",
|
||||||
"note_error": "Impossibile creare la nota. Riprova.",
|
"note_error": "Impossibile creare la nota. Riprova.",
|
||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "la nota è vuota."
|
"empty_content": "la nota è vuota."
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.",
|
"explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.",
|
||||||
"custom_password": "password personalizzata"
|
"custom_password": "password personalizzata"
|
||||||
}
|
},
|
||||||
|
"pasting": "Incollando...",
|
||||||
|
"pasted_files": "File incollati",
|
||||||
|
"remove": "Rimuovi"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"uploading": "アップロード中",
|
"uploading": "アップロード中",
|
||||||
"downloading": "ダウンロード中",
|
"downloading": "ダウンロード中",
|
||||||
"qr_code": "QRコード",
|
"qr_code": "QRコード",
|
||||||
"password": "暗号"
|
"password": "パスワード"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
|
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "新しいメモ",
|
"new_note": "新しいメモ",
|
||||||
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
|
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "メモを作成できませんでした。メモが大きすぎる",
|
"note_too_big": "メモを作成できませんでした。メモが大きすぎる",
|
||||||
"note_error": "メモを作成できませんでした。もう一度お試しください。",
|
"note_error": "メモを作成できませんでした。もう一度お試しください。",
|
||||||
"max": "最大ファイルサイズ: {n}",
|
"max": "最大ファイルサイズ: {n}",
|
||||||
"empty_content": "メモは空です。"
|
"empty_content": "メモは空です。"
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
|
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
|
||||||
"custom_password": "カスタムパスワード"
|
"custom_password": "カスタムパスワード"
|
||||||
}
|
},
|
||||||
|
"pasting": "貼り付け中...",
|
||||||
|
"pasted_files": "貼り付けたファイル",
|
||||||
|
"remove": "削除"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
},
|
},
|
||||||
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
|
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
|
||||||
"show_note": "メモを表示",
|
"show_note": "メモを表示",
|
||||||
"warning_will_not_see_again": "あなた <b>できません</b> このノートをもう一度見る",
|
"warning_will_not_see_again": "このノートを再度表示することは<b>できません</b>",
|
||||||
"download_all": "すべてダウンロード",
|
"download_all": "すべてダウンロード",
|
||||||
"links_found": "メモ内にあるリンク:"
|
"links_found": "メモ内にあるリンク:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
"create": "utwórz",
|
"create": "utwórz",
|
||||||
"loading": "ładowanie",
|
"loading": "ładowanie",
|
||||||
"mode": "tryb",
|
"mode": "tryb",
|
||||||
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {# wyświetleń}}",
|
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {{n} wyświetleń}}",
|
||||||
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minuty}}",
|
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {{n} minuty}}",
|
||||||
"max": "maks.",
|
"max": "maks.",
|
||||||
"share_link": "link udostępniania",
|
"share_link": "link udostępniania",
|
||||||
"copy_clipboard": "kopiuj do schowka",
|
"copy_clipboard": "kopiuj do schowka",
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
"new_note": "nowa notatka",
|
"new_note": "nowa notatka",
|
||||||
"new_note_notice": "<b>dostępność:</b><br />nie ma gwarancji, że notatka będzie przechowywana, ponieważ wszystko jest przechowywane w pamięci RAM, jeśli się zapełni, najstarsze notatki zostaną usunięte.<br />(prawdopodobnie nic się nie stanie, ale warto ostrzec.)",
|
"new_note_notice": "<b>dostępność:</b><br />nie ma gwarancji, że notatka będzie przechowywana, ponieważ wszystko jest przechowywane w pamięci RAM, jeśli się zapełni, najstarsze notatki zostaną usunięte.<br />(prawdopodobnie nic się nie stanie, ale warto ostrzec.)",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "nie można utworzyć notatki. notatka jest za duża",
|
"note_too_big": "nie można utworzyć notatki. notatka jest za duża",
|
||||||
"note_error": "nie można utworzyć notatki. spróbuj ponownie.",
|
"note_error": "nie można utworzyć notatki. spróbuj ponownie.",
|
||||||
"max": "maks .: {n}",
|
"max": "maks.: {n}",
|
||||||
"empty_content": "notatka jest pusta."
|
"empty_content": "notatka jest pusta."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.",
|
"explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.",
|
||||||
"custom_password": "własne hasło"
|
"custom_password": "własne hasło"
|
||||||
}
|
},
|
||||||
|
"pasting": "Wklejanie...",
|
||||||
|
"pasted_files": "Wklejone pliki",
|
||||||
|
"remove": "Usuń"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -1,58 +1,61 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"note": "заметка",
|
"note": "заметка",
|
||||||
"file": "файл",
|
"file": "файл",
|
||||||
"advanced": "расширенные",
|
"advanced": "расширенные",
|
||||||
"create": "создать",
|
"create": "создать",
|
||||||
"loading": "загрузка",
|
"loading": "загрузка",
|
||||||
"mode": "режим",
|
"mode": "режим",
|
||||||
"views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}",
|
"views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}",
|
||||||
"minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}",
|
"minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}",
|
||||||
"max": "макс",
|
"max": "макс",
|
||||||
"share_link": "поделиться ссылкой",
|
"share_link": "поделиться ссылкой",
|
||||||
"copy_clipboard": "скопировать в буфер обмена",
|
"copy_clipboard": "скопировать в буфер обмена",
|
||||||
"copied_to_clipboard": "скопировано в буфер обмена",
|
"copied_to_clipboard": "скопировано в буфер обмена",
|
||||||
"encrypting": "шифрование",
|
"encrypting": "шифрование",
|
||||||
"decrypting": "расшифровка",
|
"decrypting": "расшифровка",
|
||||||
"uploading": "загрузка",
|
"uploading": "загрузка",
|
||||||
"downloading": "скачивание",
|
"downloading": "скачивание",
|
||||||
"qr_code": "qr код",
|
"qr_code": "qr код",
|
||||||
"password": "пароль"
|
"password": "пароль"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
|
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
|
||||||
"explanation": "заметка истечет и будет уничтожена после {type}.",
|
"explanation": "заметка истечет и будет уничтожена после {type}.",
|
||||||
"new_note": "новая заметка",
|
"new_note": "новая заметка",
|
||||||
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)",
|
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "нельзя создать новую заметку. заметка слишком большая",
|
"note_too_big": "нельзя создать новую заметку. заметка слишком большая",
|
||||||
"note_error": "нельзя создать новую заметку. пожалйста попробуйте позднее.",
|
"note_error": "нельзя создать новую заметку. пожалуйста попробуйте позже.",
|
||||||
"max": "макс: {n}",
|
"max": "макс: {n}",
|
||||||
"empty_content": "пустая заметка."
|
"empty_content": "пустая заметка."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"note_created": "заметка создана."
|
"note_created": "заметка создана."
|
||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
|
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
|
||||||
"custom_password": "пользовательский пароль"
|
"custom_password": "пользовательский пароль"
|
||||||
}
|
},
|
||||||
},
|
"pasting": "Вставка...",
|
||||||
"show": {
|
"pasted_files": "Вставленные файлы",
|
||||||
"errors": {
|
"remove": "Удалить"
|
||||||
"not_found": "заметка не найдена или была удалена.",
|
},
|
||||||
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.",
|
"show": {
|
||||||
"unsupported_type": "неподдерживаемый тип заметки."
|
"errors": {
|
||||||
},
|
"not_found": "заметка не найдена или была удалена.",
|
||||||
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела",
|
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. заметка уничтожена.",
|
||||||
"show_note": "показать заметку",
|
"unsupported_type": "неподдерживаемый тип заметки."
|
||||||
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
|
},
|
||||||
"download_all": "скачать всё",
|
"explanation": "щелкните ниже, чтобы показать и удалить заметку, если счетчик достиг предела",
|
||||||
"links_found": "ссылки внутри заметки:"
|
"show_note": "показать заметку",
|
||||||
},
|
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
|
||||||
"file_upload": {
|
"download_all": "скачать всё",
|
||||||
"selected_files": "Выбранные файлы",
|
"links_found": "ссылки внутри заметки:"
|
||||||
"no_files_selected": "Файлы не выбраны",
|
},
|
||||||
"clear": "Сброс"
|
"file_upload": {
|
||||||
}
|
"selected_files": "Выбранные файлы",
|
||||||
}
|
"no_files_selected": "Файлы не выбраны",
|
||||||
|
"clear": "Сброс"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "新筆記",
|
"new_note": "新筆記",
|
||||||
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
|
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "無法創建筆記。筆記過大",
|
"note_too_big": "無法創建筆記。筆記過大",
|
||||||
"note_error": "無法創建筆記。請再試一次。",
|
"note_error": "無法創建筆記。請再試一次。",
|
||||||
"max": "最大值:{n}",
|
"max": "最大值:{n}",
|
||||||
"empty_content": "筆記內容為空。"
|
"empty_content": "筆記內容為空。"
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
|
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
|
||||||
"custom_password": "自定義密碼"
|
"custom_password": "自定義密碼"
|
||||||
}
|
},
|
||||||
|
"pasting": "正在粘貼...",
|
||||||
|
"pasted_files": "粘貼的檔案",
|
||||||
|
"remove": "移除"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"new_note": "新建密信",
|
"new_note": "新建密信",
|
||||||
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
|
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
|
||||||
"errors": {
|
"errors": {
|
||||||
"note_to_big": "创建失败,密信过大。",
|
"note_too_big": "创建失败,密信过大。",
|
||||||
"note_error": "创建失败,请稍后重试。",
|
"note_error": "创建失败,请稍后重试。",
|
||||||
"max": "次数上限:{n}",
|
"max": "次数上限:{n}",
|
||||||
"empty_content": "密信不能为空。"
|
"empty_content": "密信不能为空。"
|
||||||
@@ -36,7 +36,10 @@
|
|||||||
"advanced": {
|
"advanced": {
|
||||||
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
|
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
|
||||||
"custom_password": "自定义密码"
|
"custom_password": "自定义密码"
|
||||||
}
|
},
|
||||||
|
"pasting": "正在粘贴...",
|
||||||
|
"pasted_files": "粘贴的文件",
|
||||||
|
"remove": "移除"
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -7,31 +7,28 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig tsconfig.json",
|
"check": "svelte-check --tsconfig tsconfig.json",
|
||||||
"licenses": "license-checker --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.0.0",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/kit": "^2.70.3",
|
||||||
"@sveltejs/kit": "^2.16.0",
|
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
|
||||||
"@zerodevx/svelte-toast": "^0.9.6",
|
"@zerodevx/svelte-toast": "^0.9.6",
|
||||||
"adm-zip": "^0.5.16",
|
"license-checker-rseidelsohn": "^5.0.1",
|
||||||
"dotenv": "^16.4.7",
|
"svelte": "^5.57.0",
|
||||||
"svelte": "^5.19.0",
|
"svelte-check": "^4.7.6",
|
||||||
"svelte-check": "^4.1.4",
|
|
||||||
"svelte-intl-precompile": "^0.12.3",
|
"svelte-intl-precompile": "^0.12.3",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^6.0.7"
|
"vite": "^8.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/fira-mono": "^5.1.1",
|
"@cryptgeon/shared": "workspace:*",
|
||||||
"cryptgeon": "workspace:*",
|
"@fontsource/fira-mono": "^5.3.0",
|
||||||
"occulto": "^2.0.6",
|
"comlink": "^4.4.2",
|
||||||
"pretty-bytes": "^6.1.1",
|
"pretty-bytes": "^7.1.3",
|
||||||
"qrious": "^4.0.2"
|
"uqr": "^0.1.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import { LokaliseApi } from '@lokalise/node-api'
|
|
||||||
import AdmZip from 'adm-zip'
|
|
||||||
import dotenv from 'dotenv'
|
|
||||||
import https from 'https'
|
|
||||||
|
|
||||||
dotenv.config()
|
|
||||||
|
|
||||||
function exit(msg) {
|
|
||||||
console.error(msg)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.LOKALISE_API_KEY
|
|
||||||
const project_id = process.env.LOKALISE_PROJECT
|
|
||||||
if (!apiKey) exit('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
|
|
||||||
if (!project_id) exit('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
|
|
||||||
const client = new LokaliseApi({ apiKey })
|
|
||||||
|
|
||||||
const WGet = (url) =>
|
|
||||||
new Promise((done) => {
|
|
||||||
https
|
|
||||||
.get(url, (res) => {
|
|
||||||
const data = []
|
|
||||||
res
|
|
||||||
.on('data', (chunk) => {
|
|
||||||
data.push(chunk)
|
|
||||||
})
|
|
||||||
.on('end', () => {
|
|
||||||
let buffer = Buffer.concat(data)
|
|
||||||
done(buffer)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
console.log('download error:', err)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function download() {
|
|
||||||
// For details see: https://app.lokalise.com/api2docs/curl/#transition-download-files-post
|
|
||||||
const download = await client.files().download(project_id, {
|
|
||||||
format: 'json',
|
|
||||||
indentation: 'tab',
|
|
||||||
json_unescaped_slashes: true,
|
|
||||||
original_filenames: false,
|
|
||||||
bundle_structure: '%LANG_ISO%.%FORMAT%',
|
|
||||||
export_sort: 'first_added',
|
|
||||||
export_empty_as: 'skip',
|
|
||||||
add_newline_eof: true,
|
|
||||||
replace_breaks: false,
|
|
||||||
})
|
|
||||||
const buffered = await WGet(download.bundle_url)
|
|
||||||
const zip = new AdmZip(buffered)
|
|
||||||
zip.extractAllTo('./locales', true)
|
|
||||||
}
|
|
||||||
|
|
||||||
download().catch((e) => {
|
|
||||||
console.error(e)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
@@ -1,8 +1,25 @@
|
|||||||
import { API, type Status } from 'cryptgeon/shared'
|
import { status as apiStatus } from '@cryptgeon/shared'
|
||||||
import { writable } from 'svelte/store'
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
export const status = writable<null | Status>(null)
|
export type StatusInfo = {
|
||||||
|
version: string
|
||||||
|
max_size: number
|
||||||
|
max_views: number
|
||||||
|
max_expiration: number
|
||||||
|
allow_advanced: boolean
|
||||||
|
allow_files: boolean
|
||||||
|
imprint_url: string
|
||||||
|
imprint_html: string
|
||||||
|
theme_image: string
|
||||||
|
theme_text: string
|
||||||
|
theme_page_title: string
|
||||||
|
theme_favicon: string
|
||||||
|
theme_new_note_notice: boolean
|
||||||
|
theme_home_link: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const status = writable<null | StatusInfo>(null)
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
status.set(await API.status())
|
status.set((await apiStatus()) as StatusInfo)
|
||||||
}
|
}
|
||||||
@@ -4,10 +4,9 @@
|
|||||||
import { status } from '$lib/stores/status'
|
import { status } from '$lib/stores/status'
|
||||||
import Switch from '$lib/ui/Switch.svelte'
|
import Switch from '$lib/ui/Switch.svelte'
|
||||||
import TextInput from '$lib/ui/TextInput.svelte'
|
import TextInput from '$lib/ui/TextInput.svelte'
|
||||||
import type { Note } from 'cryptgeon/shared'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
note: Note
|
note: { views: number; expiration: number }
|
||||||
timeExpiration?: boolean
|
timeExpiration?: boolean
|
||||||
customPassword?: string | null
|
customPassword?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import Button from '$lib/ui/Button.svelte'
|
import Button from '$lib/ui/Button.svelte'
|
||||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||||
import type { FileDTO } from 'cryptgeon/shared'
|
import type { FileDTO } from '@cryptgeon/shared'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label?: string
|
label?: string
|
||||||
@@ -16,9 +16,9 @@
|
|||||||
async function fileToDTO(file: File): Promise<FileDTO> {
|
async function fileToDTO(file: File): Promise<FileDTO> {
|
||||||
return {
|
return {
|
||||||
name: file.name,
|
name: file.name,
|
||||||
|
mime: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.type,
|
data: new Uint8Array(await file.arrayBuffer()),
|
||||||
contents: new Uint8Array(await file.arrayBuffer()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,12 @@
|
|||||||
|
|
||||||
import { status } from '$lib/stores/status'
|
import { status } from '$lib/stores/status'
|
||||||
|
|
||||||
// Due to encoding overhead (~35%) with base64
|
// Payload is raw bytes (msgpack + cipher), no base64 padding overhead.
|
||||||
// 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 * overhead, { binary: true })}
|
{prettyBytes($status.max_size, { binary: true })}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('common.loading')}
|
{$_('common.loading')}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { status } from '$lib/stores/status'
|
import { status } from '$lib/stores/status'
|
||||||
import Button from '$lib/ui/Button.svelte'
|
import Button from '$lib/ui/Button.svelte'
|
||||||
import TextInput from '$lib/ui/TextInput.svelte'
|
import TextInput from '$lib/ui/TextInput.svelte'
|
||||||
import Canvas from './Canvas.svelte'
|
import QR from './QR.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
result: NoteResult
|
result: NoteResult
|
||||||
@@ -18,8 +18,11 @@
|
|||||||
|
|
||||||
let { result }: Props = $props()
|
let { result }: Props = $props()
|
||||||
|
|
||||||
let url = $state(`${window.location.origin}/note/${result.id}`)
|
let url = $derived.by(() => {
|
||||||
if (result.password) url += `#${result.password}`
|
let url = `${window.location.origin}/note/${result.id}`
|
||||||
|
if (result.password) url += `#${result.password}`
|
||||||
|
return url
|
||||||
|
})
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
@@ -36,7 +39,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Canvas value={url} />
|
<QR value={url} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $status?.theme_new_note_notice}
|
{#if $status?.theme_new_note_notice}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
import Button from '$lib/ui/Button.svelte'
|
||||||
|
import type { FileDTO } from '@cryptgeon/shared'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
files: FileDTO[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let { files = $bindable([]) }: Props = $props()
|
||||||
|
|
||||||
|
let previewUrls: string[] = $state([])
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const urls = files.map((f) => URL.createObjectURL(new Blob([f.data.slice(0)], { type: f.mime })))
|
||||||
|
previewUrls = urls
|
||||||
|
return () => {
|
||||||
|
for (const url of urls) URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function remove(index: number) {
|
||||||
|
const removed = files[index]
|
||||||
|
URL.revokeObjectURL(previewUrls[index])
|
||||||
|
files = files.toSpliced(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(type: string) {
|
||||||
|
return type.startsWith('image/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if files.length > 0}
|
||||||
|
<div class="pasted-files-preview">
|
||||||
|
<h4>{$t('home.pasted_files')}</h4>
|
||||||
|
<div class="files-grid">
|
||||||
|
{#each files as entry, index}
|
||||||
|
<div class="file-preview">
|
||||||
|
{#if isImage(entry.mime)}
|
||||||
|
<img src={previewUrls[index]} class="preview-img" alt={entry.name} />
|
||||||
|
{:else}
|
||||||
|
<div class="file-icon">
|
||||||
|
<div class="file-extension">
|
||||||
|
{entry.name.split('.').pop()?.toUpperCase() || entry.mime}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="file-name">{entry.name}</div>
|
||||||
|
<div class="file-actions">
|
||||||
|
<Button onclick={() => remove(index)}>
|
||||||
|
{$t('home.remove')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pasted-files-preview {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pasted-files-preview h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img {
|
||||||
|
max-width: 150px;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-extension {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-actions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pasted-files-preview {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-img, .file-icon {
|
||||||
|
max-width: 120px;
|
||||||
|
max-height: 120px;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-grid {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+14
-15
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// @ts-ignore
|
|
||||||
import QR from 'qrious'
|
|
||||||
import { t } from 'svelte-intl-precompile'
|
|
||||||
|
|
||||||
import { getCSSVariable } from '$lib/utils'
|
import { getCSSVariable } from '$lib/utils'
|
||||||
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
import { renderSVG } from 'uqr'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string
|
value: string
|
||||||
@@ -11,35 +9,36 @@
|
|||||||
|
|
||||||
let { value }: Props = $props()
|
let { value }: Props = $props()
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement | null = $state(null)
|
let qr: string | null = $state(null)
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
new QR({
|
qr = renderSVG(value, {
|
||||||
value,
|
ecc: 'Q',
|
||||||
level: 'Q',
|
blackColor: getCSSVariable('--ui-bg-0'),
|
||||||
size: 800,
|
whiteColor: getCSSVariable('--ui-text-0'),
|
||||||
background: getCSSVariable('--ui-bg-0'),
|
|
||||||
foreground: getCSSVariable('--ui-text-0'),
|
|
||||||
element: canvas,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<small>{$t('common.qr_code')}</small>
|
<small>{$t('common.qr_code')}</small>
|
||||||
<div>
|
<div>
|
||||||
<canvas bind:this={canvas}></canvas>
|
{#if qr}
|
||||||
|
{@html qr}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
div {
|
div {
|
||||||
padding: 0.5rem;
|
padding: 0.25rem;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
border: 2px solid var(--ui-bg-1);
|
border: 2px solid var(--ui-bg-1);
|
||||||
background-color: var(--ui-bg-0);
|
background-color: var(--ui-bg-0);
|
||||||
margin-top: 0.125rem;
|
margin-top: 0.125rem;
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas {
|
div :global(svg) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
|
export type DecryptedNote = {
|
||||||
|
meta: { type: 'text' | 'file' }
|
||||||
|
contents: any
|
||||||
|
}
|
||||||
|
|
||||||
function saveAs(file: File) {
|
function saveAs(file: File) {
|
||||||
const url = window.URL.createObjectURL(file)
|
const url = window.URL.createObjectURL(file)
|
||||||
@@ -20,7 +23,7 @@
|
|||||||
|
|
||||||
import Button from '$lib/ui/Button.svelte'
|
import Button from '$lib/ui/Button.svelte'
|
||||||
import { copy } from '$lib/utils'
|
import { copy } from '$lib/utils'
|
||||||
import type { FileDTO, NotePublic } from 'cryptgeon/shared'
|
import type { FileDTO } from '@cryptgeon/shared'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
note: DecryptedNote
|
note: DecryptedNote
|
||||||
@@ -31,9 +34,9 @@
|
|||||||
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.contents], file.name, {
|
const f = new File([file.data.slice(0)], file.name, {
|
||||||
type: file.type,
|
type: file.mime,
|
||||||
})
|
})
|
||||||
saveAs(f)
|
saveAs(f)
|
||||||
}
|
}
|
||||||
@@ -43,11 +46,11 @@
|
|||||||
files = note.contents
|
files = note.contents
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
let download = $derived(() => {
|
function downloadAll() {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
downloadFile(file)
|
downloadFile(file)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
|
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -77,10 +80,19 @@
|
|||||||
<button onclick={() => downloadFile(file)}>
|
<button onclick={() => downloadFile(file)}>
|
||||||
<b>↓ {file.name}</b>
|
<b>↓ {file.name}</b>
|
||||||
</button>
|
</button>
|
||||||
<small> {file.type} - {prettyBytes(file.size)}</small>
|
<small> {file.mime} - {prettyBytes(file.size ?? file.data.length)}</small>
|
||||||
</div>
|
</div>
|
||||||
|
{#if file.mime.startsWith('image/')}
|
||||||
|
{#key file.name}
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(new File([file.data.slice(0)], file.name, { type: file.mime }))}
|
||||||
|
alt={file.name}
|
||||||
|
class="preview"
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<Button onclick={download}>{$t('show.download_all')}</Button>
|
<Button onclick={downloadAll}>{$t('show.download_all')}</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -129,4 +141,13 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 2px solid var(--ui-bg-1);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/ui/Icon.svelte'
|
import Icon from '$lib/ui/Icon.svelte'
|
||||||
import { copy as copyFN } from '$lib/utils'
|
import { copy as copyFN } from '$lib/utils'
|
||||||
import { getRandomBytes, Hex } from 'occulto'
|
import { randomBytes, bytesToHex } from '@cryptgeon/shared'
|
||||||
import type { HTMLInputAttributes } from 'svelte/elements'
|
import type { HTMLInputAttributes } from 'svelte/elements'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -21,25 +21,21 @@
|
|||||||
...rest
|
...rest
|
||||||
}: HTMLInputAttributes & Props = $props()
|
}: HTMLInputAttributes & Props = $props()
|
||||||
|
|
||||||
const initialType = rest.type
|
// svelte-ignore state_referenced_locally
|
||||||
|
const initialType = $state(rest.type)
|
||||||
const isPassword = initialType === 'password'
|
const isPassword = initialType === 'password'
|
||||||
let hidden = $state(true)
|
let hidden = $state(true)
|
||||||
|
|
||||||
let valid = $derived(validate(value))
|
let valid = $derived(validate(value))
|
||||||
|
let type = $derived(isPassword ? (hidden ? 'password' : 'text') : rest.type)
|
||||||
$effect(() => {
|
|
||||||
if (isPassword) {
|
|
||||||
value
|
|
||||||
rest.type = hidden ? initialType : 'text'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
|
console.debug('toggle')
|
||||||
hidden = !hidden
|
hidden = !hidden
|
||||||
}
|
}
|
||||||
|
|
||||||
async function randomFN() {
|
async function randomFN() {
|
||||||
value = Hex.encode(await getRandomBytes(32))
|
value = bytesToHex(randomBytes(32))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -50,7 +46,7 @@
|
|||||||
<span class="error-text">{valid}</span>
|
<span class="error-text">{valid}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</small>
|
</small>
|
||||||
<input bind:value {...rest} class:valid={valid === true} />
|
<input bind:value {...rest} {type} autocomplete="off" class:valid={valid === true} />
|
||||||
<div class="icons">
|
<div class="icons">
|
||||||
{#if isPassword}
|
{#if isPassword}
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AES, Hex } from 'occulto'
|
import {
|
||||||
|
create as apiCreate,
|
||||||
|
bytesToHex,
|
||||||
|
packContent,
|
||||||
|
type FileDTO,
|
||||||
|
type NoteInput,
|
||||||
|
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'
|
||||||
|
import { transfer } from 'comlink'
|
||||||
|
|
||||||
import { status } from '$lib/stores/status'
|
import { status } from '$lib/stores/status'
|
||||||
import { notify } from '$lib/toast'
|
import { notify } from '$lib/toast'
|
||||||
@@ -11,16 +19,13 @@
|
|||||||
import Loader from '$lib/ui/Loader.svelte'
|
import Loader from '$lib/ui/Loader.svelte'
|
||||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||||
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
|
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
|
||||||
|
import PastedFilesPreview from '$lib/ui/PastedFilesPreview.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'
|
import { createWorker } from '$lib/worker'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
let note: Note = $state({
|
let note: { views: number; expiration: number } = $state({ views: 1, expiration: 60 })
|
||||||
contents: '',
|
|
||||||
meta: { type: 'text' },
|
|
||||||
views: 1,
|
|
||||||
expiration: 60,
|
|
||||||
})
|
|
||||||
let files: FileDTO[] = $state([])
|
let files: FileDTO[] = $state([])
|
||||||
let result: NoteResult | null = $state(null)
|
let result: NoteResult | null = $state(null)
|
||||||
let advanced = $state(false)
|
let advanced = $state(false)
|
||||||
@@ -29,6 +34,8 @@
|
|||||||
let customPassword: string | null = $state(null)
|
let customPassword: string | null = $state(null)
|
||||||
let description = $state('')
|
let description = $state('')
|
||||||
let loading: string | null = $state(null)
|
let loading: string | null = $state(null)
|
||||||
|
let isPasting = $state(false)
|
||||||
|
let textContent = $state('')
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!advanced) {
|
if (!advanced) {
|
||||||
@@ -48,14 +55,67 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
note.meta.type = isFile ? 'file' : 'text'
|
if (!isFile) textContent = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
const worker = createWorker()
|
||||||
if (!isFile) {
|
|
||||||
note.contents = ''
|
async function handlePaste(e: ClipboardEvent) {
|
||||||
|
const data = e.clipboardData
|
||||||
|
if (!data) return
|
||||||
|
|
||||||
|
const raw: File[] = []
|
||||||
|
|
||||||
|
if (data.files.length) {
|
||||||
|
raw.push(...Array.from(data.files))
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
for (let i = 0; i < data.items.length; i++) {
|
||||||
|
const item = data.items[i]
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) raw.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const pasted: File[] = []
|
||||||
|
for (const f of raw) {
|
||||||
|
const key = `${f.name}|${f.size}`
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key)
|
||||||
|
pasted.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isPasting = true
|
||||||
|
|
||||||
|
const dtos: FileDTO[] = await Promise.all(
|
||||||
|
pasted.map(async (file) => {
|
||||||
|
const ext = file.name.includes('.') ? '' : `.${file.type.split('/')[1] || 'bin'}`
|
||||||
|
const name =
|
||||||
|
file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`
|
||||||
|
const renamed = new File([file], name, { type: file.type })
|
||||||
|
const data = new Uint8Array(await renamed.arrayBuffer())
|
||||||
|
return {
|
||||||
|
name: renamed.name,
|
||||||
|
mime: renamed.type,
|
||||||
|
size: renamed.size,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (dtos.length > 0) {
|
||||||
|
if (!isFile) isFile = true
|
||||||
|
files = [...files, ...dtos]
|
||||||
|
}
|
||||||
|
|
||||||
|
isPasting = false
|
||||||
|
}
|
||||||
|
|
||||||
class EmptyContentError extends Error {}
|
class EmptyContentError extends Error {}
|
||||||
|
|
||||||
@@ -64,40 +124,42 @@
|
|||||||
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()
|
||||||
data.contents = await Adapters.Files.encrypt(files, key)
|
} else if (textContent === '') {
|
||||||
} else {
|
throw new EmptyContentError()
|
||||||
if (note.contents === '') throw new EmptyContentError()
|
}
|
||||||
data.contents = await Adapters.Text.encrypt(note.contents, key)
|
|
||||||
|
const noteInput: NoteInput = isFile
|
||||||
|
? transfer(
|
||||||
|
{
|
||||||
|
type: 'files',
|
||||||
|
files: $state.snapshot(files),
|
||||||
|
},
|
||||||
|
files.map((f) => f.data.buffer)
|
||||||
|
)
|
||||||
|
: { type: 'text', text: textContent }
|
||||||
|
const payload = await worker.pack(noteInput, 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 API.create(data)
|
const response = await apiCreate(serverNote)
|
||||||
result = {
|
result = {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
password: customPassword ? undefined : Hex.encode(key),
|
password: customPassword ? undefined : bytesToHex(payload.key),
|
||||||
}
|
}
|
||||||
notify.success($t('home.messages.note_created'))
|
notify.success($t('home.messages.note_created'))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PayloadToLargeError) {
|
console.error(e)
|
||||||
notify.error($t('home.errors.note_to_big'))
|
notify.error($t('home.errors.note_error'))
|
||||||
} 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
|
||||||
}
|
}
|
||||||
@@ -110,18 +172,28 @@
|
|||||||
<p>
|
<p>
|
||||||
{@html $status?.theme_text || $t('home.intro')}
|
{@html $status?.theme_text || $t('home.intro')}
|
||||||
</p>
|
</p>
|
||||||
<form onsubmit={submit}>
|
<form onsubmit={submit} onpaste={handlePaste}>
|
||||||
<fieldset disabled={loading !== null}>
|
<fieldset disabled={loading !== null}>
|
||||||
{#if isFile}
|
<div class="paste-indicator">
|
||||||
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
{#if isPasting}
|
||||||
{:else}
|
<div class="pasting-overlay">
|
||||||
<TextArea
|
<div class="pasting-spinner"></div>
|
||||||
data-testid="text-field"
|
<span>{$t('home.pasting')}</span>
|
||||||
label={$t('common.note')}
|
</div>
|
||||||
bind:value={note.contents}
|
{/if}
|
||||||
placeholder="..."
|
{#if isFile}
|
||||||
/>
|
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||||
{/if}
|
{:else}
|
||||||
|
<TextArea
|
||||||
|
data-testid="text-field"
|
||||||
|
label={$t('common.note')}
|
||||||
|
bind:value={textContent}
|
||||||
|
placeholder="..."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<PastedFilesPreview bind:files />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="bottom">
|
<div class="bottom">
|
||||||
{#if $status?.allow_files}
|
{#if $status?.allow_files}
|
||||||
@@ -180,4 +252,40 @@
|
|||||||
.grow {
|
.grow {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paste-indicator {
|
||||||
|
position: relative;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pasting-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pasting-spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 2px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<nav>
|
<nav>
|
||||||
<a href="/">/home</a>
|
{#if $status?.theme_home_link !== false}
|
||||||
|
<a href="/">/home</a>
|
||||||
|
{/if}
|
||||||
<a href="/about">/about</a>
|
<a href="/about">/about</a>
|
||||||
{#if $status?.imprint_url}
|
{#if $status?.imprint_url}
|
||||||
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
|
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a onclick={reset} href="/">
|
<a onclick={reset} href="/">
|
||||||
{#if $status?.theme_image}
|
{#if $status === null}
|
||||||
|
<!-- waiting for status to load to avoid flashing default logo -->
|
||||||
|
{:else if $status.theme_image}
|
||||||
<img alt="logo" src={$status.theme_image} />
|
<img alt="logo" src={$status.theme_image} />
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { wrap } from 'comlink'
|
||||||
|
|
||||||
|
import CryptWorker from './worker?worker'
|
||||||
|
import type { packContent, unpackContent } from '@cryptgeon/shared'
|
||||||
|
|
||||||
|
export function createWorker() {
|
||||||
|
const worker = new CryptWorker()
|
||||||
|
return wrap<WorkerConract>(worker)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkerConract = {
|
||||||
|
pack: typeof packContent
|
||||||
|
unpack: typeof unpackContent
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { WorkerConract } from '$lib/worker'
|
||||||
|
import { packContent, unpackContent } from '@cryptgeon/shared'
|
||||||
|
import { expose, transfer } from 'comlink'
|
||||||
|
|
||||||
|
const contract: WorkerConract = {
|
||||||
|
pack(input, password) {
|
||||||
|
const content = packContent(input, password)
|
||||||
|
return transfer(content, [content.data.buffer, content.extra.buffer, content.key.buffer])
|
||||||
|
},
|
||||||
|
unpack(data, key) {
|
||||||
|
return unpackContent(data, key)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expose(contract)
|
||||||
@@ -45,18 +45,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</AboutParagraph>
|
</AboutParagraph>
|
||||||
|
|
||||||
<AboutParagraph title="translations">
|
<AboutParagraph title="attribution">
|
||||||
<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,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { AES, Hex } from 'occulto'
|
import {
|
||||||
|
deriveKey,
|
||||||
|
hexToBytes,
|
||||||
|
decode,
|
||||||
|
info,
|
||||||
|
get as apiGet,
|
||||||
|
unpackContent,
|
||||||
|
type FileDTO,
|
||||||
|
} from '@cryptgeon/shared'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
@@ -7,8 +15,8 @@
|
|||||||
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'
|
||||||
|
import { createWorker } from '$lib/worker'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData
|
data: PageData
|
||||||
@@ -16,11 +24,11 @@
|
|||||||
|
|
||||||
let { data }: Props = $props()
|
let { data }: Props = $props()
|
||||||
|
|
||||||
let id = data.id
|
let id = $derived(data.id)
|
||||||
let password: string | null = $state<string | null>(null)
|
let password: string | null = $state<string | null>(null)
|
||||||
let note: DecryptedNote | null = $state(null)
|
let note: DecryptedNote | null = $state(null)
|
||||||
let exists = $state(false)
|
let exists = $state(false)
|
||||||
let meta: NoteMeta | null = $state(null)
|
let hasExtra = $state(false)
|
||||||
|
|
||||||
let loading: string | null = $state(null)
|
let loading: string | null = $state(null)
|
||||||
let error: string | null = $state(null)
|
let error: string | null = $state(null)
|
||||||
@@ -28,13 +36,16 @@
|
|||||||
let valid = $derived(!!password?.length)
|
let valid = $derived(!!password?.length)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Check if note exists
|
|
||||||
try {
|
try {
|
||||||
loading = $t('common.loading')
|
loading = $t('common.loading')
|
||||||
password = window.location.hash.slice(1)
|
password = window.location.hash.slice(1)
|
||||||
const note = await API.info(id)
|
const meta = await info(id)
|
||||||
meta = note.meta
|
if (meta) {
|
||||||
exists = true
|
hasExtra = !!meta.extra?.length
|
||||||
|
exists = true
|
||||||
|
} else {
|
||||||
|
exists = false
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
exists = false
|
exists = false
|
||||||
} finally {
|
} finally {
|
||||||
@@ -42,9 +53,8 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
const worker = createWorker()
|
||||||
* Get the actual contents of the note and decrypt it.
|
|
||||||
*/
|
|
||||||
async function show(e: SubmitEvent) {
|
async function show(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
try {
|
try {
|
||||||
@@ -53,24 +63,40 @@
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load note
|
|
||||||
error = null
|
error = null
|
||||||
loading = $t('common.downloading')
|
loading = $t('common.downloading')
|
||||||
const data = await API.get(id)
|
const serverNote = await apiGet(id)
|
||||||
|
if (!serverNote) {
|
||||||
|
error = $t('show.errors.not_found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
loading = $t('common.decrypting')
|
loading = $t('common.decrypting')
|
||||||
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
|
let key: Uint8Array
|
||||||
const key = derived ? derived[0] : Hex.decode(password!)
|
if (hasExtra && serverNote.meta.extra && serverNote.meta.extra.length > 0) {
|
||||||
switch (data.meta.type) {
|
const derivation = decode(serverNote.meta.extra) as any
|
||||||
|
key = deriveKey(password!, new Uint8Array(derivation.salt))
|
||||||
|
} else {
|
||||||
|
key = hexToBytes(password!)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await worker.unpack(serverNote.data, key)
|
||||||
|
|
||||||
|
switch (content.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
note = {
|
note = {
|
||||||
meta: { type: 'text' },
|
meta: { type: 'text' },
|
||||||
contents: await Adapters.Text.decrypt(data.contents, key),
|
contents: content.data,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'file':
|
case 'files':
|
||||||
|
const files = (content.data as any[]).map((f: any) => ({
|
||||||
|
...f,
|
||||||
|
data: f.data instanceof Uint8Array ? f.data : new Uint8Array(f.data as any),
|
||||||
|
}))
|
||||||
note = {
|
note = {
|
||||||
meta: { type: 'file' },
|
meta: { type: 'file' },
|
||||||
contents: await Adapters.Files.decrypt(data.contents, key),
|
contents: files,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -94,7 +120,7 @@
|
|||||||
<form onsubmit={show}>
|
<form onsubmit={show}>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<p>{$t('show.explanation')}</p>
|
<p>{$t('show.explanation')}</p>
|
||||||
{#if meta?.derivation}
|
{#if hasExtra}
|
||||||
<TextInput
|
<TextInput
|
||||||
data-testid="show-note-password"
|
data-testid="show-note-password"
|
||||||
type="password"
|
type="password"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"extends": "./.svelte-kit/tsconfig.json",
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
}
|
"skipLibCheck": true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite'
|
import { sveltekit } from '@sveltejs/kit/vite'
|
||||||
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
|
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
const port = 3000
|
const port = 3000
|
||||||
|
|
||||||
/** @type {import('vite').UserConfig} */
|
export default defineConfig({
|
||||||
const config = {
|
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port,
|
port,
|
||||||
@@ -14,6 +14,4 @@ const config = {
|
|||||||
},
|
},
|
||||||
preview: { port },
|
preview: { port },
|
||||||
plugins: [sveltekit(), precompileIntl('locales')],
|
plugins: [sveltekit(), precompileIntl('locales')],
|
||||||
}
|
})
|
||||||
|
|
||||||
export default config
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@cryptgeon/shared",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
|
"@noble/ciphers": "^2.4.0",
|
||||||
|
"@noble/hashes": "^2.4.0",
|
||||||
|
"lz4js": "^0.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/strictest": "catalog:",
|
||||||
|
"@types/lz4js": "^0.2.2",
|
||||||
|
"typescript": "catalog:",
|
||||||
|
"vitest": "^4.1.11"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { encode, decode } from '@msgpack/msgpack'
|
||||||
|
import { setServer, getServer, create, info, get, status } from './api'
|
||||||
|
|
||||||
|
const server = 'http://example.test'
|
||||||
|
const created = encode({ id: 'abc123' })
|
||||||
|
const metaOut = encode({ meta: { views: 3, extra: Buffer.from('040506','hex') } })
|
||||||
|
const dataOut = encode({ meta: { views: 0 },data: Buffer.from('090909','hex') })
|
||||||
|
|
||||||
|
function mockFetch(body: Uint8Array) {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
|
||||||
|
json: async () => ({}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBuffer(buf: Uint8Array) {
|
||||||
|
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('api client', () => {
|
||||||
|
it('setServer trims trailing slashes', () => {
|
||||||
|
setServer('http://x.test///')
|
||||||
|
expect(getServer()).toBe('http://x.test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('create POSTs msgpack note and returns id', async () => {
|
||||||
|
setServer(server)
|
||||||
|
const fetchMock = mockFetch(created)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const note = { meta: { views: 5 },data: Buffer.from('010203','hex') }
|
||||||
|
const result = await create(note)
|
||||||
|
const url = fetchMock.mock.calls[0]![0]!
|
||||||
|
const init = fetchMock.mock.calls[0]![1]!
|
||||||
|
expect(url).toBe(server + '/api/v3/notes')
|
||||||
|
expect(init.method).toBe('POST')
|
||||||
|
expect(init.headers).toEqual({ 'content-type': 'application/msgpack' })
|
||||||
|
const sent = decode(new Uint8Array(copyBuffer(init.body))) as { meta?: { views?: number } }
|
||||||
|
expect(sent?.meta?.views).toBe(5)
|
||||||
|
expect(result).toEqual({ id: 'abc123' })
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('info GETs meta', async () => {
|
||||||
|
setServer(server)
|
||||||
|
const fetchMock = mockFetch(metaOut)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const result = await info('id1')
|
||||||
|
expect(result?.views).toBe(3)
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('get DELETEs and parses data', async () => {
|
||||||
|
setServer(server)
|
||||||
|
const fetchMock = mockFetch(dataOut)
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
const result = await get('id2')
|
||||||
|
expect(result?.meta?.views).toBe(0)
|
||||||
|
const init = fetchMock.mock.calls[0]![1]!
|
||||||
|
expect(init.method).toBe('DELETE')
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('status GETs JSON config', async () => {
|
||||||
|
setServer(server)
|
||||||
|
const fetchMock = mockFetch(new Uint8Array())
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
await status()
|
||||||
|
const url = fetchMock.mock.calls[0]![0]!
|
||||||
|
expect(url).toBe(server + '/api/v3/status')
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { encode, decode } from "@msgpack/msgpack";
|
||||||
|
|
||||||
|
import type { ServerNote, Status } from "./types.js";
|
||||||
|
|
||||||
|
let server = "";
|
||||||
|
|
||||||
|
export function setServer(url: string) {
|
||||||
|
server = url.replace(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServer() {
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(path: string) {
|
||||||
|
return `${server}/api/v3/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(note: ServerNote): Promise<{ id: string }> {
|
||||||
|
const res = await fetch(api("notes"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/msgpack" },
|
||||||
|
body: encode(note),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("create failed");
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const data = decode(new Uint8Array(buf)) as any;
|
||||||
|
if (typeof data?.id !== "string") throw new Error("invalid response");
|
||||||
|
return { id: data.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function info(id: string): Promise<ServerNote["meta"] | null> {
|
||||||
|
const res = await fetch(api(`notes/${id}`));
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const data = decode(new Uint8Array(buf)) as any;
|
||||||
|
const meta = data?.meta as ServerNote["meta"] | undefined;
|
||||||
|
if (!meta) return null;
|
||||||
|
if (meta.extra && !(meta.extra instanceof Uint8Array)) meta.extra = new Uint8Array(meta.extra as any);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(id: string): Promise<ServerNote | null> {
|
||||||
|
const res = await fetch(api(`notes/${id}`), { method: "DELETE" });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
const data = decode(new Uint8Array(buf)) as any;
|
||||||
|
const meta = data.meta as ServerNote["meta"];
|
||||||
|
if (meta?.extra && !(meta.extra instanceof Uint8Array)) meta.extra = new Uint8Array(meta.extra as any);
|
||||||
|
const d = data.data;
|
||||||
|
return { meta, data: d instanceof Uint8Array ? d : new Uint8Array(d) } satisfies ServerNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function status(): Promise<Status> {
|
||||||
|
const res = await fetch(api("status"));
|
||||||
|
if (!res.ok) throw new Error("status failed");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { compress, decompress, utf8ToBytes } from "./index";
|
||||||
|
|
||||||
|
describe("compression", () => {
|
||||||
|
it("round-trips small text", () => {
|
||||||
|
const data = utf8ToBytes("hello world");
|
||||||
|
const compressed = compress(data);
|
||||||
|
const decompressed = decompress(compressed);
|
||||||
|
expect(decompressed).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips highly compressible data", () => {
|
||||||
|
const data = utf8ToBytes("a".repeat(10_000));
|
||||||
|
const compressed = compress(data);
|
||||||
|
expect(compressed.length).toBeLessThan(data.length);
|
||||||
|
const decompressed = decompress(compressed);
|
||||||
|
expect(decompressed).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips arbitrary bytes", () => {
|
||||||
|
const data = new Uint8Array([0, 128, 255, 1, 2, 3, 200, 100]);
|
||||||
|
const compressed = compress(data);
|
||||||
|
const decompressed = decompress(compressed);
|
||||||
|
expect(decompressed).toEqual(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import LZ4 from "lz4js";
|
||||||
|
|
||||||
|
export function compress(data: Uint8Array): Uint8Array {
|
||||||
|
return LZ4.compress(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decompress(data: Uint8Array): Uint8Array {
|
||||||
|
return LZ4.decompress(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { deriveKey, encrypt, decrypt, generateKey, utf8ToBytes, randomBytes } from "./crypto";
|
||||||
|
|
||||||
|
describe("crypto", () => {
|
||||||
|
it("encrypts and decrypts with generated key", () => {
|
||||||
|
const data = utf8ToBytes("hello world");
|
||||||
|
const key = generateKey();
|
||||||
|
const enc = encrypt(data, key);
|
||||||
|
const dec = decrypt(enc, key);
|
||||||
|
expect(dec).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encrypts and decrypts with derived key", () => {
|
||||||
|
const data = utf8ToBytes("secret message");
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const key = deriveKey("password123", salt);
|
||||||
|
const enc = encrypt(data, key);
|
||||||
|
const dec = decrypt(enc, key);
|
||||||
|
expect(dec).toEqual(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derived key has same length as generated", () => {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
expect(deriveKey("test", salt).length).toBe(generateKey().length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||||
|
import { managedNonce, randomBytes } from "@noble/ciphers/utils.js";
|
||||||
|
import { scrypt } from "@noble/hashes/scrypt.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
bytesToUtf8,
|
||||||
|
utf8ToBytes,
|
||||||
|
hexToBytes,
|
||||||
|
bytesToHex,
|
||||||
|
randomBytes,
|
||||||
|
} from "@noble/ciphers/utils.js";
|
||||||
|
|
||||||
|
const N = 2 ** 15;
|
||||||
|
const KEY_SIZE = 32;
|
||||||
|
|
||||||
|
export function generateKey(): Uint8Array {
|
||||||
|
return randomBytes(KEY_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveKey(password: string, salt: Uint8Array): Uint8Array {
|
||||||
|
return scrypt(password, salt, { N, r: 8, p: 1, dkLen: KEY_SIZE });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||||
|
const chacha = managedNonce(xchacha20poly1305)(key);
|
||||||
|
return chacha.encrypt(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
|
||||||
|
const chacha = managedNonce(xchacha20poly1305)(key);
|
||||||
|
return chacha.decrypt(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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";
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type NoteMeta = {
|
||||||
|
expiration?: number;
|
||||||
|
views?: number;
|
||||||
|
extra?: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ServerNote = {
|
||||||
|
meta: NoteMeta;
|
||||||
|
data: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NoteContent =
|
||||||
|
| { type: "text"; data: string }
|
||||||
|
| { type: "files"; data: FileDTO[] };
|
||||||
|
|
||||||
|
export type FileDTO = {
|
||||||
|
name: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
data: Uint8Array;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Status = {
|
||||||
|
version: string;
|
||||||
|
max_size: number;
|
||||||
|
max_views: number;
|
||||||
|
max_expiration: number;
|
||||||
|
allow_advanced: boolean;
|
||||||
|
allow_files: boolean;
|
||||||
|
imprint_url: string;
|
||||||
|
imprint_html: string;
|
||||||
|
theme_image: string;
|
||||||
|
theme_text: string;
|
||||||
|
theme_page_title: string;
|
||||||
|
theme_favicon: string;
|
||||||
|
theme_new_note_notice: boolean;
|
||||||
|
theme_home_link: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+3554
-2340
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,12 @@
|
|||||||
packages:
|
packages:
|
||||||
- "packages/**"
|
- "packages/**"
|
||||||
|
|
||||||
|
catalog:
|
||||||
|
vite-plus: ^0.3.0
|
||||||
|
typescript: ^7.0.2
|
||||||
|
"@tsconfig/strictest": ^2.0.8
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
|
||||||
|
minimumReleaseAge: 10080 # One week
|
||||||
|
|||||||
+5
-2
@@ -33,7 +33,10 @@ async function createNote(page: Page, options: CreatePage): Promise<void> {
|
|||||||
await fileChooser.setFiles(options.files)
|
await fileChooser.setFiles(options.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.views || options.expiration || options.password) await page.getByTestId('switch-advanced').click()
|
if (options.views || options.expiration || options.password) {
|
||||||
|
await page.getByTestId('switch-advanced').waitFor({ state: 'visible', timeout: 10000 })
|
||||||
|
await page.getByTestId('switch-advanced').click()
|
||||||
|
}
|
||||||
if (options.views) {
|
if (options.views) {
|
||||||
await page.getByTestId('field-views').fill(options.views.toString())
|
await page.getByTestId('field-views').fill(options.views.toString())
|
||||||
}
|
}
|
||||||
@@ -97,7 +100,7 @@ export async function checkLinkDoesNotExist(page: Page, link: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function CLI(...args: string[]) {
|
export async function CLI(...args: string[]) {
|
||||||
return await exec('./packages/cli/dist/cli.cjs', args, {
|
return await exec('./packages/cli/dist/cli.mjs', args, {
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
CRYPTGEON_SERVER: 'http://localhost:3000',
|
CRYPTGEON_SERVER: 'http://localhost:3000',
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { getFileChecksum } from "../../files";
|
||||||
|
|
||||||
|
const IMG_PATH = "test/assets/image.jpg";
|
||||||
|
|
||||||
|
test.describe("@web", () => {
|
||||||
|
test("paste image", async ({ page, browserName }) => {
|
||||||
|
test.skip(
|
||||||
|
browserName !== "chromium",
|
||||||
|
"DataTransfer.items.add(File) is only supported in Chromium",
|
||||||
|
);
|
||||||
|
const checksum = await getFileChecksum(IMG_PATH);
|
||||||
|
const imgBuffer = await readFile(IMG_PATH);
|
||||||
|
const imgBase64 = imgBuffer.toString("base64");
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.waitForSelector("form");
|
||||||
|
|
||||||
|
// Create a paste event with clipboardData containing the image file
|
||||||
|
await page.evaluate(
|
||||||
|
async ({ base64, mimeType }) => {
|
||||||
|
const response = await fetch(`data:${mimeType};base64,${base64}`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], "image.jpg", { type: mimeType });
|
||||||
|
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(file);
|
||||||
|
|
||||||
|
const event = new ClipboardEvent("paste", {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
clipboardData: dt,
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("form")?.dispatchEvent(event);
|
||||||
|
},
|
||||||
|
{ base64: imgBase64, mimeType: "image/jpeg" },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the paste to process and preview to appear
|
||||||
|
await expect(page.locator(".pasted-files-preview")).toBeVisible({
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the note
|
||||||
|
await page.locator('button:has-text("create")').click();
|
||||||
|
const link = await page.getByTestId("share-link").inputValue();
|
||||||
|
|
||||||
|
// Navigate to the note and reveal it
|
||||||
|
await page.goto("/");
|
||||||
|
await page.goto(link);
|
||||||
|
await page.getByTestId("show-note-button").click();
|
||||||
|
|
||||||
|
// Verify image preview is visible
|
||||||
|
await expect(page.locator("img.preview")).toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
// Download and verify checksum
|
||||||
|
const [download] = await Promise.all([
|
||||||
|
page.waitForEvent("download"),
|
||||||
|
page.locator('[data-testid="result"] button').first().click(),
|
||||||
|
]);
|
||||||
|
const path = await download.path();
|
||||||
|
if (!path) throw new Error("Download failed");
|
||||||
|
const cs = await getFileChecksum(path);
|
||||||
|
expect(cs).toBe(checksum);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("@web", () => {
|
||||||
|
for (const path of ["/", "/about", "/note/does-not-exist"]) {
|
||||||
|
test(`serves ${path} with status 200`, async ({ request }) => {
|
||||||
|
const response = await request.get(path);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("api still reports a missing note as 404", async ({ request }) => {
|
||||||
|
const response = await request.get("/api/notes/does-not-exist");
|
||||||
|
expect(response.status()).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-7
@@ -1,10 +1,9 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import shelljs from 'shelljs'
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
import { execSync } from 'node:child_process'
|
import { execSync } from 'node:child_process'
|
||||||
|
|
||||||
const VERSION = process.argv[2]
|
const VERSION = process.argv[2]
|
||||||
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
|
|
||||||
const semver =
|
const semver =
|
||||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm
|
||||||
if (!semver.test(VERSION)) {
|
if (!semver.test(VERSION)) {
|
||||||
@@ -12,9 +11,12 @@ if (!semver.test(VERSION)) {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI
|
function sed(file, pattern, replacement) {
|
||||||
shelljs.sed('-i', /"version": ".*"/, `"version": "${process.argv[2]}"`, './packages/cli/package.json')
|
const content = readFileSync(file, 'utf-8')
|
||||||
|
writeFileSync(file, content.replace(pattern, replacement))
|
||||||
|
}
|
||||||
|
|
||||||
// Backend
|
sed('./packages/cli/package.json', /"version": ".*"/, `"version": "${VERSION}"`)
|
||||||
shelljs.sed('-i', /^version = ".*"$/m, `version = "${process.argv[2]}"`, './packages/backend/Cargo.toml')
|
sed('./packages/backend/Cargo.toml', /^version = ".*"$/m, `version = "${VERSION}"`)
|
||||||
execSync('cargo check -p cryptgeon', { cwd: './packages/backend' })
|
|
||||||
|
execSync('cargo check -p cryptgeon', { cwd: './packages/backend' })
|
||||||
Reference in New Issue
Block a user