Compare commits

..
288 Commits
Author SHA1 Message Date
cupcakearmy b58bf8af6d feat: move calculation intensive part into a web worker 2026-09-13 21:51:13 +02:00
cupcakearmy a57ead61b9 fix(web): snapshot files before worker transfer (Svelte proxy not cloneable) 2026-09-13 21:17:24 +02:00
cupcakearmy 0d597edfee fix(web): re-add FileDTO import; run svelte-check in CI 2026-09-13 16:19:41 +02:00
cupcakearmy 7653409473 chore: bump to 3.0.0-rc.3 2026-09-12 16:13:41 +02:00
cupcakearmy f05707e033 update deps 2026-09-12 16:13:02 +02:00
cupcakearmy 8df6b6c7b5 docs: use v3 image tag instead of latest in compose 2026-09-12 15:56:00 +02:00
cupcakearmy 2dc9d6aa16 chore: bump to 3.0.0-rc.2 2026-09-12 15:32:32 +02:00
cupcakearmy 98c56f6fb9 fix(cli): replace removed run-s in prepublishOnly 2026-09-12 15:32:02 +02:00
cupcakearmy 3f7bb6f32e chore: bump to 3.0.0-rc.1 2026-09-12 15:29:49 +02:00
cupcakearmy 6de97039d3 ci: publish with --no-git-checks and drop done cli-deps todo 2026-09-12 15:27:29 +02:00
cupcakearmy a0bca0a1a7 chore(cli): move all deps to devDependencies (bundled into single output) 2026-09-12 15:17:56 +02:00
cupcakearmy 41d0f0bfa2 chore: bump to 3.0.0-rc.0 and publish npm prereleases under rc tag 2026-09-12 15:10:51 +02:00
cupcakearmy ba7669514e Delete Cryptgeon.postman_collection.json 2026-09-12 15:05:36 +02:00
cupcakearmy 8a3831fd05 chore: formatting 2026-09-12 15:04:04 +02:00
cupcakearmy 56f8498530 docs: drop deprecated compose version tag 2026-09-12 15:03:15 +02:00
cupcakearmy c3c6e96774 docs(examples): rename cache service in compose examples to match CACHE env 2026-09-12 15:02:20 +02:00
cupcakearmy b5c5629cca chore: formatting 2026-09-12 15:01:47 +02:00
cupcakearmy 74f387f920 docs: align ES and zh readmes with v3 (cache, healthz, msgpack, env) 2026-09-12 15:00:53 +02:00
cupcakearmy b7c20d6f59 fix(web): drop stale base64 overhead from max size display 2026-09-12 14:59:16 +02:00
cupcakearmy b0737030b2 docs: correct v2.9.3 docker registry changelog entry 2026-09-12 14:55:48 +02:00
cupcakearmy 918640e54c docs: expand changelog with missing releases and add v3 unreleased notes 2026-09-12 14:51:56 +02:00
cupcakearmy 7a68422d67 docs: fold todo into roadmap and unify naming 2026-09-12 14:45:17 +02:00
cupcakearmy 8162db4364 docs: replace v3 plan with roadmap and drop stale plan file 2026-09-12 14:40:14 +02:00
cupcakearmy f36ae73b42 fix(web): pass FileDTO array directly to packContent (files already byte-converted) 2026-09-12 11:06:09 +02:00
cupcakearmy 2ded578141 Merge branch 'v3' of github.com:cupcakearmy/cryptgeon into v3
# Conflicts:
#	packages/cli/src/actions/upload.ts
#	packages/frontend/src/lib/views/Create.svelte
#	packages/shared/src/payload.test.ts
#	packages/shared/src/payload.ts
2026-09-12 10:06:12 +02:00
cupcakearmy 82900adef8 refactor: extract payload pack/unpack pipeline into @cryptgeon/shared 2026-09-12 10:01:49 +02:00
cupcakearmy 8871a6b90d refactor: extract payload pack/unpack pipeline into @cryptgeon/shared 2026-09-12 09:57:54 +02:00
cupcakearmy 77b7ae1de6 fix(ci): point CLI tests to cli.mjs output and drop removed buildx install input 2026-09-06 23:03:38 +02:00
cupcakearmy be617383f4 fix(ci): drop test:dl-browsers from test:prepare (root-only script broke CI) 2026-09-06 22:54:15 +02:00
cupcakearmy 3ff1f8202b ci: add rust+shared tests, cli-only build, bump action versions 2026-09-06 22:50:24 +02:00
cupcakearmy e81dde63ff chore: remove all lokalise references and badge 2026-09-06 22:46:46 +02:00
cupcakearmy 4f5f829c81 test: add shared api client tests and remove dead backend code 2026-09-06 22:46:40 +02:00
cupcakearmy c78845ca7a docs: add vite-plus tooling todo 2026-09-06 22:29:12 +02:00
cupcakearmy dd213d6b41 chore(web): remove lokalise tooling and scripts 2026-09-06 22:29:07 +02:00
cupcakearmy 849ab7e4c5 chore: upgrade to typescript 7 with tsconfig strictest 2026-09-06 22:29:02 +02:00
cupcakearmy 6a4d40e7be refactor(cli): build with vite-plus instead of tsup 2026-09-06 22:28:58 +02:00
cupcakearmy 2f4ebb90e6 build: add vite-plus, typescript 7 and tsconfig strictest catalog deps 2026-09-06 22:28:55 +02:00
cupcakearmy e0d22283c8 v3 2026-09-06 18:34:07 +02:00
cupcakearmy 062054f450 first v3 2026-07-12 11:36:01 +02:00
cupcakearmy 1f180a2e53 Merge pull request #213 from cupcakearmy/feat/redis-key-prefix
feat: add REDIS_PREFIX env var for sharing a Redis instance
2026-06-25 22:30:59 +02:00
cupcakearmy 6b29c6f069 feat: add REDIS_PREFIX env var for shared Redis instance namespace 2026-06-25 21:21:17 +01:00
cupcakearmy 40f3559533 chore:bump version 2026-06-25 21:01:10 +01:00
cupcakearmy ac686e1935 Merge pull request #209 from unambient/typo-fix
fix typo in localization key
2026-06-25 21:42:38 +02:00
cupcakearmy 2cb984405f Merge pull request #208 from fwa-wup/main
fix #207
2026-06-25 21:40:46 +02:00
maddie 8a000ce131 change localization instances of note_to_big to note_too_big 2026-06-09 10:43:05 +00:00
Fabian W 38987efc8a fix #207 2026-06-09 12:17:58 +02:00
cupcakearmy c3e475c16d Merge pull request #203 from smeinecke/feat/image-paste
feat: add image paste support
2026-06-07 14:20:28 +02:00
cupcakearmy 5ff4d42aa6 fix test 2026-06-07 14:12:02 +02:00
cupcakearmy 9c4fbc30f9 cleanup 2026-06-07 13:53:38 +02:00
cupcakearmy f55925de86 Merge branch 'main' into feat/image-paste 2026-06-07 12:54:32 +02:00
cupcakearmy afbe6aac0f Merge pull request #204 from smeinecke/fix/various
fix: multiple minor fixes (#198, #188, #190, #200)
2026-06-07 12:53:46 +02:00
cupcakearmy e14abeee74 replace license checker package 2026-06-07 11:15:35 +02:00
Stefan Meinecke f5823fb533 fix: clean up whitespace and add alt text to pasted images
Remove trailing whitespace throughout the Create.svelte component and add missing alt attribute to pasted image previews for better accessibility.
2026-06-01 02:35:57 +02:00
Stefan Meinecke 3e3d528d5a feat: add image paste support
Add image paste functionality allowing users to paste images directly
from the clipboard as files. Uses the standard DataTransfer API
(clipboardData.items) for cross-browser compatibility.

On the download page, images are now displayed as previews in addition
to the download option.

Also fix Rust 2024 never-type-fallback compatibility by adding turbofish
type annotations to redis crate calls.
2026-06-01 02:35:57 +02:00
Stefan Meinecke 84ec85e96f docs: add THEME_HOME_LINK environment variable to README files 2026-06-01 02:33:52 +02:00
Stefan Meinecke 905bce0072 fix https://github.com/cupcakearmy/cryptgeon/issues/200 2026-06-01 02:33:35 +02:00
Stefan Meinecke e8c3b53e49 fix: multiple minor fixes (#198, #188, #190)
- https://github.com/cupcakearmy/cryptgeon/issues/198: Replace abandoned license-checker with license-checker-evergreen
- https://github.com/cupcakearmy/cryptgeon/issues/188: Fix Polish locale number formatting by using {n} instead of # in ICU plural forms
- https://github.com/cupcakearmy/cryptgeon/issues/190: Add THEME_HOME_LINK env var to conditionally hide the /home link (defaults to true)
- Fix Rust 2024 never-type-fallback compatibility in store.rs
2026-06-01 02:33:24 +02:00
cupcakearmy d40bbd25ea Merge pull request #180 from ShiroDN/main
Add czech translation
2026-06-01 00:00:27 +02:00
cupcakearmy 339c3ac445 version bump 2026-05-31 23:59:01 +02:00
cupcakearmy e27915db06 Merge branch 'main' into main 2026-05-31 23:58:09 +02:00
cupcakearmy d400cfc8e6 Merge pull request #205 from cupcakearmy/maintenance/2026-05-31
maintenance/2026 05 31
2026-05-31 23:51:42 +02:00
cupcakearmy 198accddeb Merge branch 'main' into main 2026-05-31 23:50:44 +02:00
cupcakearmy 3cbe9fabf2 update docker image 2026-05-31 23:44:28 +02:00
cupcakearmy 2923aab916 cleanup docs 2026-05-31 23:41:02 +02:00
cupcakearmy b5102dc647 cleanup docs 2026-05-31 23:40:48 +02:00
cupcakearmy 12d8b87cc1 cleanup readme 2026-05-31 23:31:36 +02:00
cupcakearmy f42662812f cleanup translations 2026-05-31 23:31:31 +02:00
cupcakearmy a551b16216 frontend cleanup 2026-05-31 23:24:55 +02:00
cupcakearmy 09840dcf0a frontend cleanup 2026-05-31 23:24:46 +02:00
cupcakearmy 24e99b84e0 update gihub action 2026-05-31 22:57:28 +02:00
cupcakearmy 690b955d5d watchexec & breaking changes in axum 0.8 2026-05-31 22:55:11 +02:00
cupcakearmy 9b0155dc9a use mise 2026-05-31 22:24:30 +02:00
cupcakearmy 0a56c4c572 maintenance 2026-05-31 22:24:20 +02:00
cupcakearmy f6bf8c656c Merge pull request #202 from smeinecke/update/redis-to-valkey
Replace Redis with Valkey in docker-compose files
2026-05-31 21:40:46 +02:00
Stefan Meinecke 1a243cc96a Replace Redis with Valkey in docker-compose files and fix Rust 2024 compat
Swap redis:7-alpine images to valkey/valkey:7-alpine across all
docker-compose files and example READMEs. Keep service name as
"redis" so that the default REDIS=redis://redis/ connection string
still resolves correctly in Docker networking.

Also add turbofish type annotations to redis crate calls in store.rs
for Rust 2024 never-type-fallback compatibility, and fix a typo
("notion" -> "note") in an error message.
2026-05-12 18:40:41 +02:00
cupcakearmy 482795dd9a Merge pull request #181 from cupcakearmy/dependabot/cargo/packages/backend/cargo-362f336499
Bump ring from 0.16.20 to 0.17.12 in /packages/backend in the cargo group across 1 directory
2025-03-08 10:34:30 +01:00
dependabot[bot] 2907e7c002 Bump ring in /packages/backend in the cargo group across 1 directory
Bumps the cargo group with 1 update in the /packages/backend directory: [ring](https://github.com/briansmith/ring).


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

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

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


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

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

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

---

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


Updates `vite` from 5.1.4 to 5.1.7
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.1.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.7/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-03 17:38:45 +00:00
cupcakearmy 19bf155653 Merge pull request #120 from MairusuPawa/patch-1
Update fr.json
2024-03-24 20:57:00 +01:00
MairusuPawa 9a4e84db62 Update fr.json
Original translation confuses "crypter" and "chiffrer".
2024-03-23 16:19:29 +01:00
cupcakearmy 32cd3843a7 version bump 2024-03-21 15:55:21 +01:00
cupcakearmy 9b48d39c82 Merge pull request #119 from compasspathways/main
Misc improvements including `ALLOW_FILES` and `NEW_NOTE_NOTICE`
2024-03-19 16:04:04 +01:00
Tamás Kádár 239e950f8e NEW_NOTE_NOTICE -> THEME_NEW_NOTE_NOTICE 2024-03-19 12:06:46 +00:00
Tamás Kádár b00846ce9d clicking the logo after the creation of the note should do a reset 2024-03-15 14:30:55 +00:00
Tamás Kádár e70f06f99f NEW_NOTE_NOTICE feature flag 2024-03-15 14:26:33 +00:00
Tamás Kádár 4781882c72 fix extra space in /about 2024-03-15 14:19:30 +00:00
Tamás Kádár 549dfb55db update @types/node 2024-03-15 14:16:02 +00:00
Tamás Kádár 2653a4bccf ALLOW_FILES flag 2024-03-15 14:14:17 +00:00
Tamás Kádár 7213e6c690 Minor Dockerfile fixes 2024-03-15 14:11:34 +00:00
Tamás Kádár 8a5f667ff6 Fix file-saver CommonJS error 2024-03-15 13:53:12 +00:00
cupcakearmy fc3938701e add reset translation 2024-03-04 18:46:00 +01:00
cupcakearmy 23b4f81dac Merge pull request #118 from codiflow/codiflow-patch-1
Update de.json
2024-03-04 18:42:39 +01:00
cupcakearmy 7c68620d8b Merge branch 'main' into codiflow-patch-1 2024-03-04 18:32:46 +01:00
cupcakearmy eb76fe085a rm pkg and update node version 2024-03-04 18:32:03 +01:00
Christian 38540b33f2 Update de.json
Fixed some translations in the German language strings
2024-03-04 17:39:35 +01:00
cupcakearmy 39a9ac0dad version bump 2024-03-04 15:25:02 +01:00
cupcakearmy ff1b5d500b Merge pull request #117 from cupcakearmy/also-expose-api-methods-for-programmatic-usage
Also expose api methods for programmatic usage
2024-03-04 15:24:02 +01:00
cupcakearmy 1698abe2eb pipeline 2024-03-04 15:15:47 +01:00
cupcakearmy 3036927a45 update dependencies 2024-03-03 01:50:52 +01:00
cupcakearmy f9c26ba81c change entry point for cli test 2024-03-03 01:35:17 +01:00
cupcakearmy 752e68e213 revert to node 18 2024-03-03 01:34:08 +01:00
cupcakearmy 6eb3a59e33 use package.json pnpm version 2024-03-03 01:34:03 +01:00
cupcakearmy 1a2728d21f also expose internal shared functionality for external usage 2024-03-03 01:22:39 +01:00
cupcakearmy a37a0932e0 translations 2024-02-26 17:19:29 +01:00
cupcakearmy 71a33a7939 Merge pull request #111 from cupcakearmy/dependabot/cargo/packages/backend/zerocopy-0.7.31
Bump zerocopy from 0.7.21 to 0.7.31 in /packages/backend
2024-02-09 14:47:47 +01:00
dependabot[bot] 83033a4b85 Bump zerocopy from 0.7.21 to 0.7.31 in /packages/backend
Bumps [zerocopy](https://github.com/google/zerocopy) from 0.7.21 to 0.7.31.
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/compare/v0.7.21...v0.7.31)

---
updated-dependencies:
- dependency-name: zerocopy
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-15 03:50:33 +00:00
cupcakearmy 40570bbbaf Merge pull request #108 from codiflow/patch-1
Updated German language strings
2023-12-13 21:22:44 +01:00
Christian f591e589d0 Updated German language strings 2023-12-04 16:01:09 +01:00
cupcakearmy d1eebb04f3 Merge pull request #107 from pgschk/locale-fixes
Fixes to locales en and de
2023-11-08 13:49:13 +01:00
pgschk 5a76ea7778 Update locale de
Fix some translation errors
2023-11-01 23:29:02 +01:00
pgschk 45a1af7569 Fix locale en
Fix spelling: to -> too, it's -> its
2023-11-01 23:18:58 +01:00
cupcakearmy 9578b2bed2 Merge pull request #106 from cupcakearmy/2.4.0
2.4.0
2023-11-01 15:57:12 +01:00
cupcakearmy f94e4e3858 sparse bundler is enabled by default now 2023-11-01 15:26:55 +01:00
cupcakearmy ffa2c49ea3 don't insert html anymore, use separate link section 2023-11-01 15:26:45 +01:00
cupcakearmy 0d850aadfc update deps 2023-11-01 15:26:33 +01:00
cupcakearmy a9c646c981 update packages 2023-11-01 15:26:20 +01:00
cupcakearmy a2803a13da update deps 2023-08-14 10:22:09 +02:00
cupcakearmy deef56776d maintenance 2023-08-14 10:12:26 +02:00
cupcakearmy b089323990 Merge pull request #97 from MVS/grammar-patch
Update en.json
2023-08-04 09:51:17 +02:00
MVS 6002ede685 Update en.json
Small tweaks to grammar
2023-08-03 12:07:50 -05:00
cupcakearmy 8a444ceb88 Merge pull request #95 from cbin/patch-1
Update ru.json
2023-07-19 16:59:50 +02:00
Oleg Salnikov 1e01ccb65a Update ru.json
minor changes
2023-07-18 20:59:24 +03:00
cupcakearmy 2dfa9dd248 Merge pull request #94 from Rooyca/main
Spanish translation of about page
2023-07-13 14:37:59 +02:00
Ronald 618e914b55 Add README_ES.md 2023-07-09 14:09:10 -05:00
Ronald 86f596fa4b Add README_ES.md 2023-07-09 14:07:40 -05:00
Ronald dcb4613f66 Create README_ES.md 2023-07-09 14:05:59 -05:00
cupcakearmy c46f80aaa0 Merge pull request #93 from cupcakearmy/feat/92-health-check
Feat/92 health check
2023-06-23 10:34:18 +02:00
cupcakearmy e2711cc887 add healthcheck endpoint and startup check 2023-06-23 10:17:13 +02:00
cupcakearmy e02224216a add changelog 2023-06-23 10:16:28 +02:00
cupcakearmy 1b0d5449a0 update postman collection 2023-06-23 10:16:13 +02:00
cupcakearmy 9695d3a63f version bumps 2023-06-23 10:16:03 +02:00
cupcakearmy 22d4efb03e add healthcheck examples 2023-06-23 10:15:31 +02:00
cupcakearmy 97741ed73f add curl for health check 2023-06-23 10:15:14 +02:00
cupcakearmy c9e5de0f37 about page spacing 2023-06-02 23:51:54 +02:00
cupcakearmy dc1c03d912 Merge pull request #90 from cupcakearmy/feature/52-Add-note-id-size-option
feat: add note id size option
2023-05-30 10:31:12 +02:00
cupcakearmy 2a75acae3f docs 2023-05-30 09:43:41 +02:00
cupcakearmy 815ac4e8ba changelog 2023-05-30 09:43:31 +02:00
cupcakearmy 7c85c1e621 version bump 2023-05-30 09:43:26 +02:00
cupcakearmy a323d48c41 feat: add note id size option 2023-05-29 16:34:59 +02:00
cupcakearmy 2bff6a37db add some metadata 2023-05-26 01:10:22 +02:00
cupcakearmy f8223dfc62 enable sparse bundle 2023-05-26 00:21:50 +02:00
cupcakearmy 063d073c27 fix pipeline 2023-05-25 23:54:59 +02:00
cupcakearmy ac32b97383 Merge pull request #89 from cupcakearmy/69/password
69/password
2023-05-25 23:47:08 +02:00
cupcakearmy 9c9c23d958 version bump 2023-05-25 23:29:09 +02:00
cupcakearmy 92893a5b2d github actions 2023-05-25 23:29:05 +02:00
cupcakearmy ac68f4a540 docs 2023-05-25 19:06:07 +02:00
cupcakearmy 83b2fa5372 version bump 2023-05-25 18:15:31 +02:00
cupcakearmy 3c86f3f3be update pnpm version 2023-05-25 18:15:18 +02:00
cupcakearmy 80e64ad207 fix types 2023-05-25 18:15:05 +02:00
cupcakearmy a5809c216c fix scripts 2023-05-25 10:17:08 +02:00
cupcakearmy fb95a68b0d test files in cli and cross with password 2023-05-25 10:17:01 +02:00
cupcakearmy b43b802221 add --all option, stdin and password option 2023-05-25 10:16:44 +02:00
cupcakearmy 2e89007c83 add test ids 2023-05-25 10:16:12 +02:00
cupcakearmy fdc2722fb9 add password to frontend 2023-05-23 09:39:19 +02:00
cupcakearmy 6da28a701e translations 2023-05-23 09:39:06 +02:00
cupcakearmy e6d1e0f44a add password to CLI 2023-05-23 09:39:00 +02:00
cupcakearmy 6000553b95 include meta in info endpoint 2023-05-23 09:38:23 +02:00
cupcakearmy 85204776d7 demo postman collection 2023-05-23 09:38:00 +02:00
cupcakearmy c2bfe9dd0d add derivation to metadata 2023-05-23 09:37:49 +02:00
cupcakearmy b05841a562 add timeout 2023-05-23 09:37:33 +02:00
cupcakearmy d7e5a34b14 CLI (#84)
* move to packages

* update deps

* update deps

* actions maintenance

* don't use blob

* cli

* fix default import

* use synthetic default imports

* remove comment

* cli packaging

* node 18 guard

* packages

* build system

* testing

* test pipeline

* pipelines

* changelog

* version bump

* update locales

* update deps

* update deps

* update dependecies
2023-05-14 13:52:47 +02:00
cupcakearmy 13dfd933af Add tutorial (#83) 2023-05-13 08:41:05 +02:00
Oleg Salnikov 74840416f1 locales (#85) 2023-05-13 08:40:50 +02:00
luolongfei 9aaad5b910 update zh.json file. (#79) 2023-03-02 09:55:50 +01:00
dependabot[bot] c246207420 Bump tokio from 1.24.1 to 1.25.0 in /packages/backend (#75)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.24.1 to 1.25.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.24.1...tokio-1.25.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-13 19:21:06 +01:00
cupcakearmy 7ee1b8370a update deps 2023-01-14 18:54:27 +01:00
cupcakearmy e7750699cc chose: changelog 2023-01-14 18:40:45 +01:00
cupcakearmy e14042ea28 chinese language code 2023-01-14 18:40:16 +01:00
cupcakearmy 6fb7518b6a moved to occulto 2023-01-13 21:24:27 +01:00
cupcakearmy 436ae2a7e5 move dev ports 2023-01-13 19:36:26 +01:00
cupcakearmy fe5ce580ab locales 2023-01-04 19:45:25 +01:00
cupcakearmy 0f882da5d1 bump version 2023-01-04 19:45:18 +01:00
cupcakearmy ad6f136dd0 qr code 2023-01-04 19:40:37 +01:00
cupcakearmy da527a0857 2.0.7 2022-12-26 22:54:47 +01:00
cupcakearmy a95931ae77 update deps 2022-12-26 18:00:00 +01:00
cupcakearmy d6c2236673 update deps 2022-12-26 12:59:37 +01:00
cupcakearmy 42a8ab5d0f test command 2022-11-15 13:15:34 +01:00
cupcakearmy 0934808a59 testing 2022-11-15 13:05:13 +01:00
cupcakearmy 88ea828b66 upload 2022-11-15 12:35:02 +01:00
cupcakearmy 41ed5c0e23 ci 2022-11-15 12:17:52 +01:00
cupcakearmy 0a98481991 use npx 2022-11-15 12:13:03 +01:00
cupcakearmy 5d62c48a35 don't use pnpm 2022-11-14 16:26:51 +01:00
cupcakearmy 0ab39023b0 test 2022-11-14 16:16:50 +01:00
cupcakearmy 7b202962e8 testing 2022-11-14 15:55:49 +01:00
cupcakearmy 7a045b3f34 test on docker image 2022-11-14 15:47:12 +01:00
cupcakearmy cb80c8bfe4 changelog 2022-11-12 14:40:21 +01:00
cupcakearmy 74c3197e47 update dependencies and fix some a11y issues 2022-11-12 14:40:17 +01:00
cupcakearmy 6ae927ce71 update version and dependencies 2022-11-12 13:55:33 +01:00
cupcakearmy 9d13e607f5 #66 set minimum 2022-11-12 13:42:09 +01:00
cupcakearmy 0db3ef4a1f changelog and only test on x86 2022-11-04 23:34:36 +01:00
151 changed files with 9653 additions and 4340 deletions
+12 -12
View File
@@ -1,15 +1,15 @@
* *
!/packages/backend/src !/packages
!/packages/backend/Cargo.lock !/package.json
!/packages/backend/Cargo.toml !/pnpm-lock.yaml
!/pnpm-workspace.yaml
!/packages/frontend/locales **/target
!/packages/frontend/src **/node_modules
!/packages/frontend/static **/dist
!/packages/frontend/.npmrc **/bin
!/packages/frontend/package.json **/*.tsbuildinfo
!/packages/frontend/pnpm-lock.yaml **/build
!/packages/frontend/svelte.config.js **/.svelte
!/packages/frontend/tsconfig.json **/.svelte-kit
!/packages/frontend/vite.config.js
+2
View File
@@ -0,0 +1,2 @@
SIZE_LIMIT=10MiB
VERBOSITY=debug
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

-38
View File
@@ -1,38 +0,0 @@
name: Publish
on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
install: true
- name: Docker Labels
id: meta
uses: crazy-max/ghaction-docker-meta@v2
with:
images: cupcakearmy/cryptgeon
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
+61
View File
@@ -0,0 +1,61 @@
name: Publish
on:
workflow_dispatch:
push:
tags:
- 'v*.*.*'
jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
cache: 'pnpm'
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
- run: |
pnpm install
pnpm --filter cryptgeon build
- name: Publish to npm
run: |
DIST_TAG=latest
if [[ "${GITHUB_REF_NAME}" == *"-"* ]]; then
DIST_TAG=rc
fi
pnpm publish --filter cryptgeon --tag "${DIST_TAG}" --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- name: Docker Labels
id: meta
uses: docker/metadata-action@v6
with:
images: cupcakearmy/cryptgeon
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Login to DockerHub
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
+29 -39
View File
@@ -7,52 +7,42 @@ on:
pull_request: pull_request:
jobs: jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
arch: ["linux/amd64", "linux/arm64"]
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
install: true
- name: Build and push
uses: docker/build-push-action@v2
with:
platforms: ${{ matrix.arch }}
push: false
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v7
- uses: actions/setup-node@v2
# Node
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with: with:
node-version: "16" cache: 'pnpm'
- uses: pnpm/action-setup@v2 node-version-file: '.nvmrc'
with:
version: 7 # Docker
- uses: actions-rs/toolchain@v1 - uses: docker/setup-qemu-action@v4
with: - uses: docker/setup-buildx-action@v4
toolchain: 1.64
- name: Prepare - name: Prepare
run: | run: |
pnpm install pnpm install
pnpm exec playwright install --with-deps
pnpm run test:prepare pnpm run test:prepare
- name: Install Playwright
run: npx playwright install --with-deps - 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 run test:run run: pnpm test
- name: Upload test results
if: always() - uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v2 if: ${{ !cancelled() }}
with: with:
name: test-results name: playwright-report
path: test-results path: test-results/
retention-days: 7
+7 -10
View File
@@ -1,14 +1,11 @@
.env
*.tsbuildinfo
node_modules
dist
bin
# Backend
target target
# Client # Testing
.DS_Store
node_modules
/.svelte
/build
/functions
.env
General
test-results test-results
tmp
+1
View File
@@ -0,0 +1 @@
v26
-6
View File
@@ -1,6 +0,0 @@
{
"cSpell.words": ["ciphertext", "cryptgeon"],
"i18n-ally.localesPaths": ["packages/frontend/locales"],
"i18n-ally.enabledFrameworks": ["svelte"],
"i18n-ally.keystyle": "nested"
}
+265 -3
View File
@@ -5,12 +5,274 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] — v3 (major rewrite)
### Added
- New shared TypeScript package `@cryptgeon/shared` as single source of truth for crypto, content codec and API client (crypto + compression + payload + types).
- Shared payload codec: `packContent` / `unpackContent` (encode → LZ4 → XChaCha20-Poly1305 and reverse).
- New `pg`-backend storage of note hashes in the cache.
### Changed
- Encryption from AES to **XChaCha20-Poly1305** (client-side); dropped `occulto`.
- All API bodies switched to **MessagePack**.
- Frontend migrated to SvelteKit + `@cryptgeon/shared`.
- CLI rebuilt with `vite-plus` (bundles all deps) and imports from `@cryptgeon/shared`.
### Breaking changes
- Endpoints moved to `/api/v3/notes/` and `/api/v3/status`; health check to `/healthz`.
- `meta.extra` holds client-opaque data (e.g. scrypt derivation params), size-limited (`EXTRA_SIZE_LIMIT`, default 512 bytes).
- Inner payload is msgpack: `{ type: "text", data }` or `{ type: "files", data: [{ name, mime, size, data }] }`.
- Env renames: `REDIS` → `CACHE`, `REDIS_PREFIX` → `CACHE_PREFIX`; new `EXTRA_SIZE_LIMIT`.
- Docker `redis` service → `cache`; healthcheck → `http://127.0.0.1:8000/healthz`; image stays `valkey/valkey:7-alpine` (swap for any RESP-compatible).
- Storage switched to cache hashes with atomic `HINCRBY` view counting; the per-note lock (`lock.rs`) is removed.
- Notes can have **both** `views` and `expiration` set simultaneously.
- v2 notes are **not migrated**: flush the cache before deploying v3; v2/v3 notes are not interoperable.
## [2.9.3] - 2026-06-25
### Added
- Basic file drag-and-drop support.
### Changed
- Publish the Docker image to GitHub Container Registry (ghcr).
### Fixed
- #207: keep audio/other file mime types intact.
- Localization key typo `note_to_big` → `note_too_big`.
## [2.9.2] - 2026-06-07
### Added
- Image paste support.
- Czech translation.
- `THEME_HOME_LINK` environment variable.
- Docker compose: prevent anonymous volume creation.
### Changed
- Replace Redis with Valkey in docker-compose files.
- Rust 2024 edition compat, watchexec and axum 0.8 updates.
- Switched license checker package.
- Frontend cleanup and readme/docs cleanup.
### Security
- Updated dependencies (ring, npm_and_yarn group).
## [2.9.1] - 2025-02-27
### Added
- Docs about running Redis in RAM-only mode.
### Fixed
- Password eye toggle not working.
### Security
- Updated dependencies.
## [2.9.0] - 2025-01-18
### Changed
- Frontend rework: migrate to Svelte 5.
- Update Redis documentation link in compose.
### Fixed
- Fix race condition on the delete endpoint by introducing locks to guarantee the view counter.
## [2.8.4] - 2025-01-02
### Added
- Chinese (zh-TW) translations.
- Basic auth example (nginx).
## [2.8.3] - 2024-09-27
### Added
- Options to add an imprint: `IMPRINT_URL`, `IMPRINT_HTML`.
## [2.8.2] - 2024-09-20
### Added
- Raycast extension links.
### Changed
- Add `type="button"` to form elements.
- Bump pnpm version.
## [2.8.1] - 2024-09-02
### Changed
- Move shared package into the CLI.
- Add a guide.
## [2.8.0] - 2024-08-27
### Changed
- Migrate backend from actix to axum (major refactor).
- More robust config, body limit via axum.
- Use container for test pipeline; skip size/expiration quirks in Safari.
### Fixed
- Typos in English localization.
## [2.7.0] - 2024-08-23
### Added
- Better programmatic access to the shared client.
- Redis TLS feature, dynamically-linked and native musl targets.
- French blog post and improved French translations.
### Changed
- Bump redis crate to 0.25.2.
## [2.6.1] - 2024-05-04
### Added
- Polish translation.
## [2.6.0] - 2024-03-24
### Added
- `ALLOW_FILES` flag.
- `NEW_NOTE_NOTICE` → `THEME_NEW_NOTE_NOTICE` theme flag.
- French translation update.
### Changed
- Reset form when clicking the logo after creating a note.
## [2.5.1] - 2024-03-04
### Changed
- Reset translation.
- German (`de`) translation update.
## [2.5.0] - 2024-03-04
### Added
- Expose internal shared functionality for external/programmatic usage.
- German translation updates.
### Security
- Updated dependencies (zerocopy).
## [2.4.0] - 2023-11-01
### Changed
- Removed HTML sanitation, display the original message as string
- 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
### Added
- #92: Endpoint (`/api/live/`) for checking health status.
## [2.3.0] - 2023-05-30
### Added
- New CLI 🎉.
- Russian language.
- Option for reducing note id size (`ID_LENGTH`).
### Changed
- Moved to monorepo.
## [2.2.0] - 2023-01-14
### Changed
- Default port is now 8000, not 5000.
- Moved to generic encryption library `occulto`.
### Fixed
- Bad chinese language code.
### Security
- Updated dependencies.
## [2.1.0] - 2023-01-04
### Added
- QR Code to more easily copy and share links.
## [2.0.7] - 2022-12-26
### Changed
- Svelte Kit now stable 🎉
## [2.0.6] - 2022-11-12
### Fixed
- #66 Set minimum a view.
### Security
- Updated dependencies.
## [2.0.5] - 2022-11-04
### Fixed
- Docker build pipeline.
## [2.0.4] - 2022-10-29 ## [2.0.4] - 2022-10-29
### Added ### Added
- `THEME_PAGE_TITLE` - `THEME_PAGE_TITLE`.
- `THEME_FAVICON` - `THEME_FAVICON`.
## [2.0.3] - 2022-10-07 ## [2.0.3] - 2022-10-07
@@ -28,7 +290,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Toasts for events. - Toasts for events.
- E2E Tests. - E2E Tests.
- Make backend more configurable - Make backend more configurable.
## [2.0.1] - 2022-07-18 ## [2.0.1] - 2022-07-18
+47
View File
@@ -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.
+15 -17
View File
@@ -1,32 +1,30 @@
# FRONTEND # FRONTEND
FROM node:16-alpine as client FROM node:24-alpine AS client
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /tmp WORKDIR /tmp
RUN npm install -g pnpm@7 COPY . .
COPY ./packages/frontend ./ RUN pnpm install --frozen-lockfile
RUN pnpm install
RUN pnpm exec svelte-kit sync
RUN pnpm run build RUN pnpm run build
# BACKEND # BACKEND
FROM rust:1.64-alpine as backend FROM rust:1.95-alpine AS backend
WORKDIR /tmp WORKDIR /tmp
RUN apk add libc-dev openssl-dev alpine-sdk RUN apk add --no-cache libc-dev openssl-dev alpine-sdk
COPY ./packages/backend/Cargo.* ./
# https://blog.rust-lang.org/2022/06/22/sparse-registry-testing.html
RUN rustup update nightly
ENV CARGO_UNSTABLE_SPARSE_REGISTRY=true
RUN cargo +nightly fetch
COPY ./packages/backend ./ COPY ./packages/backend ./
RUN cargo +nightly build --release RUN RUSTFLAGS="-Ctarget-feature=-crt-static" cargo build --release
# RUNNER # RUNNER
FROM alpine FROM alpine:3
WORKDIR /app WORKDIR /app
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/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 5000 EXPOSE 8000
ENTRYPOINT [ "/app/cryptgeon" ] ENTRYPOINT [ "/app/cryptgeon" ]
+68 -68
View File
@@ -11,25 +11,41 @@
<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>
<br/><br/> <br/><br/>
EN | [简体中文](README_zh-CN.md) EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md)
## About? ## About?
_cryptgeon_ is a secure, open source sharing note or file service inspired by [_PrivNote_](https://privnote.com) _cryptgeon_ is a secure, open source sharing note or file service inspired by [_PrivNote_](https://privnote.com).
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
### Web
Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org) Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org)
### CLI
```
npx cryptgeon send text "This is a secret note"
```
For more documentation about the CLI see the [readme](./packages/cli/README.md).
### Raycast Extension
There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cryptgeon).
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
## Features ## Features
- send text or files
- server cannot decrypt contents due to client side encryption - server cannot decrypt contents due to client side encryption
- view or time constraints - view or time constraints
- in memory, no persistence - in memory, no persistence
@@ -39,11 +55,13 @@ Check out the live service / demo and see for yourself [cryptgeon.org](https://c
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 cache instance can run into race conditions, where a note might be retrieved more than the view count allows.
## Screenshot ## Screenshot
![screenshot](./design/Screens.png) ![screenshot](./design/Screens.png)
@@ -51,21 +69,31 @@ 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. |
| `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. |
| `CACHE_PREFIX` | `""` | Optional prefix for all cache keys. Useful when sharing a cache instance with other apps via ACL namespaces. |
| `EXTRA_SIZE_LIMIT` | `512` | Maximum size in bytes of the opaque `extra` payload (e.g. key derivation params) stored on the note metadata. |
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` | | `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable | | `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo | | `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| `THEME_PAGE_TITLE` | `""` | Custom text the page title | | `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable | | `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
| `THEME_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 `/healthz`. It returns either 200 or 503.
### Docker ### Docker
@@ -74,24 +102,35 @@ 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
ports: ports:
- 80:5000 - 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s
``` ```
### NGINX Proxy ### NGINX Proxy
@@ -112,66 +151,27 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
### YouTube Guides ### YouTube Guides
- English by [Webnestify](https://www.youtube.com/watch?v=XAyD42I7wyI)
- English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE) - English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
- German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg) - German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
## Development ### Written Guides
**Requirements** - French by [zarevskaya](https://belginux.com/installer-cryptgeon-avec-docker/)
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
- `pnpm`: `>=6` ## Contributing
- `node`: `>=16`
- `rust`: edition `2021`
**Install** See [CONTRIBUTING.md](./CONTRIBUTING.md).
```bash
pnpm install
pnpm --prefix frontend 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.
> If you are on `macOS` you might need to disable AirPlay Receiver as it uses port 5000 (So stupid...)
> https://developer.apple.com/forums/thread/682332
```bash
pnpm run dev
```
Running `pnpm run dev` in the root folder will start the following things:
- redis docker container
- rust backend
- client
You can see the app under [localhost:1234](http://localhost:1234).
## Tests
Tests are end to end tests written with Playwright.
```sh
pnpm run test:prepare
docker compose up redis -d
pnpm run test:server
# In another terminal.
# 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).
###### Attributions ---
_Attributions_
- Test data: - Test data:
- Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/) - Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
+163
View File
@@ -0,0 +1,163 @@
<p align="center">
<img src="./design/Github.png" alt="logo">
</p>
<a href="https://discord.gg/nuby6RnxZt">
<img alt="discord" src="https://img.shields.io/discord/252403122348097536?style=for-the-badge" />
<img alt="docker pulls" src="https://img.shields.io/docker/pulls/cupcakearmy/cryptgeon?style=for-the-badge" />
<img alt="Docker image size badge" src="https://img.shields.io/docker/image-size/cupcakearmy/cryptgeon?style=for-the-badge" />
<img alt="Latest version" src="https://img.shields.io/github/v/release/cupcakearmy/cryptgeon?style=for-the-badge" />
</a>
<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>
<br/><br/>
[EN](README.md) | [简体中文](README_zh-CN.md) | ES
## Acerca de
_cryptgeon_ es un servicio seguro y de código abierto para compartir notas o archivos inspirado en [_PrivNote_](https://privnote.com).
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.
## Demo
### Web
Prueba la demo y experimenta por ti mismo [cryptgeon.org](https://cryptgeon.org)
### CLI
```
npx cryptgeon send text "Esto es una nota secreta"
```
Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/README.md).
## Características
- enviar texto o archivos
- el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente
- restricción de vistas o de tiempo
- en memoria, sin persistencia
- compatibilidad obligatoria con el modo oscuro
## ¿Cómo funciona?
Se genera una <code>id (256bit)</code> y una <code>llave 256(bit)</code> para cada nota. La
<code>id</code>
se usa para guardar y recuperar la nota. Después la nota es encriptada con XChaCha20-Poly1305 del lado del cliente y por último se envía al servidor. La información es almacenada en memoria y nunca persiste en el disco. El servidor nunca ve la llave de encriptación por lo que no puede desencriptar el contenido de las notas aunque lo intentara.
## Capturas de pantalla
![screenshot](./design/Screens.png)
## Variables de entorno
| Variable | Default | Descripción |
| ----------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CACHE` | `redis://cache/` | URL de caché (valkey o redis) a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `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_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_FILES` | `true` | Permitir subir archivos. Si es `false`, los usuarios solo podrán crear notas de texto. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Útil para reducir el tamaño del link. _No afecta el nivel de encriptación_. |
| `CACHE_PREFIX` | `""` | Prefijo opcional para las claves de caché. Útil al compartir una instancia con otras apps vía namespaces ACL. |
| `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. |
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título. |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
| `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
> ℹ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación.
> ℹ️ Hay un endpoint para verificar el estado, lo encontramos en `/healthz`. Regresa un código 200 o 503.
### Docker
Docker es la manera más fácil. Aquí encontramos [la imagen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
```yaml
# docker-compose.yml
services:
cache:
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 1g --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:v3
depends_on:
- cache
environment:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s
```
### NGINX Proxy
Ver la carpeta de [ejemplo/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/nginx). Hay un ejemplo con un proxy simple y otro con https. Es necesario que especifiques el nombre del servidor y los certificados.
### Traefik 2
Ver la carpeta de [ejemplo/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik).
### Scratch
Ver la carpeta de [ejemplo/scratch](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/scratch). Ahí encontrarás una guía de cómo configurar el servidor e instalar cryptgeon desde cero.
### Synology
Hay una [guía](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) (en inglés) que puedes seguir.
### Guías en Youtube
- En inglés, por [Webnestify](https://www.youtube.com/watch?v=XAyD42I7wyI)
- 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)
## Contribuir
Ver [CONTRIBUTING.md](./CONTRIBUTING.md).
## Seguridad
Por favor dirígete a la sección de seguridad [aquí](./SECURITY.md).
---
_Atribuciones_
- Datos del Test:
- Texto para los tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
- [AES Paper](https://www.cs.miami.edu/home/burt/learning/Csc688.012/rijndael/rijndael_doc_V2.pdf)
- [Unsplash Imágenes](https://unsplash.com/)
- Animación de carga por [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa)
- Iconos hechos por <a href="https://www.freepik.com" title="Freepik">freepik</a> de <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
+47 -75
View File
@@ -11,18 +11,15 @@
<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) | 简体中文 [EN](README.md) | 简体中文 | [ES](README_ES.md)
## 关于本项目 ## 关于本项目
_加密鸽_ 是一个受 [_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`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。
## 屏幕截图 ## 屏幕截图
@@ -48,14 +45,25 @@ _加密鸽_ 是一个受 [_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` 时用户只能创建文本密信。 |
| `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_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,20 +77,27 @@ 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:
- 80:5000 - 80:8000
``` ```
### NGINX 反向代理 ### NGINX 反向代理
@@ -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,57 +145,9 @@ services:
- traefik.http.routers.cryptgeon.tls.certresolver=le - traefik.http.routers.cryptgeon.tls.certresolver=le
``` ```
## 开发 ## 贡献
**环境要求** 参见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
- `pnpm`: `>=6`
- `node`: `>=14`
- `rust`: edition `2021`
**安装**
```bash
pnpm install
pnpm --prefix frontend install
# 你还需要安装CargoWatch.
# https://lib.rs/crates/cargo-watch
cargo install cargo-watch
```
**运行**
确保你的 Docker 正在运行
> 如果你用的是 `macOS` 的话你可能需要关闭 AirPlay 接收功能因为该功能需要占用 5000 端口...)
> https://developer.apple.com/forums/thread/682332
```bash
pnpm run dev
```
在根目录执行 `pnpm run dev` 会开启下列服务:
- 一个 redis docker 容器
- 无热重载的 rust 后端
- 可热重载的客户端
你可以通过 1234 端口进入该应用,即 [localhost:1234](http://localhost:1234).
## 测试
这些测试是用 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
+20
View File
@@ -0,0 +1,20 @@
{
"folders": [
{
"path": "."
},
{
"path": "packages/backend"
},
{
"path": "packages/frontend"
},
{
"path": "packages/cli"
}
],
"settings": {
"i18n-ally.localesPaths": ["locales"],
"cSpell.words": ["cryptgeon"]
}
}
+32
View File
@@ -0,0 +1,32 @@
# DEV Compose file.
# For a production file see: README.md
services:
cache:
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:
- 6379:6379
app:
build: .
env_file: .env.dev
depends_on:
- cache
restart: unless-stopped
ports:
- 3000:8000
healthcheck:
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/healthz']
interval: 1m
timeout: 3s
retries: 2
start_period: 5s
+29
View File
@@ -0,0 +1,29 @@
services:
cache:
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:
image: cupcakearmy/cryptgeon:v3
depends_on:
- cache
environment:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s
-20
View File
@@ -1,20 +0,0 @@
# DEV Compose file.
# For a production file see: README.md
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
app:
# build: .
image: cupcakearmy/cryptgeon
depends_on:
- redis
environment:
SIZE_LIMIT: 10 MiB
ports:
- 1234:5000
+76
View File
@@ -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
```
+12 -5
View File
@@ -1,13 +1,20 @@
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
+1 -1
View File
@@ -4,7 +4,7 @@ server {
server_name _; server_name _;
location / { location / {
proxy_pass http://app:5000/; proxy_pass http://app:8000/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+1 -1
View File
@@ -20,7 +20,7 @@ server {
ssl_trusted_certificate /path/to/fullchain.pem; ssl_trusted_certificate /path/to/fullchain.pem;
location / { location / {
proxy_pass http://app:5000/; proxy_pass http://app:8000/;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+24 -22
View File
@@ -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
+61 -8
View File
@@ -2,29 +2,34 @@
Assumptions: Assumptions:
- Traefik 2 installed. - Traefik 2/3 installed.
- External proxy docker network `proxy`. - External proxy docker network `proxy`.
- A certificate resolver `le`. - A certificate resolver `le`.
- A https entrypoint `secure`. - A https entrypoint `secure`.
- 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
@@ -34,3 +39,51 @@ services:
- traefik.http.routers.cryptgeon.entrypoints=secure - traefik.http.routers.cryptgeon.entrypoints=secure
- traefik.http.routers.cryptgeon.tls.certresolver=le - traefik.http.routers.cryptgeon.tls.certresolver=le
``` ```
## With basic auth
Some times it's useful to hide the service behind auth. This is easily achieved with traefik middleware. Many reverse proxies support similar features, so while traefik is used in this example, other reverse proxies can do the same.
```yaml
services:
traefik:
image: traefik:v3.0
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
cache:
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:
image: cupcakearmy/cryptgeon:v3
depends_on:
- cache
labels:
- "traefik.enable=true"
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
- "traefik.http.routers.cryptgeon.entrypoints=web"
- "traefik.http.routers.cryptgeon.middlewares=cryptgeon-auth"
- "traefik.http.middlewares.cryptgeon-auth.basicauth.users=user:$$2y$$05$$juUw0zgc5ebvJ00MFPVVLujF6P.rcEMbGZ99Jfq6ZWEa1dgetacEq"
```
```bash
docker compose up -d
```
1. Open http://cryptgeon.localhost
2. Log in with `user` and `secret`
+11
View File
@@ -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"
+16 -11
View File
@@ -1,18 +1,23 @@
{ {
"scripts": { "scripts": {
"dev:docker": "docker-compose 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:proxy": "node proxy.mjs", "dev": "pnpm --parallel run /dev/",
"dev": "run-p dev:*", "docker:up": "docker compose -f docker-compose.dev.yaml up",
"test:run": "playwright test --project chrome firefox safari", "docker:build": "docker compose -f docker-compose.dev.yaml build",
"test:local": "playwright test --project local", "test": "playwright test --project=chrome --project=firefox --project=safari",
"test:server": "pnpm --parallel run test:server", "test:local": "playwright test --project=chrome",
"test:prepare": "pnpm --parallel run test:prepare" "test:server": "docker compose -f docker-compose.dev.yaml up",
"test:dl-browsers": "playwright install",
"test:prepare": "pnpm run build && pnpm run docker:build",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.25.1", "@playwright/test": "^1.62.1",
"@types/node": "^16.11.57", "@types/node": "^24.13.3"
"http-proxy": "^1.18.1", },
"npm-run-all": "^4.1.5" "packageManager": "pnpm@11.5.0",
"engines": {
"node": ">=22"
} }
} }
+1158 -803
View File
File diff suppressed because it is too large Load Diff
+14 -11
View File
@@ -1,25 +1,28 @@
[package] [package]
name = "cryptgeon" name = "cryptgeon"
version = "2.0.4" version = "3.0.0-rc.3"
authors = ["cupcakearmy <hi@nicco.io>"] authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021" edition = "2024"
rust-version = "1.95"
[[bin]] [[bin]]
name = "cryptgeon" name = "cryptgeon"
path = "src/main.rs" path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
actix-web = "4" # Core
actix-files = "0.6" axum = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["full"] }
redis = { version = "1", features = ["tls-native-tls"] }
# 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"
mime = "0.3"
env_logger = "0.9"
redis = "0.21.5"
+3 -3
View File
@@ -1,10 +1,10 @@
{ {
"name": "backend",
"private": true, "private": true,
"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:1234 cargo run", "test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
"test:prepare": "cargo build" "test:prepare": "cargo build"
} }
} }
-12
View File
@@ -1,12 +0,0 @@
use actix_web::web;
use crate::note;
use crate::status;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api")
.service(note::init())
.service(status::init()),
);
}
-17
View File
@@ -1,17 +0,0 @@
use actix_files::{Files, NamedFile};
use actix_web::{web, Result};
use crate::config;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
Files::new("/", config::FRONTEND_PATH.to_string())
.index_file("index.html")
.use_etag(true),
);
}
pub async fn index() -> Result<NamedFile> {
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
Ok(NamedFile::open(index)?)
}
+33 -1
View File
@@ -8,7 +8,7 @@ lazy_static! {
pub static ref FRONTEND_PATH: String = pub static ref FRONTEND_PATH: String =
std::env::var("FRONTEND_PATH").unwrap_or("../frontend/build".to_string()); std::env::var("FRONTEND_PATH").unwrap_or("../frontend/build".to_string());
pub static ref LISTEN_ADDR: String = pub static ref LISTEN_ADDR: String =
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:5000".to_string()); std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:8000".to_string());
pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string()); pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string());
} }
@@ -30,6 +30,30 @@ lazy_static! {
.unwrap_or("true".to_string()) .unwrap_or("true".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string())
.parse()
.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")
.unwrap_or("true".to_string())
.parse()
.unwrap();
pub static ref IMPRINT_URL: String = std::env::var("IMPRINT_URL")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref EXTRA_SIZE_LIMIT: usize = std::env::var("EXTRA_SIZE_LIMIT")
.unwrap_or("512".to_string())
.parse()
.unwrap();
} }
// THEME // THEME
@@ -50,4 +74,12 @@ lazy_static! {
.unwrap_or("".to_string()) .unwrap_or("".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref THEME_NEW_NOTE_NOTICE: bool = std::env::var("THEME_NEW_NOTE_NOTICE")
.unwrap_or("true".to_string())
.parse()
.unwrap();
pub static ref THEME_HOME_LINK: bool = std::env::var("THEME_HOME_LINK")
.unwrap_or("true".to_string())
.parse()
.unwrap();
} }
+10
View File
@@ -0,0 +1,10 @@
use crate::store;
use axum::http::StatusCode;
pub async fn report_health() -> (StatusCode,) {
if store::can_reach_cache() {
return (StatusCode::OK,);
} else {
return (StatusCode::SERVICE_UNAVAILABLE,);
}
}
+55 -22
View File
@@ -1,36 +1,69 @@
use actix_web::{ use axum::{
middleware::{self, Logger}, Router, ServiceExt,
web, App, HttpServer, extract::{DefaultBodyLimit, Request},
routing::{delete, get, post},
}; };
use dotenv::dotenv; use dotenv::dotenv;
use tower::Layer;
use tower_http::{
compression::CompressionLayer,
normalize_path::NormalizePathLayer,
services::{ServeDir, ServeFile},
};
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
mod api;
mod client;
mod config; mod config;
mod health;
mod note; mod note;
mod size;
mod status; mod status;
mod store; mod store;
#[actix_web::main] #[tokio::main]
async fn main() -> std::io::Result<()> { async fn main() {
dotenv().ok(); dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
return HttpServer::new(|| { if !store::can_reach_cache() {
App::new() println!("cannot reach cache");
.wrap(Logger::new("\"%r\" %s %b %T")) panic!("cannot reach cache");
.wrap(middleware::Compress::default()) }
.wrap(middleware::DefaultHeaders::default())
.configure(size::init) let notes_routes = Router::new()
.configure(api::init) .route("/", post(note::create))
.configure(client::init) .route("/{id}", delete(note::view))
.default_service(web::to(client::index)) .route("/{id}", get(note::preview));
}) let health_routes = Router::new().route("/healthz", get(health::report_health));
.bind(config::LISTEN_ADDR.to_string())? let status_routes = Router::new().route("/status", get(status::get_status));
.run() let v3_routes = Router::new()
.await; .nest("/notes", notes_routes)
.merge(status_routes);
let api_routes = Router::new().nest("/v3", v3_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
let serve_dir =
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
let app = Router::new()
.nest("/api", api_routes)
.merge(health_routes)
.fallback_service(serve_dir)
.layer(DefaultBodyLimit::max(*config::LIMIT))
.layer(
CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true),
);
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
let listener = tokio::net::TcpListener::bind(config::LISTEN_ADDR.to_string())
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, ServiceExt::<Request>::into_make_service(app))
.await
.unwrap();
} }
+32 -10
View File
@@ -2,26 +2,48 @@ use bs62;
use ring::rand::SecureRandom; use ring::rand::SecureRandom;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
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, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct NoteInfo {} pub struct CreateRequest {
pub meta: NoteMeta,
pub data: Vec<u8>,
}
#[derive(Serialize, Deserialize, Clone)] #[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 {
let mut id: [u8; 32] = [0; 32]; let mut result = "".to_owned();
let mut id: [u8; 1] = [0; 1];
let sr = ring::rand::SystemRandom::new(); let sr = ring::rand::SystemRandom::new();
for _ in 0..*config::ID_LENGTH {
let _ = sr.fill(&mut id); let _ = sr.fill(&mut id);
return bs62::encode_data(&id); result.push_str(&bs62::encode_data(&id));
}
result
} }
+107 -106
View File
@@ -1,143 +1,144 @@
use actix_web::{delete, get, post, web, HttpResponse, Responder, Scope}; use axum::{
use serde::{Deserialize, Serialize}; extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
body::Bytes,
};
use serde::Deserialize;
use std::time::SystemTime; use std::time::SystemTime;
use crate::config; use crate::note::{CreateRequest, generate_id};
use crate::note::{generate_id, Note, NoteInfo, NotePublic};
use crate::store; use crate::store;
use crate::config;
pub fn now() -> u32 { use super::{CreateResponse, MetaResponse, NoteResponse, NoteMeta};
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(Serialize, Deserialize)] #[derive(Deserialize)]
struct NotePath { pub struct NoteParams {
id: String, id: String,
} }
#[get("/{id}")] pub async fn create(body: Bytes) -> Response {
async fn one(path: web::Path<NotePath>) -> impl Responder { let req: CreateRequest = match rmp_serde::from_slice(&body) {
let p = path.into_inner(); Ok(r) => r,
let note = store::get(&p.id); Err(_) => return (StatusCode::BAD_REQUEST, "Invalid msgpack").into_response(),
};
match note { if req.meta.views.is_none() && req.meta.expiration.is_none() {
Ok(Some(_)) => HttpResponse::Ok().json(NoteInfo {}), return (StatusCode::BAD_REQUEST, "At least views or expiration must be set").into_response();
Ok(None) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}
} }
#[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,
} }
#[post("/")] let mut meta = req.meta;
async fn create(note: web::Json<Note>) -> impl Responder {
let mut n = note.into_inner();
let id = generate_id();
let bad_req = HttpResponse::BadRequest().finish();
if n.views == None && n.expiration == None {
return bad_req;
}
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 { if v > *config::MAX_VIEWS || v < 1 {
return bad_req; 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 { if e > *config::MAX_EXPIRATION || e < 1 {
return bad_req; 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()) { Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
Ok(_) => return HttpResponse::Ok().json(CreateResponse { id: id }),
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
} }
} }
#[delete("/{id}")] pub async fn preview(Path(NoteParams { id }): Path<NoteParams>) -> Response {
async fn delete(path: web::Path<NotePath>) -> impl Responder { match store::get_meta(&id) {
let p = path.into_inner(); Ok(Some((views, expiration, extra))) => {
let note = store::get(&p.id); let meta = NoteMeta {
match note { views: views.map(|v| v as u32),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()), expiration: expiration.map(|e| e as u32),
Ok(None) => return HttpResponse::NotFound().finish(), extra,
Ok(Some(note)) => { };
let mut changed = note.clone(); let resp = MetaResponse { meta };
if changed.views == None && changed.expiration == None { let bytes = rmp_serde::to_vec_named(&resp).unwrap();
return HttpResponse::BadRequest().finish(); (StatusCode::OK, Bytes::from(bytes)).into_response()
} }
match changed.views { Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Some(v) => { Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
changed.views = Some(v - 1);
let id = p.id.clone();
if v <= 1 {
match store::del(&id) {
Err(e) => {
return HttpResponse::InternalServerError().body(e.to_string())
} }
_ => {}
} }
pub async fn view(Path(NoteParams { id }): Path<NoteParams>) -> Response {
let (views, expiration, extra) = match store::get_meta(&id) {
Ok(Some(v)) => v,
_ => return (StatusCode::NOT_FOUND).into_response(),
};
let has_views = views.is_some();
if has_views {
let remaining = match store::decrement_views(&id) {
Ok(r) => r,
Err(_) => return (StatusCode::NOT_FOUND).into_response(),
};
let data = match store::get_data(&id) {
Ok(Some(d)) => d,
_ => return (StatusCode::NOT_FOUND).into_response(),
};
if remaining <= 0 {
let _ = store::del(&id);
}
let meta = NoteMeta {
views: Some(if remaining > 0 { remaining as u32 } else { 0 }),
expiration: expiration.map(|e| e as u32),
extra,
};
let resp = NoteResponse { meta, data };
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
(StatusCode::OK, Bytes::from(bytes)).into_response()
} else { } else {
match store::set(&id, &changed.clone()) { let data = match store::get_data(&id) {
Err(e) => { Ok(Some(d)) => d,
return HttpResponse::InternalServerError().body(e.to_string()) _ => return (StatusCode::NOT_FOUND).into_response(),
} };
_ => {}
}
}
}
_ => {}
}
let n = now(); let meta = NoteMeta {
match changed.expiration { views: None,
Some(e) => { expiration: expiration.map(|e| e as u32),
if e < n { extra,
match store::del(&p.id.clone()) { };
Ok(_) => return HttpResponse::BadRequest().finish(), let resp = NoteResponse { meta, data };
Err(e) => { let bytes = rmp_serde::to_vec_named(&resp).unwrap();
return HttpResponse::InternalServerError().body(e.to_string()) (StatusCode::OK, Bytes::from(bytes)).into_response()
} }
} }
}
}
_ => {}
}
return HttpResponse::Ok().json(NotePublic {
contents: changed.contents,
meta: changed.meta,
});
}
}
}
#[derive(Serialize, Deserialize)]
struct Status {
version: String,
max_size: usize,
}
pub fn init() -> Scope {
web::scope("/notes")
.service(one)
.service(create)
.service(delete)
}
-12
View File
@@ -1,12 +0,0 @@
use crate::config;
use actix_web::web;
use mime;
pub fn init(cfg: &mut web::ServiceConfig) {
let json = web::JsonConfig::default().limit(*config::LIMIT);
let plain = web::PayloadConfig::default()
.limit(*config::LIMIT)
.mimetype(mime::STAR_STAR);
// cfg.app_data(plain);
cfg.app_data(json).app_data(plain);
}
+44 -4
View File
@@ -1,5 +1,45 @@
mod model; use crate::config;
mod routes; use axum::{http::StatusCode, Json};
use serde::Serialize;
pub use model::*; #[derive(Serialize)]
pub use routes::*; pub struct Status {
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
pub allow_files: bool,
pub imprint_url: String,
pub imprint_html: String,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
pub theme_new_note_notice: bool,
pub theme_home_link: bool,
}
pub async fn get_status() -> (StatusCode, Json<Status>) {
let status = Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
allow_files: *config::ALLOW_FILES,
imprint_url: config::IMPRINT_URL.to_string(),
imprint_html: config::IMPRINT_HTML.to_string(),
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_home_link: *config::THEME_HOME_LINK,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string(),
};
(StatusCode::OK, Json(status))
}
-17
View File
@@ -1,17 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Status {
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
}
-23
View File
@@ -1,23 +0,0 @@
use actix_web::{get, web, HttpResponse, Responder, Scope};
use crate::config;
use crate::status::Status;
#[get("/")]
async fn get_status() -> impl Responder {
return HttpResponse::Ok().json(Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string()
});
}
pub fn init() -> Scope {
web::scope("/status").service(get_status)
}
+63 -35
View File
@@ -1,55 +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 set(id: &String, note: &Note) -> Result<(), &'static str> { pub fn can_reach_cache() -> bool {
let serialized = serde_json::to_string(&note.clone()).unwrap(); conn().is_ok()
let mut conn = get_connection()?;
conn.set(id, serialized)
.map_err(|_| "Unable to set note in redis")?;
match note.expiration {
Some(e) => {
let seconds = e - now();
conn.expire(id, seconds as usize)
.map_err(|_| "Unable to set expiration on notion")?
} }
None => {}
}; pub fn set(id: &str, data: &[u8], views: Option<i64>, expiration: Option<u64>, extra: &[u8]) -> Result<(), &'static str> {
let key = prefixed(id);
let mut c = conn()?;
c.hset::<_, _, _, ()>(&key, "data", data).map_err(|_| "Unable to set note")?;
c.hset::<_, _, _, ()>(&key, "extra", extra).map_err(|_| "Unable to set note")?;
if let Some(v) = views {
c.hset::<_, _, _, ()>(&key, "views", v).map_err(|_| "Unable to set note")?;
}
if let Some(e) = expiration {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let ttl = e.saturating_sub(now);
c.expire::<_, ()>(&key, ttl as i64).map_err(|_| "Unable to set expiration")?;
}
Ok(()) Ok(())
} }
pub fn get(id: &String) -> Result<Option<Note>, &'static str> { pub fn get_meta(id: &str) -> Result<Option<(Option<i64>, Option<u64>, Vec<u8>)>, &'static str> {
let mut conn = get_connection()?; let key = prefixed(id);
let value: Option<String> = conn.get(id).map_err(|_| "Could not load note in redis")?; let mut c = conn()?;
match value {
None => return Ok(None), let exists: bool = c.exists::<_, bool>(&key).map_err(|_| "Cache error")?;
Some(s) => { if !exists {
let deserialize: Note = serde_json::from_str(&s).unwrap(); return Ok(None);
return Ok(Some(deserialize));
}
}
} }
pub fn del(id: &String) -> Result<(), &'static str> { let views: Option<i64> = c.hget::<_, _, Option<i64>>(&key, "views").map_err(|_| "Cache error")?;
let mut conn = get_connection()?; let expiration: Option<u64> = c.hget::<_, _, Option<u64>>(&key, "expiration").map_err(|_| "Cache error")?;
conn.del(id).map_err(|_| "Unable to delete note in redis")?; 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(()) Ok(())
} }
+54
View File
@@ -0,0 +1,54 @@
# Cryptgeon CLI
The CLI is a functionally identical way to interact with cryptgeon notes.
It supports text, files, expiration, password, etc.
## Installation
```bash
npx cryptgeon
# Or install globally
npm -g install cryptgeon
cryptgeon
```
## Examples
```bash
# Create simple note
cryptgeon send text "Foo bar"
# Send two files
cryptgeon send file my.pdf picture.png
# 3 views
cryptgeon send text "My message" --views 3
# 10 minutes
cryptgeon send text "My message" --minutes 10
# Custom password
cryptgeon send text "My message" --password "1337"
# Password from stdin
echo "1337" | cryptgeon send text "My message"
# Open a link
cryptgeon open https://cryptgeon.org/note/16gOIkxWjCxYNuXM8tCqMUzl...
```
## Options
### Custom server
The default server is `cryptgeon.org`, however you can use any cryptgeon server by passing the `-s` or `--server` option, or by setting the `CRYPTGEON_SERVER` environment variable.
### Password
Optionally, just like in the web ui, you can choose to use a manual password. You can do that by passing the `-p` or `--password` options, or by piping it into stdin.
```bash
echo "my pw" | cryptgeon send text "my text"
cat pass.txt | cryptgeon send text "my text"
```
+43
View File
@@ -0,0 +1,43 @@
{
"name": "cryptgeon",
"version": "3.0.0-rc.3",
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": {
"type": "git",
"url": "https://github.com/cupcakearmy/cryptgeon.git",
"directory": "packages/cli"
},
"type": "module",
"exports": {
".": "./dist/index.mjs"
},
"types": "./dist/index.d.mts",
"bin": {
"cryptgeon": "./dist/cli.mjs"
},
"files": [
"dist"
],
"scripts": {
"build": "vp pack",
"dev": "vp pack --watch",
"prepublishOnly": "pnpm run build"
},
"devDependencies": {
"@commander-js/extra-typings": "^15.0.0",
"@cryptgeon/shared": "workspace:*",
"@msgpack/msgpack": "^3.1.3",
"@tsconfig/strictest": "catalog:",
"@types/inquirer": "^9.0.10",
"@types/node": "^22.20.1",
"commander": "^15.0.0",
"inquirer": "^14.2.1",
"mime": "^4.1.0",
"pretty-bytes": "^7.1.3",
"typescript": "catalog:",
"vite-plus": "catalog:"
},
"engines": {
"node": ">=22"
}
}
+77
View File
@@ -0,0 +1,77 @@
import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { decode } from '@msgpack/msgpack'
import pretty from 'pretty-bytes'
import { deriveKey, setServer, info, get, unpackContent } from '@cryptgeon/shared'
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
setServer(url.origin)
const id = url.pathname.split('/')[2]
if (!id) throw new Error('Invalid URL')
const meta = await info(id)
if (!meta) throw new Error('Note does not exist or is expired')
let key: Uint8Array
if (meta.extra && meta.extra.length > 0) {
if (suggestedPassword) {
const derivation = decode(meta.extra) as any
key = deriveKey(suggestedPassword, new Uint8Array(derivation.salt))
} else {
const response = await inquirer.prompt([
{ type: 'password', message: 'Note password', name: 'password' },
])
const derivation = decode(meta.extra) as any
key = deriveKey(response.password, new Uint8Array(derivation.salt))
}
} else {
const hex = url.hash.slice(1)
key = new Uint8Array(Buffer.from(hex, 'hex'))
}
const note = await get(id)
if (!note) throw new Error('Could not load note')
const content = unpackContent(note.data, key)
switch (content.type) {
case 'files':
const files: { name: string; data: Uint8Array }[] = content.data
let selected: typeof files
if (all) {
selected = files
} else {
const { names } = await inquirer.prompt([
{
type: 'checkbox',
message: 'What files should be saved?',
name: 'names',
choices: files.map((f) => ({
value: f.name,
name: `${f.name} - ${pretty(f.data.length, { binary: true })}`,
checked: true,
})),
},
])
selected = files.filter((f) => names.includes(f.name))
}
if (!selected.length) throw new Error('No files selected')
await Promise.all(
selected.map(async (f) => {
let filename = resolve(f.name)
try {
await access(filename, constants.R_OK)
filename = resolve(`${Date.now()}-${f.name}`)
} catch {}
await writeFile(filename, f.data)
console.log(`Saved: ${basename(filename)}`)
})
)
break
case 'text':
console.log(content.data)
break
default:
throw new Error('Unknown content type')
}
}
+38
View File
@@ -0,0 +1,38 @@
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'
import mime from 'mime'
import { getServer, create, packContent, type FileDTO } from '@cryptgeon/shared'
export type UploadOptions = { views?: number; expiration?: number; password?: string }
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
const { password, ...noteOptions } = options
const payload = packContent(
typeof input === 'string'
? { type: 'text', text: input }
: { type: 'files', files: await fileDTOSfromPaths(input) },
password
)
const result = await create({ meta: { ...noteOptions, extra: payload.extra }, data: payload.data })
let url = `${getServer()}/note/${result.id}`
if (!password) url += `#${Buffer.from(payload.key).toString('hex')}`
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,
}
})
)
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
import { Argument, Option, program } from '@commander-js/extra-typings'
import prettyBytes from 'pretty-bytes'
import { download } from './actions/download.js'
import { upload } from './actions/upload.js'
import { setServer, status } from '@cryptgeon/shared'
import { parseFile, parseNumber } from './utils/parsers.js'
import { getStdin } from './utils/stdin.js'
import { checkConstrains, exit } from './utils/utils.js'
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile)
const text = new Argument('<text>', 'Text content of the note')
const password = new Option('-p --password <string>', 'manually set a password')
const all = new Option('-a --all', 'Save all files without prompt').default(false)
const url = new Argument('<url>', 'The url to open')
const views = new Option('-v --views <number>', 'Amount of views before getting destroyed').argParser(parseNumber)
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
// Node 18 guard
const major = Number(process.version.slice(1).split('.')[0])
if (!Number.isFinite(major) || major < 18) exit('Node 18 or higher is required')
// @ts-ignore
const version: string = VERSION
program.name('cryptgeon').version(version).configureHelp({ showGlobalOptions: true })
program
.command('info')
.description('show information about the server')
.addOption(server)
.action(async (options) => {
setServer(options.server!)
const response = await status()
const formatted = Object.fromEntries(
Object.entries({ ...response, max_size: prettyBytes(response.max_size) })
.filter(([key]) => !key.startsWith('theme_'))
)
console.table(formatted)
})
const send = program.command('send').description('send a note')
send
.command('file')
.addArgument(files)
.addOption(server)
.addOption(views)
.addOption(minutes)
.addOption(password)
.action(async (files, options) => {
setServer(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
try {
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}`)
} catch {
exit('Could not create note')
}
})
send
.command('text')
.addArgument(text)
.addOption(server)
.addOption(views)
.addOption(minutes)
.addOption(password)
.action(async (text, options) => {
setServer(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
try {
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}`)
} catch {
exit('Could not create note')
}
})
program
.command('open')
.description('open a link with text or files inside')
.addArgument(url)
.addOption(password)
.addOption(all)
.action(async (note, options) => {
try {
const url = new URL(note)
options.password ||= await getStdin()
try {
await download(url, options.all, options.password)
} catch (e) {
exit(e instanceof Error ? e.message : 'Unknown error occurred')
}
} catch {
exit('Invalid URL')
}
})
program.parse()
+2
View File
@@ -0,0 +1,2 @@
export * from './actions/download.js'
export * from './actions/upload.js'
+27
View File
@@ -0,0 +1,27 @@
import { InvalidArgumentError, InvalidOptionArgumentError } from '@commander-js/extra-typings'
import { accessSync, constants } from 'node:fs'
import { resolve } from 'node:path'
export function parseFile(value: string, before: string[] = []) {
try {
const file = resolve(value)
accessSync(file, constants.R_OK)
return [...before, file]
} catch {
throw new InvalidArgumentError('cannot access file')
}
}
export function parseURL(value: string, _: URL): URL {
try {
return new URL(value)
} catch {
throw new InvalidArgumentError('is not a valid url')
}
}
export function parseNumber(value: string, _: number): number {
const n = Number.parseInt(value, 10)
if (Number.isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
return n
}
+25
View File
@@ -0,0 +1,25 @@
export function getStdin(timeout: number = 10): Promise<string> {
return new Promise<string>((resolve, reject) => {
// Store the data from stdin in a buffer
let buffer = ''
let t: NodeJS.Timeout
const dataHandler = (d: Buffer) => (buffer += d.toString())
const endHandler = () => {
clearTimeout(t)
resolve(buffer.trim())
}
// Stop listening for data after the timeout, otherwise hangs indefinitely
t = setTimeout(() => {
process.stdin.removeListener('data', dataHandler)
process.stdin.removeListener('end', endHandler)
process.stdin.pause()
resolve('')
}, timeout)
process.stdin.on('error', reject)
process.stdin.on('data', dataHandler)
process.stdin.on('end', endHandler)
})
}
+17
View File
@@ -0,0 +1,17 @@
import { exit as exitNode } from 'node:process'
import { status } from '@cryptgeon/shared'
export function exit(message: string) {
console.error(message)
exitNode(1)
}
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
if (!constrains.views && !constrains.minutes) constrains.views = 1
const response = await status()
if (constrains.views && constrains.views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${constrains.views} given.`)
if (constrains.minutes && constrains.minutes > response.max_expiration)
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${constrains.minutes} given.`)
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "Bundler",
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "./dist",
"rootDir": "./src",
"allowSyntheticDefaultImports": true
},
"exclude": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite-plus";
import pkg from "./package.json" with { type: "json" };
export default defineConfig({
pack: {
entry: ["src/index.ts", "src/cli.ts"],
dts: true,
minify: true,
format: ["esm"],
target: "es2023",
deps: { alwaysBundle: ["**"] },
define: { VERSION: JSON.stringify(pkg.version) },
},
});
-1
View File
@@ -1 +0,0 @@
engine-strict=true
+15 -6
View File
@@ -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
1 ├─ MIT: 13 ├─ MIT: 324
2 ├─ ISC: 2 ├─ ISC: 84
3 ├─ BSD-3-Clause: 1 ├─ Apache-2.0: 21
4 ├─ (MPL-2.0 OR Apache-2.0): 1 ├─ BlueOak-1.0.0: 14
5 ├─ BSD-2-Clause: 1 ├─ BSD-3-Clause: 6
6 ├─ BSD-2-Clause: 4
7 ├─ OFL-1.1: 1
8 ├─ Python-2.0: 1
9 ├─ CC-BY-4.0: 1
10 ├─ UNKNOWN: 1
11 ├─ MPL-2.0: 1
12 ├─ CC-BY-3.0: 1
13 ├─ CC0-1.0: 1
14 ├─ (MIT AND CC-BY-3.0): 1
15 ├─ 0BSD: 1 ├─ 0BSD: 1
16 └─ Apache-2.0: 1 └─ (MIT OR CC0-1.0): 1
17
+61
View File
@@ -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"
}
}
+36 -25
View File
@@ -1,50 +1,61 @@
{ {
"common": { "common": {
"note": "Hinweis", "note": "Notiz",
"file": "Datei", "file": "Datei",
"advanced": "erweitert", "advanced": "Erweiterte Optionen",
"create": "erstellen", "create": "Erstellen",
"loading": "läd", "loading": "Lädt...",
"mode": "Modus", "mode": "Modus",
"views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}", "views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}",
"minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}", "minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}",
"max": "max", "max": "max",
"share_link": "Link teilen", "share_link": "Link teilen",
"copy_clipboard": "in die Zwischenablage kopieren", "copy_clipboard": "In die Zwischenablage kopieren",
"copied_to_clipboard": "in die Zwischenablage kopiert", "copied_to_clipboard": "In die Zwischenablage kopiert.",
"encrypting": "verschlüsseln", "encrypting": "Wird verschlüsselt...",
"decrypting": "entschlüsselt", "decrypting": "Wird entschlüsselt...",
"uploading": "hochladen", "uploading": "Hochladen",
"downloading": "wird heruntergeladen" "downloading": "Wird heruntergeladen",
"qr_code": "QR-Code",
"password": "Passwort"
}, },
"home": { "home": {
"intro": "Senden Sie ganz einfach <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien mit einem Klick. Erstellen Sie einfach eine Notiz und teilen Sie den Link.", "intro": "Erstellen Sie mit einem Klick <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien und teilen Sie diese über einen Link.",
"explanation": "die Notiz verfällt und wird nach {type} zerstört.", "explanation": "Die Notiz verfällt nach {type}.",
"new_note": "neue Note", "new_note": "Neue Notiz",
"new_note_notice": "<b>Verfügbarkeit:</b><br />es ist nicht garantiert, dass die Notiz gespeichert wird, da alles im Speicher gehalten wird. Wenn dieser voll ist, werden die ältesten Notizen entfernt.<br />(Sie werden wahrscheinlich keine Probleme haben, seien Sie nur gewarnt).", "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. Notiz ist zu groß", "note_too_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
"note_error": "konnte keine Notiz erstellen. 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."
}, },
"messages": { "messages": {
"note_created": "notiz erstellt." "note_created": "Notiz wurde erstellt."
} },
"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.",
"custom_password": "Benutzerdefiniertes Passwort"
},
"pasting": "Einfügen...",
"pasted_files": "Eingefügte Dateien",
"remove": "Entfernen"
}, },
"show": { "show": {
"errors": { "errors": {
"not_found": "wurde nicht gefunden oder wurde bereits gelöscht.", "not_found": "Notiz konnte nicht gefunden werden oder wurde bereits gelöscht.",
"decryption_failed": "falsches Passwort. konnte nicht entziffert werden. wahrscheinlich ein defekter Link. Notiz wurde zerstört.", "decryption_failed": "Notiz konnte nicht entschlüsselt werden. Vermutlich ist das Passwort falsch oder der Link defekt. Die Notiz wurde daher gelöscht.",
"unsupported_type": "nicht unterstützter Notiztyp." "unsupported_type": "Nicht unterstützter Notiztyp."
}, },
"explanation": "Klicken Sie unten, um die Notiz anzuzeigen und zu löschen, wenn der Zähler sein Limit erreicht hat", "explanation": "Klicken Sie auf den Button, um die Notiz anzuzeigen und anschließend zu löschen, falls ein festgelegtes Limit erreicht wurde.",
"show_note": "Notiz anzeigen", "show_note": "Notiz anzeigen",
"warning_will_not_see_again": "haben Sie <b>keine</b> Gelegenheit, die Notiz noch einmal zu sehen.", "warning_will_not_see_again": "ACHTUNG! Sie werden anschließend <b>keine</b> Gelegenheit mehr haben, die Notiz erneut anzusehen.",
"download_all": "alle herunterladen" "download_all": "Alle Dateien herunterladen",
"links_found": "Gefundene Links in der Notiz:"
}, },
"file_upload": { "file_upload": {
"selected_files": "Ausgewählte Dateien", "selected_files": "Ausgewählte Dateien",
"no_files_selected": "Keine Dateien ausgewählt" "no_files_selected": "Keine Dateien ausgewählt",
"clear": "Zurücksetzen"
} }
} }
+17 -6
View File
@@ -15,7 +15,9 @@
"encrypting": "encrypting", "encrypting": "encrypting",
"decrypting": "decrypting", "decrypting": "decrypting",
"uploading": "uploading", "uploading": "uploading",
"downloading": "downloading" "downloading": "downloading",
"qr_code": "qr code",
"password": "password"
}, },
"home": { "home": {
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.", "intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
@@ -23,14 +25,21 @@
"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 to 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."
}, },
"messages": { "messages": {
"note_created": "note created." "note_created": "note created."
} },
"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.",
"custom_password": "custom password"
},
"pasting": "Pasting...",
"pasted_files": "Pasted Files",
"remove": "Remove"
}, },
"show": { "show": {
"errors": { "errors": {
@@ -38,13 +47,15 @@
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.", "decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.",
"unsupported_type": "unsupported note type." "unsupported_type": "unsupported note type."
}, },
"explanation": "click below to show and delete the note if the counter has reached it's limit", "explanation": "click below to show and delete the note if the counter has reached its limit",
"show_note": "show note", "show_note": "show note",
"warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.", "warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.",
"download_all": "download all" "download_all": "download all",
"links_found": "links found inside the note:"
}, },
"file_upload": { "file_upload": {
"selected_files": "Selected Files", "selected_files": "Selected Files",
"no_files_selected": "No Files Selected" "no_files_selected": "No Files Selected",
"clear": "Reset"
} }
} }
+17 -6
View File
@@ -15,22 +15,31 @@
"encrypting": "encriptando", "encrypting": "encriptando",
"decrypting": "descifrando", "decrypting": "descifrando",
"uploading": "cargando", "uploading": "cargando",
"downloading": "descargando" "downloading": "descargando",
"qr_code": "código qr",
"password": "contraseña"
}, },
"home": { "home": {
"intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.", "intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.",
"explanation": "la nota expirará y se destruirá después de {type}.", "explanation": "la nota expirará y se destruirá después de {type}.",
"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, sólo 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."
}, },
"messages": { "messages": {
"note_created": "nota creada." "note_created": "nota creada."
} },
"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.",
"custom_password": "contraseña personalizada"
},
"pasting": "Pegando...",
"pasted_files": "Archivos pegados",
"remove": "Eliminar"
}, },
"show": { "show": {
"errors": { "errors": {
@@ -41,10 +50,12 @@
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite", "explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
"show_note": "mostrar nota", "show_note": "mostrar nota",
"warning_will_not_see_again": "<b>no</b> tendrás la oportunidad de volver a ver la nota.", "warning_will_not_see_again": "<b>no</b> tendrás la oportunidad de volver a ver la nota.",
"download_all": "descargar todo" "download_all": "descargar todo",
"links_found": "enlaces que se encuentran dentro de la nota:"
}, },
"file_upload": { "file_upload": {
"selected_files": "Archivos seleccionados", "selected_files": "Archivos seleccionados",
"no_files_selected": "No hay archivos seleccionados" "no_files_selected": "No hay archivos seleccionados",
"clear": "Restablecer"
} }
} }
+21 -10
View File
@@ -12,25 +12,34 @@
"share_link": "partager le lien", "share_link": "partager le lien",
"copy_clipboard": "copier dans le presse-papiers", "copy_clipboard": "copier dans le presse-papiers",
"copied_to_clipboard": "copié dans le presse-papiers", "copied_to_clipboard": "copié dans le presse-papiers",
"encrypting": "cryptage", "encrypting": "chiffrer",
"decrypting": "déchiffrer", "decrypting": "déchiffrer",
"uploading": "téléchargement", "uploading": "téléversement",
"downloading": "téléchargement" "downloading": "téléchargement",
"qr_code": "code qr",
"password": "mot de passe"
}, },
"home": { "home": {
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement cryptés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.", "intro": "Envoyez facilement des notes ou des fichiers <i>entièrement chiffrés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.",
"explanation": "la note expirera et sera détruite après {type}.", "explanation": "la note expirera et sera détruite après {type}.",
"new_note": "nouvelle note", "new_note": "nouvelle note",
"new_note_notice": "<b>disponibilité :</b><br />la note n'est pas garantie d'être stockée car tout est conservé dans la mémoire vive, si elle se remplit les notes les plus anciennes seront supprimées.<br />(vous serez 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."
}, },
"messages": { "messages": {
"note_created": "note créée." "note_created": "note créée."
} },
"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.",
"custom_password": "mot de passe personnalisé"
},
"pasting": "Collage...",
"pasted_files": "Fichiers collés",
"remove": "Supprimer"
}, },
"show": { "show": {
"errors": { "errors": {
@@ -39,12 +48,14 @@
"unsupported_type": "type de note non supporté." "unsupported_type": "type de note non supporté."
}, },
"explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.", "explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.",
"show_note": "note de présentation", "show_note": "afficher la note",
"warning_will_not_see_again": "vous <b>n'aurez pas</b> la chance de revoir la note.", "warning_will_not_see_again": "vous <b>n'aurez pas</b> la chance de revoir la note.",
"download_all": "télécharger tout" "download_all": "tout télécharger",
"links_found": "liens trouvés à l’intérieur de la note :"
}, },
"file_upload": { "file_upload": {
"selected_files": "Fichiers sélectionnés", "selected_files": "Fichiers sélectionnés",
"no_files_selected": "Aucun fichier sélectionné" "no_files_selected": "Aucun fichier sélectionné",
"clear": "Réinitialiser"
} }
} }
+17 -6
View File
@@ -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",
@@ -15,7 +15,9 @@
"encrypting": "criptando", "encrypting": "criptando",
"decrypting": "decifrando", "decrypting": "decifrando",
"uploading": "caricamento", "uploading": "caricamento",
"downloading": "scaricando" "downloading": "scaricando",
"qr_code": "codice qr",
"password": "password"
}, },
"home": { "home": {
"intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.", "intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.",
@@ -23,14 +25,21 @@
"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."
}, },
"messages": { "messages": {
"note_created": "nota creata." "note_created": "nota creata."
} },
"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.",
"custom_password": "password personalizzata"
},
"pasting": "Incollando...",
"pasted_files": "File incollati",
"remove": "Rimuovi"
}, },
"show": { "show": {
"errors": { "errors": {
@@ -41,10 +50,12 @@
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite", "explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
"show_note": "mostra la nota", "show_note": "mostra la nota",
"warning_will_not_see_again": "<b>non</b> avrete la possibilità di rivedere la nota.", "warning_will_not_see_again": "<b>non</b> avrete la possibilità di rivedere la nota.",
"download_all": "scarica tutti" "download_all": "scarica tutti",
"links_found": "link presenti all'interno della nota:"
}, },
"file_upload": { "file_upload": {
"selected_files": "File selezionati", "selected_files": "File selezionati",
"no_files_selected": "Nessun file selezionato" "no_files_selected": "Nessun file selezionato",
"clear": "Reset"
} }
} }
+61
View File
@@ -0,0 +1,61 @@
{
"common": {
"note": "新しいメモ",
"file": "ファイル",
"advanced": "アドバンスド",
"create": "作成",
"loading": "読み込み中",
"mode": "モード",
"views": "{n, plural, =0 {表示可能な時間} =1 { 1 ビュー} other {#ビュー}}",
"minutes": "{n, plural, =0 {有効期間(分)} =1 {1 分} other {# 分}}",
"max": "マックス",
"share_link": "共有リンク",
"copy_clipboard": "クリップボードにコピーする",
"copied_to_clipboard": "クリップボードにコピーされました",
"encrypting": "暗号化",
"decrypting": "復号化",
"uploading": "アップロード中",
"downloading": "ダウンロード中",
"qr_code": "QRコード",
"password": "パスワード"
},
"home": {
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
"explanation": "メモは{type}後に期限切れになり、破棄されます。",
"new_note": "新しいメモ",
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
"errors": {
"note_too_big": "メモを作成できませんでした。メモが大きすぎる",
"note_error": "メモを作成できませんでした。もう一度お試しください。",
"max": "最大ファイルサイズ: {n}",
"empty_content": "メモは空です。"
},
"messages": {
"note_created": "メモが作成されました。"
},
"advanced": {
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
"custom_password": "カスタムパスワード"
},
"pasting": "貼り付け中...",
"pasted_files": "貼り付けたファイル",
"remove": "削除"
},
"show": {
"errors": {
"not_found": "メモが見つからないか、既に削除されています。",
"decryption_failed": "パスワードエラー!不完全なリンクを貼り付けたり、暗号を解読しようとしたりしている可能性があります!しかし、いずれにしても、この暗号は破棄されました!",
"unsupported_type": "サポートされていないメモ タイプです。"
},
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
"show_note": "メモを表示",
"warning_will_not_see_again": "このノートを再度表示することは<b>できません</b>",
"download_all": "すべてダウンロード",
"links_found": "メモ内にあるリンク:"
},
"file_upload": {
"selected_files": "選択したファイル",
"no_files_selected": "ファイルが選択されていません",
"clear": "リセット"
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"common": {
"note": "notatka",
"file": "plik",
"advanced": "zaawansowane",
"create": "utwórz",
"loading": "ładowanie",
"mode": "tryb",
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {{n} wyświetleń}}",
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {{n} minuty}}",
"max": "maks.",
"share_link": "link udostępniania",
"copy_clipboard": "kopiuj do schowka",
"copied_to_clipboard": "skopiowano do schowka",
"encrypting": "szyfrowanie",
"decrypting": "odszyfrowywanie",
"uploading": "wysyłanie",
"downloading": "pobieranie",
"qr_code": "kod QR",
"password": "hasło"
},
"home": {
"intro": "Łatwo wysyłaj <i>w pełni zaszyfrowane</i>, bezpieczne notatki lub pliki jednym kliknięciem. Po prostu utwórz notatkę i udostępnij link.",
"explanation": "notatka wygaśnie i zostanie zniszczona po {type}.",
"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.)",
"errors": {
"note_too_big": "nie można utworzyć notatki. notatka jest za duża",
"note_error": "nie można utworzyć notatki. spróbuj ponownie.",
"max": "maks.: {n}",
"empty_content": "notatka jest pusta."
},
"messages": {
"note_created": "notatka utworzona."
},
"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.",
"custom_password": "własne hasło"
},
"pasting": "Wklejanie...",
"pasted_files": "Wklejone pliki",
"remove": "Usuń"
},
"show": {
"errors": {
"not_found": "notatka nie została znaleziona lub została już usunięta.",
"decryption_failed": "błędne hasło. nie można odszyfrować. prawdopodobnie uszkodzony link. notatka została zniszczona.",
"unsupported_type": "nieobsługiwany typ notatki."
},
"explanation": "kliknij poniżej, aby wyświetlić i usunąć notatkę, jeśli licznik osiągnie swój limit",
"show_note": "pokaż notatkę",
"warning_will_not_see_again": "<b>nie będziesz mieć</b> możliwości ponownego zobaczenia notatki.",
"download_all": "pobierz wszystko",
"links_found": "linki znalezione w notatce:"
},
"file_upload": {
"selected_files": "Wybrane pliki",
"no_files_selected": "Nie wybrano plików",
"clear": "Wyczyść"
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"common": {
"note": "заметка",
"file": "файл",
"advanced": "расширенные",
"create": "создать",
"loading": "загрузка",
"mode": "режим",
"views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}",
"minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}",
"max": "макс",
"share_link": "поделиться ссылкой",
"copy_clipboard": "скопировать в буфер обмена",
"copied_to_clipboard": "скопировано в буфер обмена",
"encrypting": "шифрование",
"decrypting": "расшифровка",
"uploading": "загрузка",
"downloading": "скачивание",
"qr_code": "qr код",
"password": "пароль"
},
"home": {
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
"explanation": "заметка истечет и будет уничтожена после {type}.",
"new_note": "новая заметка",
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)",
"errors": {
"note_too_big": "нельзя создать новую заметку. заметка слишком большая",
"note_error": "нельзя создать новую заметку. пожалуйста попробуйте позже.",
"max": "макс: {n}",
"empty_content": "пустая заметка."
},
"messages": {
"note_created": "заметка создана."
},
"advanced": {
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
"custom_password": "пользовательский пароль"
},
"pasting": "Вставка...",
"pasted_files": "Вставленные файлы",
"remove": "Удалить"
},
"show": {
"errors": {
"not_found": "заметка не найдена или была удалена.",
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. заметка уничтожена.",
"unsupported_type": "неподдерживаемый тип заметки."
},
"explanation": "щелкните ниже, чтобы показать и удалить заметку, если счетчик достиг предела",
"show_note": "показать заметку",
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
"download_all": "скачать всё",
"links_found": "ссылки внутри заметки:"
},
"file_upload": {
"selected_files": "Выбранные файлы",
"no_files_selected": "Файлы не выбраны",
"clear": "Сброс"
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"common": {
"note": "筆記",
"file": "檔案",
"advanced": "進階",
"create": "創建",
"loading": "載入中",
"mode": "模式",
"views": "{n, plural, =0 {瀏覽次數} =1 {1 次瀏覽} other {# 次瀏覽}}",
"minutes": "{n, plural, =0 {分鐘} =1 {1 分鐘} other {# 分鐘}}",
"max": "最大",
"share_link": "分享連結",
"copy_clipboard": "複製到剪貼板",
"copied_to_clipboard": "已複製到剪貼板",
"encrypting": "加密中",
"decrypting": "解密中",
"uploading": "上傳中",
"downloading": "下載中",
"qr_code": "QR 碼",
"password": "密碼"
},
"home": {
"intro": "輕鬆地以一鍵傳送<i>完全加密</i>的安全筆記或檔案。只需創建筆記並分享連結。",
"explanation": "筆記將在 {type} 後過期並被銷毀。",
"new_note": "新筆記",
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
"errors": {
"note_too_big": "無法創建筆記。筆記過大",
"note_error": "無法創建筆記。請再試一次。",
"max": "最大值:{n}",
"empty_content": "筆記內容為空。"
},
"messages": {
"note_created": "筆記已創建。"
},
"advanced": {
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
"custom_password": "自定義密碼"
},
"pasting": "正在粘貼...",
"pasted_files": "粘貼的檔案",
"remove": "移除"
},
"show": {
"errors": {
"not_found": "筆記未找到或已被刪除。",
"decryption_failed": "密碼錯誤。無法解密。可能是連結已損壞。筆記已被銷毀。",
"unsupported_type": "不支持的筆記類型。"
},
"explanation": "如果計數器達到限制,請點擊下方以顯示並刪除筆記",
"show_note": "顯示筆記",
"warning_will_not_see_again": "您將<b>無法</b>再次查看筆記。",
"download_all": "全部下載",
"links_found": "在筆記中找到的連結:"
},
"file_upload": {
"selected_files": "已選擇的檔案",
"no_files_selected": "未選擇檔案",
"clear": "重置"
}
}
+61
View File
@@ -0,0 +1,61 @@
{
"common": {
"note": "密信",
"file": "上传文件",
"advanced": "高级设置",
"create": "创建",
"loading": "加载中",
"mode": "模式",
"views": "{n, plural, =0 {可读次数} =1 {1 次查看} other {# 次查看}}",
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
"max": "最大值",
"share_link": "分享链接",
"copy_clipboard": "复制到粘贴板",
"copied_to_clipboard": "已成功复制到粘贴板",
"encrypting": "加密",
"decrypting": "解密",
"uploading": "上传",
"downloading": "下载",
"qr_code": "二维码",
"password": "密码"
},
"home": {
"intro": "飞鸽传书,一键传输完全加密的密信或文件,阅后即焚。",
"explanation": "该密信会在{type}后失效。",
"new_note": "新建密信",
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
"errors": {
"note_too_big": "创建失败,密信过大。",
"note_error": "创建失败,请稍后重试。",
"max": "次数上限:{n}",
"empty_content": "密信不能为空。"
},
"messages": {
"note_created": "密信创建成功。"
},
"advanced": {
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
"custom_password": "自定义密码"
},
"pasting": "正在粘贴...",
"pasted_files": "粘贴的文件",
"remove": "移除"
},
"show": {
"errors": {
"not_found": "密信不存在,可能已被查看或删除。",
"decryption_failed": "密钥错误,无法查看。",
"unsupported_type": "不支持的类型。"
},
"explanation": "点击下方按钮即可查看密信,阅后即焚。",
"show_note": "查看密信",
"warning_will_not_see_again": "你将<b>无法</b>再次查看该密信,请尽快复制到粘贴板。",
"download_all": "下载全部",
"links_found": "注释中找到的链接:"
},
"file_upload": {
"selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中",
"clear": "重置"
}
}
-50
View File
@@ -1,50 +0,0 @@
{
"common": {
"note": "密信",
"file": "上传文件",
"advanced": "高级设置",
"create": "创建",
"loading": "加载中",
"mode": "模式",
"views": "{n, plural, =0 {可查看次数} =1 {1 次查看} other {# 次查看}}",
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
"max": "最大值",
"share_link": "分享链接",
"copy_clipboard": "复制到剪切版",
"copied_to_clipboard": "已复制到剪切板",
"encrypting": "加密",
"decrypting": "解密",
"uploading": "上传",
"downloading": "下载"
},
"home": {
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
"explanation": "该密信会在{type}后失效。",
"new_note": "新建密信",
"new_note_notice": "<b>可用性警示:</b><br />由于加密鸽的所有数据是全部保存在内存中的,所以如果加密鸽的可用内存被用光了那么它将会删除最早的密信以释放内存,因此不保证该密信的可用性。<br />(一般情况下是您应该是不会遇到这个问题,只是警示一下。)",
"errors": {
"note_to_big": "无法创建密信,这个密信太大了!",
"note_error": "无法创建密信,请再试一遍。",
"max": "最大文件大小: {n}",
"empty_content": "密信为空!"
},
"messages": {
"note_created": "注释创建。"
}
},
"show": {
"errors": {
"not_found": "该密信无法被找到或者它已经被删除了!",
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!",
"unsupported_type": "不支持的票据类型。"
},
"explanation": "点击下方的按钮可以查看密信,如果它到达了限制将会被删除",
"show_note": "查看密信",
"warning_will_not_see_again": "您将<b>无法</b>再次查看该密信",
"download_all": "下载全部"
},
"file_upload": {
"selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中"
}
}
+20 -23
View File
@@ -1,37 +1,34 @@
{ {
"private": true, "private": true,
"name": "@cryptgeon/web",
"scripts": { "scripts": {
"postinstall": "svelte-kit sync",
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview --port 3000", "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": "^7.3.1", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/adapter-static": "1.0.0-next.42", "@sveltejs/kit": "^2.70.3",
"@sveltejs/kit": "1.0.0-next.480", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@types/dompurify": "^2.3.3", "@zerodevx/svelte-toast": "^0.9.6",
"@types/file-saver": "^2.0.5", "license-checker-rseidelsohn": "^5.0.1",
"@zerodevx/svelte-toast": "^0.7.2", "svelte": "^5.57.0",
"adm-zip": "^0.5.9", "svelte-check": "^4.7.6",
"dotenv": "^16.0.1", "svelte-intl-precompile": "^0.12.3",
"svelte": "^3.49.0", "tslib": "^2.8.1",
"svelte-check": "^2.8.0", "typescript": "^6.0.3",
"svelte-intl-precompile": "^0.10.1", "vite": "^8.2.2"
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.0",
"typescript": "^4.7.4",
"vite": "^3.0.2"
}, },
"dependencies": { "dependencies": {
"@fontsource/fira-mono": "^4.5.8", "@cryptgeon/shared": "workspace:*",
"copy-to-clipboard": "^3.3.1", "@fontsource/fira-mono": "^5.3.0",
"dompurify": "^2.3.10", "comlink": "^4.4.2",
"file-saver": "^2.0.5", "pretty-bytes": "^7.1.3",
"pretty-bytes": "^5.6.0" "uqr": "^0.1.3"
} }
} }
-54
View File
@@ -1,54 +0,0 @@
import dotenv from 'dotenv'
import { LokaliseApi } from '@lokalise/node-api'
import https from 'https'
import AdmZip from 'adm-zip'
dotenv.config()
const apiKey = process.env.LOKALISE_API_KEY
const project_id = process.env.LOKALISE_PROJECT
if (!apiKey) throw new Error('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
if (!project_id) throw new Error('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)
})
+13 -1
View File
@@ -87,10 +87,12 @@ button {
font-size: inherit; font-size: inherit;
background: inherit; background: inherit;
color: inherit; color: inherit;
border: none;
padding-inline: initial;
} }
*:disabled, *:disabled,
*[disabled='true'] { .disabled {
opacity: 0.5; opacity: 0.5;
} }
@@ -124,3 +126,13 @@ fieldset {
.tr { .tr {
text-align: right; text-align: right;
} }
hr {
border: none;
border-bottom: 2px solid var(--ui-bg-1);
margin: 1rem 0;
}
p {
margin: 0;
}
+9
View File
@@ -0,0 +1,9 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
-3
View File
@@ -1,3 +0,0 @@
/// <reference types="@sveltejs/kit" />
/// <reference types="svelte" />
/// <reference types="vite/client" />
-61
View File
@@ -1,61 +0,0 @@
import type { EncryptedFileDTO, FileDTO } from './api'
import { Crypto } from './crypto'
abstract class CryptAdapter<T> {
abstract encrypt(plaintext: T, key: CryptoKey): Promise<string>
abstract decrypt(ciphertext: string, key: CryptoKey): Promise<T>
}
class CryptTextAdapter implements CryptAdapter<string> {
async encrypt(plaintext: string, key: CryptoKey) {
return await Crypto.encrypt(new TextEncoder().encode(plaintext), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new TextDecoder().decode(plaintext)
}
}
class CryptBlobAdapter implements CryptAdapter<Blob> {
async encrypt(plaintext: Blob, key: CryptoKey) {
return await Crypto.encrypt(await plaintext.arrayBuffer(), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new Blob([plaintext], { type: 'application/octet-stream' })
}
}
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
async encrypt(plaintext: FileDTO[], key: CryptoKey) {
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: CryptoKey) {
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(),
}
-78
View File
@@ -1,78 +0,0 @@
export type NoteMeta = { type: 'text' | 'file' }
export type Note = {
contents: string
meta: NoteMeta
views?: number
expiration?: number
}
export type NoteInfo = {}
export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: Blob
}
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string
}
type CallOptions = {
url: string
method: string
body?: any
}
export class PayloadToLargeError extends Error {}
export async function call(options: CallOptions) {
const response = await fetch('/api/' + options.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()
}
export 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 }
}
export async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
})
const { contents, meta } = data
return {
contents,
meta: JSON.parse(meta) as NoteMeta,
}
}
export async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
})
return data
}
-89
View File
@@ -1,89 +0,0 @@
export class Hex {
static encode(buffer: ArrayBuffer): string {
let s = ''
for (const i of new Uint8Array(buffer)) {
s += i.toString(16).padStart(2, '0')
}
return s
}
static decode(s: string): ArrayBuffer {
const size = s.length / 2
const buffer = new Uint8Array(size)
for (let i = 0; i < size; i++) {
const idx = i * 2
const segment = s.slice(idx, idx + 2)
buffer[i] = parseInt(segment, 16)
}
return buffer
}
}
export class ArrayBufferUtils {
static async toString(buffer: ArrayBuffer): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(new Blob([buffer]))
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
static async fromString(s: string): Promise<ArrayBuffer> {
return fetch(s)
.then((r) => r.blob())
.then((b) => b.arrayBuffer())
}
}
export class Keys {
public static async generateKey(size: 128 | 192 | 256 = 256): Promise<CryptoKey> {
const key = await window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: size,
},
true,
['encrypt', 'decrypt']
)
return key
}
public static async export(key: CryptoKey): Promise<string> {
return Hex.encode(await window.crypto.subtle.exportKey('raw', key))
}
public static async import(key: string): Promise<CryptoKey> {
return window.crypto.subtle.importKey('raw', Hex.decode(key), { name: 'AES-GCM' }, true, [
'encrypt',
'decrypt',
])
}
}
export class Crypto {
private static ALG = 'AES-GCM'
private static DELIMITER = ':::'
public static getRandomBytes(size: number): Uint8Array {
return window.crypto.getRandomValues(new Uint8Array(size))
}
public static async encrypt(plaintext: ArrayBuffer, key: CryptoKey): Promise<string> {
const iv = this.getRandomBytes(12) // AES-GCM needs a 96bit IV
const encrypted: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: this.ALG, iv },
key,
plaintext
)
const data = [Hex.encode(iv), await ArrayBufferUtils.toString(encrypted)].join(this.DELIMITER)
return data
}
public static async decrypt(ciphertext: string, key: CryptoKey): Promise<ArrayBuffer> {
const splitted = ciphertext.split(this.DELIMITER)
const iv = Hex.decode(splitted[0])
const encrypted = await ArrayBufferUtils.fromString(splitted[1])
const plaintext = await window.crypto.subtle.decrypt({ name: this.ALG, iv }, key, encrypted)
return plaintext
}
}
@@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512" <svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><title>Contrast</title><path ><title>Contrast</title><path
d="M256 32C132.29 32 32 132.29 32 256s100.29 224 224 224 224-100.29 224-224S379.71 32 256 32zM128.72 383.28A180 180 0 01256 76v360a178.82 178.82 0 01-127.28-52.72z" d="M256 32C132.29 32 32 132.29 32 256s100.29 224 224 224 224-100.29 224-224S379.71 32 256 32zM128.72 383.28A180 180 0 01256 76v360a178.82 178.82 0 01-127.28-52.72z"

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 316 B

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

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 354 B

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

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 765 B

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

Before

Width:  |  Height:  |  Size: 483 B

After

Width:  |  Height:  |  Size: 512 B

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

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 761 B

+10 -9
View File
@@ -1,24 +1,25 @@
import { call } from '$lib/api' import { status as apiStatus } from '@cryptgeon/shared'
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
export type Status = { export type StatusInfo = {
version: string version: string
max_size: number max_size: number
max_views: number max_views: number
max_expiration: number max_expiration: number
allow_advanced: boolean allow_advanced: boolean
allow_files: boolean
imprint_url: string
imprint_html: string
theme_image: string theme_image: string
theme_text: string theme_text: string
theme_favicon: string
theme_page_title: string theme_page_title: string
theme_favicon: string
theme_new_note_notice: boolean
theme_home_link: boolean
} }
export const status = writable<null | Status>(null) export const status = writable<null | StatusInfo>(null)
export async function init() { export async function init() {
const data = await call({ status.set((await apiStatus()) as StatusInfo)
url: 'status/',
method: 'get',
})
status.set(data)
} }
+3 -3
View File
@@ -1,11 +1,11 @@
import { toast, type SvelteToastOptions } from '@zerodevx/svelte-toast' import { toast } from '@zerodevx/svelte-toast'
export enum NotifyType { export enum NotifyType {
Success = 'success', Success = 'success',
Error = 'error', Error = 'error',
} }
const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = { const themeMapping: Record<NotifyType, Record<string, string>> = {
[NotifyType.Success]: { [NotifyType.Success]: {
'--toastBackground': 'var(--ui-clr-primary)', '--toastBackground': 'var(--ui-clr-primary)',
'--toastBarBackground': 'var(--ui-clr-primary-alt)', '--toastBarBackground': 'var(--ui-clr-primary-alt)',
@@ -17,7 +17,7 @@ const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
} }
function notifyFN(message: string, type: NotifyType = NotifyType.Success) { function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
const options: SvelteToastOptions = { const options = {
duration: 5_000, duration: 5_000,
theme: { theme: {
...themeMapping[type], ...themeMapping[type],
@@ -1,10 +1,17 @@
<script lang="ts"> <script lang="ts">
export let title: string import type { Snippet } from 'svelte'
interface Props {
title: string
children?: Snippet
}
let { title, children }: Props = $props()
</script> </script>
<p> <p>
<b>▶ {title}</b> <b>▶ {title}</b>
<slot /> {@render children?.()}
</p> </p>
<style> <style>
@@ -1,16 +1,31 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import type { Note } from '$lib/api'
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'
export let note: Note interface Props {
export let timeExpiration = false note: { views: number; expiration: number }
timeExpiration?: boolean
customPassword?: string | null
}
let {
note = $bindable(),
timeExpiration = $bindable(false),
customPassword = $bindable(null),
}: Props = $props()
let hasCustomPassword = $state(false)
$effect(() => {
if (!hasCustomPassword) customPassword = null
})
</script> </script>
<div class="fields"> <div class="flex col">
<div class="flex">
<TextInput <TextInput
data-testid="field-views" data-testid="field-views"
type="number" type="number"
@@ -18,18 +33,17 @@
bind:value={note.views} bind:value={note.views}
disabled={timeExpiration} disabled={timeExpiration}
max={$status?.max_views} max={$status?.max_views}
min={1}
validate={(v) => validate={(v) =>
($status && v <= $status?.max_views) || ($status && v <= $status?.max_views && v > 0) ||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })} $t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
/> />
<div class="middle-switch">
<Switch <Switch
data-testid="switch-advanced-toggle" data-testid="switch-advanced-toggle"
label={$t('common.mode')} label={$t('common.mode')}
bind:value={timeExpiration} bind:value={timeExpiration}
color={false} color={false}
/> />
</div>
<TextInput <TextInput
data-testid="field-expiration" data-testid="field-expiration"
type="number" type="number"
@@ -42,13 +56,36 @@
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })} $t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
/> />
</div> </div>
<div class="flex">
<Switch
data-testid="custom-password"
bind:value={hasCustomPassword}
label={$t('home.advanced.custom_password')}
/>
<TextInput
data-testid="password"
type="password"
bind:value={customPassword}
label={$t('common.password')}
disabled={!hasCustomPassword}
random
/>
</div>
<div>
{$t('home.advanced.explanation')}
</div>
</div>
<style> <style>
.middle-switch { .flex {
margin: 0 1rem; display: flex;
align-items: flex-end;
gap: 1rem;
width: 100%;
} }
.fields { .col {
display: flex; gap: 1.5rem;
flex-direction: column;
} }
</style> </style>
+11 -1
View File
@@ -1,4 +1,14 @@
<button {...$$restProps} on:click><slot /></button> <script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements'
interface Props {
children?: import('svelte').Snippet
}
let { children, ...rest }: HTMLButtonAttributes & Props = $props()
</script>
<button {...rest}>{@render children?.()}</button>
<style> <style>
button { button {
+16 -10
View File
@@ -1,26 +1,32 @@
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import type { FileDTO } from '$lib/api'
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'
export let label: string = '' interface Props {
export let files: FileDTO[] = [] label?: string
files?: FileDTO[]
[key: string]: any
}
function fileToDTO(file: File): FileDTO { let { label = '', files = $bindable([]), ...rest }: Props = $props()
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: file,
} }
} }
async function onInput(e: Event) { async function onInput(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
if (input?.files?.length) { if (input?.files?.length) {
files = [...files, ...Array.from(input.files).map(fileToDTO)] const toAdd = await Promise.all(Array.from(input.files).map(fileToDTO))
files = [...files, ...toAdd]
} }
} }
@@ -34,7 +40,7 @@
<small> <small>
{label} {label}
</small> </small>
<input {...$$restProps} type="file" on:change={onInput} multiple /> <input {...rest} type="file" onchange={onInput} multiple />
<div class="box"> <div class="box">
{#if files.length} {#if files.length}
<div> <div>
@@ -44,8 +50,8 @@
{file.name} {file.name}
</div> </div>
{/each} {/each}
<div class="spacer" /> <div class="spacer"></div>
<Button on:click={clear}>Clear</Button> <Button onclick={clear}>{$t('file_upload.clear')}</Button>
</div> </div>
{:else} {:else}
<div> <div>
+13 -7
View File
@@ -1,9 +1,10 @@
<script lang="ts" context="module"> <script lang="ts" module>
import IconContrast from '$lib/icons/IconContrast.svelte' import IconContrast from '$lib/icons/IconContrast.svelte'
import IconCopy from '$lib/icons/IconCopy.svelte' import IconCopy from '$lib/icons/IconCopy.svelte'
import IconDice from '$lib/icons/IconDice.svelte' import IconDice from '$lib/icons/IconDice.svelte'
import IconEye from '$lib/icons/IconEye.svelte' import IconEye from '$lib/icons/IconEye.svelte'
import IconEyeOff from '$lib/icons/IconEyeOff.svelte' import IconEyeOff from '$lib/icons/IconEyeOff.svelte'
import type { HTMLButtonAttributes } from 'svelte/elements'
const map = { const map = {
contrast: IconContrast, contrast: IconContrast,
@@ -15,22 +16,27 @@
</script> </script>
<script lang="ts"> <script lang="ts">
export let icon: keyof typeof map interface Props {
icon: keyof typeof map
}
let { icon, ...rest }: HTMLButtonAttributes & Props = $props()
</script> </script>
<div on:click {...$$restProps}> <button type="button" {...rest}>
{#if map[icon]} {#if map[icon]}
<svelte:component this={map[icon]} /> {@const SvelteComponent = map[icon]}
<SvelteComponent />
{/if} {/if}
</div> </button>
<style> <style>
div { button {
display: inline-block; display: inline-block;
contain: strict; contain: strict;
box-sizing: content-box; box-sizing: content-box;
} }
div > :global(svg) { button > :global(svg) {
display: block; display: block;
fill: currentColor; fill: currentColor;
} }
@@ -1,3 +1,5 @@
<script lang="ts"></script>
<svg <svg
version="1.1" version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 813 B

+2 -4
View File
@@ -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}
+29 -7
View File
@@ -1,17 +1,28 @@
<script lang="ts" context="module"> <script lang="ts" module>
export type NoteResult = { export type NoteResult = {
password: string
id: string id: string
password?: string
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
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 QR from './QR.svelte'
export let result: NoteResult interface Props {
result: NoteResult
}
let { result }: Props = $props()
let url = $derived.by(() => {
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()
@@ -22,16 +33,27 @@
type="text" type="text"
readonly readonly
label={$t('common.share_link')} label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}" value={url}
copy copy
data-testid="share-link" data-testid="share-link"
/> />
<br />
<div>
<QR value={url} />
</div>
{#if $status?.theme_new_note_notice}
<p> <p>
{@html $t('home.new_note_notice')} {@html $t('home.new_note_notice')}
</p> </p>
{/if}
<br /> <br />
<Button on:click={reset}>{$t('home.new_note')}</Button> <Button onclick={reset}>{$t('home.new_note')}</Button>
<style> <style>
div {
width: min(12rem, 100%);
margin-top: 1rem;
margin-bottom: 1rem;
}
</style> </style>
@@ -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>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import { getCSSVariable } from '$lib/utils'
import { t } from 'svelte-intl-precompile'
import { renderSVG } from 'uqr'
interface Props {
value: string
}
let { value }: Props = $props()
let qr: string | null = $state(null)
$effect(() => {
qr = renderSVG(value, {
ecc: 'Q',
blackColor: getCSSVariable('--ui-bg-0'),
whiteColor: getCSSVariable('--ui-text-0'),
})
})
</script>
<small>{$t('common.qr_code')}</small>
<div>
{#if qr}
{@html qr}
{/if}
</div>
<style>
div {
padding: 0.25rem;
width: fit-content;
border: 2px solid var(--ui-bg-1);
background-color: var(--ui-bg-0);
margin-top: 0.125rem;
overflow: hidden;
aspect-ratio: 1;
}
div :global(svg) {
width: 100%;
height: auto;
}
</style>
+90 -30
View File
@@ -1,63 +1,98 @@
<script lang="ts" context="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) {
const url = window.URL.createObjectURL(file)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = file.name
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
a.remove()
}
</script> </script>
<script lang="ts"> <script lang="ts">
import DOMPurify from 'dompurify'
import { saveAs } from 'file-saver'
import prettyBytes from 'pretty-bytes' import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import type { FileDTO, NotePublic } from '$lib/api'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils' import { copy } from '$lib/utils'
import type { FileDTO } from '@cryptgeon/shared'
export let note: DecryptedNote interface Props {
note: DecryptedNote
}
let { note }: Props = $props()
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[] = [] let files: FileDTO[] = $state([])
$: if (note.meta.type === 'file') {
files = note.contents
}
$: download = () => {
for (const file of files) {
downloadFile(file)
}
}
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)
} }
function contentWithLinks(content: string): string { $effect(() => {
const replaced = content.replace( if (note.meta.type === 'file') {
RE_URL, files = note.contents
(url) => `<a href="${url}" rel="noreferrer">${url}</a>`
)
return DOMPurify.sanitize(replaced, { USE_PROFILES: { html: true } })
} }
})
let download = $derived(() => {
for (const file of files) {
downloadFile(file)
}
})
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
</script> </script>
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p> <p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
<div data-testid="result"> <div data-testid="result">
{#if note.meta.type === 'text'} {#if note.meta.type === 'text'}
<div class="note"> <div class="note">
{@html contentWithLinks(note.contents)} {note.contents}
</div> </div>
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button> <Button onclick={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
{#if links && links.length}
<div class="links">
{$t('show.links_found')}
<ul>
{#each links as link}
<li>
<a href={link} target="_blank" rel="noopener noreferrer">{link}</a>
</li>
{/each}
</ul>
</div>
{/if}
{:else} {:else}
{#each files as file} {#each files as file}
<div class="note file"> <div class="note file">
<b on:click={() => downloadFile(file)}>↓ {file.name}</b> <button onclick={() => downloadFile(file)}>
<small> {file.type} - {prettyBytes(file.size)}</small> <b>↓ {file.name}</b>
</button>
<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 on:click={download}>{$t('show.download_all')}</Button> <Button onclick={download}>{$t('show.download_all')}</Button>
{/if} {/if}
</div> </div>
@@ -90,4 +125,29 @@
.note.file small { .note.file small {
padding-left: 1rem; padding-left: 1rem;
} }
.links {
margin-top: 2rem;
}
.links ul {
margin: 0;
padding: 0;
margin-top: 0.5rem;
padding-left: 1rem;
list-style: square;
}
.links ul li {
margin-bottom: 0.5rem;
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>
+22 -25
View File
@@ -1,46 +1,43 @@
<script lang="ts"> <script lang="ts">
export let label: string = '' interface Props {
export let value: boolean label?: string
export let color = true value: boolean
color?: boolean
[key: string]: any
}
let { label = '', value = $bindable(), color = true, ...rest }: Props = $props()
</script> </script>
<div {...$$restProps}> <label {...rest}>
<label class="switch">
<small>{label}</small> <small>{label}</small>
<input type="checkbox" bind:checked={value} /> <input type="checkbox" bind:checked={value} />
<span class:color class="slider" /> <span class:color class="slider"></span>
</label> </label>
</div>
<style> <style>
div { label {
height: 3.75rem;
}
.switch {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 4rem;
height: 2.5rem;
} }
.switch input { label input {
opacity: 0; display: none;
width: 0; }
height: 0;
small {
display: block;
width: max-content;
} }
.slider { .slider {
position: absolute; display: block;
width: 4rem;
height: 2.5rem;
position: relative;
cursor: pointer; cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
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);
transition: var(--ui-anim);
transform: translateY(1.2rem);
} }
.slider:before { .slider:before {
+8 -3
View File
@@ -1,11 +1,16 @@
<script lang="ts"> <script lang="ts">
export let label: string = '' interface Props {
export let value: string label?: string
value: string
[key: string]: any
}
let { label = '', value = $bindable(), ...rest }: Props = $props()
</script> </script>
<label> <label>
<small> <small>
{label} {label}
</small> </small>
<textarea class="box" {...$$restProps} bind:value /> <textarea class="box" {...rest} bind:value></textarea>
</label> </label>
+45 -23
View File
@@ -1,50 +1,71 @@
<script lang="ts"> <script lang="ts">
import { Crypto, Hex } from '$lib/crypto'
import Icon from '$lib/ui/Icon.svelte' import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils' import { copy as copyFN } from '$lib/utils'
import { randomBytes, bytesToHex } from '@cryptgeon/shared'
import type { HTMLInputAttributes } from 'svelte/elements'
export let label: string = '' interface Props {
export let value: any label?: string
export let validate: (value: any) => boolean | string = () => true value: any
export let copy: boolean = false validate?: (value: any) => boolean | string
export let random: boolean = false copy?: boolean
random?: boolean
const initialType = $$restProps.type
const isPassword = initialType === 'password'
let hidden = true
$: valid = validate(value)
$: if (isPassword) {
value
$$restProps.type = hidden ? initialType : 'text'
} }
let {
label = '',
value = $bindable(),
validate = () => true,
copy = false,
random = false,
...rest
}: HTMLInputAttributes & Props = $props()
// svelte-ignore state_referenced_locally
const initialType = $state(rest.type)
const isPassword = initialType === 'password'
let hidden = $state(true)
let valid = $derived(validate(value))
let type = $derived(isPassword ? (hidden ? 'password' : 'text') : rest.type)
function toggle() { function toggle() {
console.debug('toggle')
hidden = !hidden hidden = !hidden
} }
function randomFN() {
value = Hex.encode(Crypto.getRandomBytes(32)) async function randomFN() {
value = bytesToHex(randomBytes(32))
} }
</script> </script>
<label> <label>
<small disabled={$$restProps.disabled}> <small class:disabled={rest.disabled}>
{label} {label}
{#if valid !== true} {#if valid !== true}
<span class="error-text">{valid}</span> <span class="error-text">{valid}</span>
{/if} {/if}
</small> </small>
<input bind:value {...$$restProps} 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 class="icon" icon={hidden ? 'eye' : 'eye-off'} on:click={toggle} /> <Icon
disabled={rest.disabled}
class="icon"
icon={hidden ? 'eye' : 'eye-off'}
onclick={toggle}
/>
{/if} {/if}
{#if random} {#if random}
<Icon class="icon" icon="dice" on:click={randomFN} /> <Icon disabled={rest.disabled} class="icon" icon="dice" onclick={randomFN} />
{/if} {/if}
{#if copy} {#if copy}
<Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} /> <Icon
disabled={rest.disabled}
class="icon"
icon="copy"
onclick={() => copyFN(value.toString())}
/>
{/if} {/if}
</div> </div>
</label> </label>
@@ -53,6 +74,7 @@
label { label {
position: relative; position: relative;
display: block; display: block;
width: 100%;
} }
label > small { label > small {
+13 -16
View File
@@ -1,24 +1,21 @@
<script lang="ts" context="module"> <script lang="ts" module>
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
enum Theme { const themes = ['dark', 'light', 'auto'] as const
Dark = 'dark', type Theme = (typeof themes)[number]
Light = 'light',
Auto = 'auto',
}
const NextTheme = { const NextTheme: Record<Theme, Theme> = {
[Theme.Auto]: Theme.Light, auto: 'light',
[Theme.Light]: Theme.Dark, light: 'dark',
[Theme.Dark]: Theme.Auto, dark: 'auto',
} }
function init(): Theme { function init(): Theme {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const saved = window.localStorage.getItem('theme') as Theme const saved = window.localStorage.getItem('theme') as Theme
if (Object.values(Theme).includes(saved)) return saved if (themes.includes(saved)) return saved
} }
return Theme.Auto return 'auto'
} }
export const theme = writable<Theme>(init()) export const theme = writable<Theme>(init())
@@ -40,19 +37,19 @@
} }
</script> </script>
<div on:click={change}> <button onclick={change}>
<Icon class="icon" icon="contrast" /> <Icon class="icon" icon="contrast" />
{$theme} {$theme}
</div> </button>
<style> <style>
div :global(.icon) { button :global(.icon) {
height: 1rem; height: 1rem;
width: 1rem; width: 1rem;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
div { button {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
+6 -2
View File
@@ -1,11 +1,15 @@
import copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { notify } from './toast' import { notify } from './toast'
export function copy(value: string) { export function copy(value: string) {
copyToClipboard(value) window.navigator.clipboard.writeText(value)
const msg = get(t)('common.copied_to_clipboard') const msg = get(t)('common.copied_to_clipboard')
notify.success(msg) notify.success(msg)
} }
export function getCSSVariable(variable: string): string {
if (typeof window === 'undefined') return ''
return window.getComputedStyle(window.document.body).getPropertyValue(variable)
}
+167 -50
View File
@@ -1,11 +1,16 @@
<script lang="ts"> <script lang="ts">
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 { Adapters } from '$lib/adapters'
import type { FileDTO, Note } from '$lib/api'
import { create, PayloadToLargeError } from '$lib/api'
import { Keys } from '$lib/crypto'
import { status } from '$lib/stores/status' import { status } from '$lib/stores/status'
import { notify } from '$lib/toast' import { notify } from '$lib/toast'
import AdvancedParameters from '$lib/ui/AdvancedParameters.svelte' import AdvancedParameters from '$lib/ui/AdvancedParameters.svelte'
@@ -14,29 +19,32 @@
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 { createWorker } from '$lib/worker'
import { onMount } from 'svelte'
let note: Note = { let note: { views: number; expiration: number } = $state({ views: 1, expiration: 60 })
contents: '', let files: FileDTO[] = $state([])
meta: { type: 'text' }, let result: NoteResult | null = $state(null)
views: 1, let advanced = $state(false)
expiration: 60, let isFile = $state(false)
} let timeExpiration = $state(false)
let files: FileDTO[] let customPassword: string | null = $state(null)
let result: NoteResult | null = null let description = $state('')
let advanced = false let loading: string | null = $state(null)
let isFile = false let isPasting = $state(false)
let timeExpiration = false let textContent = $state('')
let description = ''
let loading: string | null = null
$: if (!advanced) { $effect(() => {
if (!advanced) {
note.views = 1 note.views = 1
timeExpiration = false timeExpiration = false
} }
})
$: { $effect(() => {
description = $t('home.explanation', { description = $t('home.explanation', {
values: { values: {
type: $t(timeExpiration ? 'common.minutes' : 'common.views', { type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
@@ -44,53 +52,114 @@
}), }),
}, },
}) })
})
$effect(() => {
if (!isFile) textContent = ''
})
const worker = createWorker()
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))
} }
$: note.meta.type = isFile ? 'file' : 'text' 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 (!isFile) { if (raw.length === 0) return
note.contents = '' 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 {}
async function submit() { async function submit(e: SubmitEvent) {
e.preventDefault()
try { try {
loading = $t('common.encrypting') loading = $t('common.encrypting')
const key = await Keys.generateKey()
const password = await Keys.export(key)
const data: Note = {
contents: '',
meta: note.meta,
}
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 create(data) const response = await apiCreate(serverNote)
result = { result = {
password: password,
id: response.id, id: response.id,
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) {
notify.error($t('home.errors.note_to_big'))
} else if (e instanceof EmptyContentError) {
notify.error($t('home.errors.empty_content'))
} else {
console.error(e) console.error(e)
notify.error($t('home.errors.note_error')) notify.error($t('home.errors.note_error'))
}
} finally { } finally {
loading = null loading = null
} }
@@ -103,26 +172,38 @@
<p> <p>
{@html $status?.theme_text || $t('home.intro')} {@html $status?.theme_text || $t('home.intro')}
</p> </p>
<form on:submit|preventDefault={submit}> <form onsubmit={submit} onpaste={handlePaste}>
<fieldset disabled={loading !== null}> <fieldset disabled={loading !== null}>
<div class="paste-indicator">
{#if isPasting}
<div class="pasting-overlay">
<div class="pasting-spinner"></div>
<span>{$t('home.pasting')}</span>
</div>
{/if}
{#if isFile} {#if isFile}
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files /> <FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
{:else} {:else}
<TextArea <TextArea
data-testid="text-field" data-testid="text-field"
label={$t('common.note')} label={$t('common.note')}
bind:value={note.contents} bind:value={textContent}
placeholder="..." placeholder="..."
/> />
{/if} {/if}
<PastedFilesPreview bind:files />
</div>
<div class="bottom"> <div class="bottom">
{#if $status?.allow_files}
<Switch <Switch
data-testid="switch-file" data-testid="switch-file"
class="file" class="file"
label={$t('common.file')} label={$t('common.file')}
bind:value={isFile} bind:value={isFile}
/> />
{/if}
{#if $status?.allow_advanced} {#if $status?.allow_advanced}
<Switch <Switch
data-testid="switch-advanced" data-testid="switch-advanced"
@@ -130,7 +211,7 @@
bind:value={advanced} bind:value={advanced}
/> />
{/if} {/if}
<div class="grow" /> <div class="grow"></div>
<div class="tr"> <div class="tr">
<small>{$t('common.max')}: <MaxSize /> </small> <small>{$t('common.max')}: <MaxSize /> </small>
<br /> <br />
@@ -148,9 +229,9 @@
</p> </p>
{#if advanced} {#if advanced}
<div transition:blur={{ duration: 250 }}> <div transition:blur|global={{ duration: 250 }}>
<br /> <hr />
<AdvancedParameters bind:note bind:timeExpiration /> <AdvancedParameters bind:note bind:timeExpiration bind:customPassword />
</div> </div>
{/if} {/if}
</fieldset> </fieldset>
@@ -171,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>
+12 -1
View File
@@ -1,13 +1,23 @@
<script lang="ts"> <script lang="ts">
import ThemeToggle from '$lib/ui/ThemeToggle.svelte' import ThemeToggle from '$lib/ui/ThemeToggle.svelte'
import { status } from '$lib/stores/status'
</script> </script>
<footer> <footer>
<ThemeToggle /> <ThemeToggle />
<nav> <nav>
{#if $status?.theme_home_link !== false}
<a href="/">/home</a> <a href="/">/home</a>
{/if}
<a href="/about">/about</a> <a href="/about">/about</a>
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener">/code</a> {#if $status?.imprint_url}
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
{:else if $status?.imprint_html}
<a href="/imprint">/imprint</a>
{/if}
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
code
</a>
</nav> </nav>
</footer> </footer>
@@ -21,6 +31,7 @@
right: 0; right: 0;
width: 100%; width: 100%;
background-color: var(--ui-bg-0-85); background-color: var(--ui-bg-0-85);
backdrop-filter: blur(2px);
} }
a { a {

Some files were not shown because too many files have changed in this diff Show More