Compare commits

..

52 Commits

Author SHA1 Message Date
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
73 changed files with 4188 additions and 2691 deletions
+9 -9
View File
@@ -10,10 +10,10 @@ jobs:
cli: cli:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v3 - uses: actions/setup-node@v4
with: with:
cache: 'pnpm' cache: 'pnpm'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
@@ -31,14 +31,14 @@ jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v2 - uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v4
with: with:
install: true install: true
- name: Docker Labels - name: Docker Labels
id: meta id: meta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v6
with: with:
images: cupcakearmy/cryptgeon images: cupcakearmy/cryptgeon
tags: | tags: |
@@ -46,12 +46,12 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v2 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v7
with: with:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
+3 -3
View File
@@ -13,15 +13,15 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Node # Node
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v6
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
cache: 'pnpm' cache: 'pnpm'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
# Docker # Docker
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v3 - uses: docker/setup-buildx-action@v4
with: with:
install: true install: true
+1 -1
View File
@@ -1 +1 @@
v22.7.0 v24
+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 redis)
## Setup
```bash
mise install
pnpm install
```
## Development
```bash
pnpm run dev
```
Make sure docker/colima is running. This starts redis, the rust backend, the web client, and the CLI. The app is at [localhost:3000](http://localhost:3000).
## 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.
+3 -3
View File
@@ -1,5 +1,5 @@
# FRONTEND # FRONTEND
FROM node:22-alpine as client FROM node:24-alpine AS client
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable RUN corepack enable
@@ -11,7 +11,7 @@ RUN pnpm run build
# BACKEND # BACKEND
FROM rust:1.80-alpine as backend FROM rust:1.95-alpine AS backend
WORKDIR /tmp WORKDIR /tmp
RUN apk add --no-cache libc-dev openssl-dev alpine-sdk RUN apk add --no-cache libc-dev openssl-dev alpine-sdk
COPY ./packages/backend ./ COPY ./packages/backend ./
@@ -19,7 +19,7 @@ RUN RUSTFLAGS="-Ctarget-feature=-crt-static" cargo build --release
# RUNNER # RUNNER
FROM alpine:3.19 FROM alpine:3
WORKDIR /app WORKDIR /app
RUN apk add --no-cache curl libgcc RUN apk add --no-cache curl libgcc
COPY --from=backend /tmp/target/release/cryptgeon . COPY --from=backend /tmp/target/release/cryptgeon .
+14 -50
View File
@@ -63,6 +63,8 @@ client side with the <code>key</code> and then sent to the server. data is store
never persisted to disk. the server never sees the encryption key and cannot decrypt the contents never persisted to disk. the server never sees the encryption key and cannot decrypt the contents
of the notes even if it tried to. of the notes even if it tried to.
> View counts are guaranteed with one running instance of cryptgeon. Multiple instances connected to the same Redis instance can run into race conditions, where a note might be retrieved more than the view count allows.
## Screenshot ## Screenshot
![screenshot](./design/Screens.png) ![screenshot](./design/Screens.png)
@@ -84,8 +86,9 @@ of the notes even if it tried to.
| `THEME_PAGE_TITLE` | `""` | Custom text the page title | | `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable | | `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. | | `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. | | `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both.| | `IMPRINT_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.
@@ -104,9 +107,14 @@ version: '3.8'
services: services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise. # Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/ # https://redis.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:latest
@@ -155,53 +163,9 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/) - Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/) - English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
## Development ## Contributing
**Requirements** See [CONTRIBUTING.md](./CONTRIBUTING.md).
- `pnpm`: `>=9`
- `node`: `>=22`
- `rust`: edition `2021`
**Install**
```bash
pnpm install
# Also you need cargo watch if you don't already have it installed.
# https://lib.rs/crates/cargo-watch
cargo install cargo-watch
```
**Run**
Make sure you have docker running.
```bash
pnpm run dev
```
Running `pnpm run dev` in the root folder will start the following things:
- redis docker container
- rust backend
- client
- cli
You can see the app under [localhost:3000](http://localhost:3000).
> There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json)
### Tests
Tests are end to end tests written with Playwright.
```sh
pnpm run test:prepare
# Use the test or test:local script. The local version only runs in one browser for quicker development.
pnpm run test:local
```
## Security ## Security
+16 -54
View File
@@ -43,7 +43,7 @@ Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/RE
- enviar texto o archivos - enviar texto o archivos
- el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente - el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente
- restriccion de vistas o de tiempo - restricción de vistas o de tiempo
- en memoria, sin persistencia - en memoria, sin persistencia
- compatibilidad obligatoria con el modo oscuro - compatibilidad obligatoria con el modo oscuro
@@ -66,34 +66,40 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
| `MAX_VIEWS` | `100` | Número máximo de vistas. | | `MAX_VIEWS` | `100` | Número máximo de vistas. |
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. | | `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. | | `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. | | `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es útil para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` | | `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_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_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título | | `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. | | `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. |
## Despliegue ## Despliegue
> ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptacón. > ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación.
> ️ Hay un endpoint para verificar el estado, lo encontramos en `/api/health/`. Regresa un código 200 o 503. > ️ Hay un endpoint para verificar el estado, lo encontramos en `/api/health/`. Regresa un código 200 o 503.
### Docker ### Docker
Docker es la manera más fácil. Aquí encontramos [la imágen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon). Docker es la manera más fácil. Aquí encontramos [la imagen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
version: '3.8' version: "3.8"
services: services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise. # Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/ # https://redis.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:latest
@@ -136,57 +142,13 @@ Hay una [guía](https://mariushosting.com/how-to-install-cryptgeon-on-your-synol
- En inglés, por [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE) - En inglés, por [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
- En alemán, por [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg) - En alemán, por [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
## Desarrollo ## Contribuir
**Requisitos** Ver [CONTRIBUTING.md](./CONTRIBUTING.md).
- `pnpm`: `>=6`
- `node`: `>=18`
- `rust`: edition `2021`
**Instalación**
```bash
pnpm install
# También necesitas cargo-watch, si no lo tienes instalado.
# https://lib.rs/crates/cargo-watch
cargo install cargo-watch
```
**Ejecutar**
Asegurate de que docker se esté ejecutando.
```bash
pnpm run dev
```
Ejecutando `pnpm run dev` en la carpeta raíz iniciará lo siguiente:
- redis docker container
- rust backend
- client
- cli
Puedes ver la app en [localhost:3000](http://localhost:3000).
> Existe una colección de Postman con algunas peticiones de ejemplo [disponible en el repo](./Cryptgeon.postman_collection.json)
### Tests
Los tests son end-to-end tests escritos con Playwright.
```sh
pnpm run test:prepare
# Usa el script test o test:local. La versión local solo corre en el navegador para acelerar el desarrollo.
pnpm run test:local
```
## Seguridad ## Seguridad
Por favor dirigite a la sección de seguridad [aquí](./SECURITY.md). Por favor dirígete a la sección de seguridad [aquí](./SECURITY.md).
--- ---
+30 -59
View File
@@ -47,15 +47,16 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
## 环境变量 ## 环境变量
| 变量名称 | 默认值 | 描述 | | 变量名称 | 默认值 | 描述 |
| ----------------- | ---------------- | --------------------------------------------------------------------------------- | | ---------------- | ---------------- | --------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis 连接 URL。 | | `REDIS` | `redis://redis/` | Redis 连接 URL。 |
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) | | `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 | | `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
| ` MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) | | `MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 | | `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 | | `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 | | `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 |
## 部署 ## 部署
@@ -69,11 +70,19 @@ Docker 是最简单的部署方式。这里是[官方镜像的地址](https://hu
```yaml ```yaml
# docker-compose.yml # docker-compose.yml
version: '3.8' version: "3.8"
services: services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:latest
@@ -99,7 +108,7 @@ services:
- 域名 `example.org` - 域名 `example.org`
```yaml ```yaml
version: '3.8' version: "3.8"
networks: networks:
proxy: proxy:
@@ -108,7 +117,14 @@ networks:
services: services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped # This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:latest
@@ -125,54 +141,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 正在运行
```bash
pnpm run dev
```
在根目录执行 `pnpm run dev` 会开启下列服务:
- 一个 redis docker 容器
- 无热重载的 rust 后端
- 可热重载的客户端
你可以通过 3000 端口进入该应用,即 [localhost:3000](http://localhost:3000).
## 测试
这些测试是用 Playwright 实现的一些端到端测试用例。
```sh
pnpm run test:prepare
docker compose up redis -d
pnpm run test:server
# 在另一个终端中:
# 使用test或者test:local script。为了更快的开发,本地版本只会在一个浏览器中运行。
pnpm run test:local
```
###### Attributions ###### Attributions
+9 -1
View File
@@ -3,7 +3,15 @@
services: services:
redis: redis:
image: redis:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
ports: ports:
- 6379:6379 - 6379:6379
+8 -3
View File
@@ -1,9 +1,14 @@
services: services:
redis: redis:
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/latest/operate/rs/databases/memory-performance/eviction-policy/ # 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:latest
+10 -2
View File
@@ -2,13 +2,21 @@ version: '3.8'
services: services:
redis: redis:
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:latest
depends_on: depends_on:
- redis - redis
proxy: proxy:
image: nginx:alpine image: nginx:alpine
depends_on: depends_on:
+9 -2
View File
@@ -108,8 +108,15 @@ networks:
services: services:
redis: redis:
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:latest
+18 -3
View File
@@ -17,8 +17,15 @@ networks:
services: services:
redis: redis:
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:latest
@@ -54,7 +61,15 @@ services:
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
redis: redis:
image: redis:7-alpine image: valkey/valkey:7-alpine
# This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
cryptgeon: cryptgeon:
image: cupcakearmy/cryptgeon image: cupcakearmy/cryptgeon
+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"
+5 -4
View File
@@ -8,14 +8,15 @@
"test": "playwright test --project=chrome --project=firefox --project=safari", "test": "playwright test --project=chrome --project=firefox --project=safari",
"test:local": "playwright test --project=chrome", "test:local": "playwright test --project=chrome",
"test:server": "run-s docker:up", "test:server": "run-s docker:up",
"test:prepare": "run-p build docker:build", "test:dl-browsers": "playwright install",
"test:prepare": "run-p test:dl-browsers build docker:build",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build" "build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.46.1", "@playwright/test": "^1.60.0",
"@types/node": "^22.5.0", "@types/node": "^24.12.4",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"shelljs": "^0.8.5" "shelljs": "^0.8.5"
}, },
"packageManager": "pnpm@9.11.0" "packageManager": "pnpm@11.5.0"
} }
+783 -431
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,9 +1,9 @@
[package] [package]
name = "cryptgeon" name = "cryptgeon"
version = "2.8.4" version = "2.9.2"
authors = ["cupcakearmy <hi@nicco.io>"] authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021" edition = "2024"
rust-version = "1.80" rust-version = "1.95"
[[bin]] [[bin]]
name = "cryptgeon" name = "cryptgeon"
@@ -11,17 +11,17 @@ path = "src/main.rs"
[dependencies] [dependencies]
# Core # Core
axum = "0.7.5" axum = "0.8"
serde = { version = "1.0.208", features = ["derive"] } serde = { version = "1", features = ["derive"] }
tokio = { version = "1.39.3", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower = "0.5.0" tower = "0.5"
tower-http = { version = "0.5.2", features = ["full"] } tower-http = { version = "0.6", features = ["full"] }
redis = { version = "0.25.2", features = ["tls-native-tls"] } redis = { version = "1", features = ["tls-native-tls"] }
# Utility # Utility
serde_json = "1" serde_json = "1"
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"
+1 -1
View File
@@ -2,7 +2,7 @@
"private": true, "private": true,
"name": "@cryptgeon/backend", "name": "@cryptgeon/backend",
"scripts": { "scripts": {
"dev": "cargo watch -x 'run --bin cryptgeon'", "dev": "watchexec -r -e rs cargo run",
"build": "cargo build --release", "build": "cargo build --release",
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run", "test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
"test:prepare": "cargo build" "test:prepare": "cargo build"
+4
View File
@@ -70,4 +70,8 @@ lazy_static! {
.unwrap_or("true".to_string()) .unwrap_or("true".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref THEME_HOME_LINK: bool = std::env::var("THEME_HOME_LINK")
.unwrap_or("true".to_string())
.parse()
.unwrap();
} }
+16
View File
@@ -0,0 +1,16 @@
use axum::{body::Body, extract::Request, http::HeaderValue, middleware::Next, response::Response};
const CUSTOM_HEADER_NAME: &str = "Content-Security-Policy";
const CUSTOM_HEADER_VALUE: &str = "default-src 'self'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; object-src 'none'; base-uri 'self'; connect-src 'self' data:; font-src 'self'; frame-src 'self'; img-src 'self'; manifest-src 'self'; media-src 'self'; worker-src 'none';";
lazy_static! {
static ref HEADER_VALUE: HeaderValue = HeaderValue::from_static(CUSTOM_HEADER_VALUE);
}
pub async fn add_csp_header(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.append(CUSTOM_HEADER_NAME, HEADER_VALUE.clone());
response
}
+10
View File
@@ -0,0 +1,10 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct SharedState {
pub locks: LockMap,
}
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
+20 -7
View File
@@ -1,9 +1,13 @@
use std::{collections::HashMap, sync::Arc};
use axum::{ use axum::{
Router, ServiceExt,
extract::{DefaultBodyLimit, Request}, extract::{DefaultBodyLimit, Request},
routing::{delete, get, post}, routing::{delete, get, post},
Router, ServiceExt,
}; };
use dotenv::dotenv; use dotenv::dotenv;
use lock::SharedState;
use tokio::sync::Mutex;
use tower::Layer; use tower::Layer;
use tower_http::{ use tower_http::{
compression::CompressionLayer, compression::CompressionLayer,
@@ -15,7 +19,9 @@ use tower_http::{
extern crate lazy_static; extern crate lazy_static;
mod config; mod config;
mod csp;
mod health; mod health;
mod lock;
mod note; mod note;
mod status; mod status;
mod store; mod store;
@@ -24,21 +30,25 @@ mod store;
async fn main() { async fn main() {
dotenv().ok(); dotenv().ok();
let shared_state = SharedState {
locks: Arc::new(Mutex::new(HashMap::new())),
};
if !store::can_reach_redis() { if !store::can_reach_redis() {
println!("cannot reach redis"); println!("cannot reach redis");
panic!("canont reach redis"); panic!("cannot reach redis");
} }
let notes_routes = Router::new() let notes_routes = Router::new()
.route("/", post(note::create)) .route("/", post(note::create))
.route("/:id", delete(note::delete)) .route("/{id}", delete(note::delete))
.route("/:id", get(note::preview)); .route("/{id}", get(note::preview));
let health_routes = Router::new().route("/live", get(health::report_health)); let health_routes = Router::new().route("/live", get(health::report_health));
let status_routes = Router::new().route("/status", get(status::get_status)); let status_routes = Router::new().route("/status", get(status::get_status));
let api_routes = Router::new() let api_routes = Router::new()
.nest("/notes", notes_routes) .nest("/notes", notes_routes)
.nest("/", health_routes) .merge(health_routes)
.nest("/", status_routes); .merge(status_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html"); let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
let serve_dir = let serve_dir =
@@ -46,6 +56,8 @@ async fn main() {
let app = Router::new() let app = Router::new()
.nest("/api", api_routes) .nest("/api", api_routes)
.fallback_service(serve_dir) .fallback_service(serve_dir)
// Disabled for now, as svelte inlines scripts
// .layer(middleware::from_fn(csp::add_csp_header))
.layer(DefaultBodyLimit::max(*config::LIMIT)) .layer(DefaultBodyLimit::max(*config::LIMIT))
.layer( .layer(
CompressionLayer::new() CompressionLayer::new()
@@ -53,7 +65,8 @@ async fn main() {
.deflate(true) .deflate(true)
.gzip(true) .gzip(true)
.zstd(true), .zstd(true),
); )
.with_state(shared_state);
let app = NormalizePathLayer::trim_trailing_slash().layer(app); let app = NormalizePathLayer::trim_trailing_slash().layer(app);
+15 -5
View File
@@ -5,11 +5,12 @@ use axum::{
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::SystemTime; use std::{sync::Arc, time::SystemTime};
use tokio::sync::Mutex;
use crate::config;
use crate::note::{generate_id, Note, NoteInfo}; use crate::note::{generate_id, Note, NoteInfo};
use crate::store; use crate::store;
use crate::{config, lock::SharedState};
use super::NotePublic; use super::NotePublic;
@@ -80,11 +81,20 @@ pub async fn create(Json(mut n): Json<Note>) -> Response {
} }
} }
pub async fn delete(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response { pub async fn delete(
Path(OneNoteParams { id }): Path<OneNoteParams>,
state: axum::extract::State<SharedState>,
) -> Response {
let mut locks_map = state.locks.lock().await;
let lock = locks_map
.entry(id.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
drop(locks_map);
let _guard = lock.lock().await;
let note = store::get(&id); let note = store::get(&id);
match note { match note {
// Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
// Ok(None) => return HttpResponse::NotFound().finish(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
Ok(None) => (StatusCode::NOT_FOUND).into_response(), Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Ok(Some(note)) => { Ok(Some(note)) => {
+2
View File
@@ -20,6 +20,7 @@ pub struct Status {
pub theme_page_title: String, pub theme_page_title: String,
pub theme_favicon: String, pub theme_favicon: String,
pub theme_new_note_notice: bool, pub theme_new_note_notice: bool,
pub theme_home_link: bool,
} }
pub async fn get_status() -> (StatusCode, Json<Status>) { pub async fn get_status() -> (StatusCode, Json<Status>) {
@@ -33,6 +34,7 @@ pub async fn get_status() -> (StatusCode, Json<Status>) {
imprint_url: config::IMPRINT_URL.to_string(), imprint_url: config::IMPRINT_URL.to_string(),
imprint_html: config::IMPRINT_HTML.to_string(), imprint_html: config::IMPRINT_HTML.to_string(),
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE, theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_home_link: *config::THEME_HOME_LINK,
theme_image: config::THEME_IMAGE.to_string(), theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(), theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(), theme_page_title: config::THEME_PAGE_TITLE.to_string(),
+4 -4
View File
@@ -31,13 +31,13 @@ pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
let serialized = serde_json::to_string(&note.clone()).unwrap(); let serialized = serde_json::to_string(&note.clone()).unwrap();
let mut conn = get_connection()?; let mut conn = get_connection()?;
conn.set(id, serialized) conn.set::<_, _, ()>(id, serialized)
.map_err(|_| "Unable to set note in redis")?; .map_err(|_| "Unable to set note in redis")?;
match note.expiration { match note.expiration {
Some(e) => { Some(e) => {
let seconds = e - now(); let seconds = e - now();
conn.expire(id, seconds as i64) conn.expire::<_, ()>(id, seconds as i64)
.map_err(|_| "Unable to set expiration on notion")? .map_err(|_| "Unable to set expiration on note")?
} }
None => {} None => {}
}; };
@@ -58,6 +58,6 @@ pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
pub fn del(id: &String) -> Result<(), &'static str> { pub fn del(id: &String) -> Result<(), &'static str> {
let mut conn = get_connection()?; let mut conn = get_connection()?;
conn.del(id).map_err(|_| "Unable to delete note in redis")?; conn.del::<_, ()>(id).map_err(|_| "Unable to delete note in redis")?;
Ok(()) Ok(())
} }
+7 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "cryptgeon", "name": "cryptgeon",
"version": "2.8.4", "version": "2.9.2",
"homepage": "https://github.com/cupcakearmy/cryptgeon", "homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": { "repository": {
"type": "git", "type": "git",
@@ -30,16 +30,16 @@
}, },
"devDependencies": { "devDependencies": {
"@commander-js/extra-typings": "^12.1.0", "@commander-js/extra-typings": "^12.1.0",
"@types/inquirer": "^9.0.7", "@types/inquirer": "^9.0.9",
"@types/mime": "^4.0.0", "@types/mime": "^4.0.0",
"@types/node": "^20.11.24", "@types/node": "^20.19.41",
"commander": "^12.1.0", "commander": "^12.1.0",
"inquirer": "^9.2.15", "inquirer": "^9.3.8",
"mime": "^4.0.1", "mime": "^4.1.0",
"occulto": "^2.0.6", "occulto": "^2.0.6",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.1",
"tsup": "^8.2.4", "tsup": "^8.5.1",
"typescript": "^5.3.3" "typescript": "^5.9.3"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
+1
View File
@@ -120,6 +120,7 @@ export type Status = {
theme_favicon: string theme_favicon: string
theme_page_title: string theme_page_title: string
theme_new_note_notice: boolean theme_new_note_notice: boolean
theme_home_link: boolean
} }
async function status() { async function status() {
+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_to_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"
}
}
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.", "explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.",
"custom_password": "Benutzerdefiniertes Passwort" "custom_password": "Benutzerdefiniertes Passwort"
} },
"pasting": "Einfügen...",
"pasted_files": "Eingefügte Dateien",
"remove": "Entfernen"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.", "explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.",
"custom_password": "custom password" "custom_password": "custom password"
} },
"pasting": "Pasting...",
"pasted_files": "Pasted Files",
"remove": "Remove"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.", "explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.",
"custom_password": "contraseña personalizada" "custom_password": "contraseña personalizada"
} },
"pasting": "Pegando...",
"pasted_files": "Archivos pegados",
"remove": "Eliminar"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.", "explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.",
"custom_password": "mot de passe personnalisé" "custom_password": "mot de passe personnalisé"
} },
"pasting": "Collage...",
"pasted_files": "Fichiers collés",
"remove": "Supprimer"
}, },
"show": { "show": {
"errors": { "errors": {
+5 -2
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",
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.", "explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.",
"custom_password": "password personalizzata" "custom_password": "password personalizzata"
} },
"pasting": "Incollando...",
"pasted_files": "File incollati",
"remove": "Rimuovi"
}, },
"show": { "show": {
"errors": { "errors": {
+6 -3
View File
@@ -17,7 +17,7 @@
"uploading": "アップロード中", "uploading": "アップロード中",
"downloading": "ダウンロード中", "downloading": "ダウンロード中",
"qr_code": "QRコード", "qr_code": "QRコード",
"password": "暗号" "password": "パスワード"
}, },
"home": { "home": {
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。", "intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。", "explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
"custom_password": "カスタムパスワード" "custom_password": "カスタムパスワード"
} },
"pasting": "貼り付け中...",
"pasted_files": "貼り付けたファイル",
"remove": "削除"
}, },
"show": { "show": {
"errors": { "errors": {
@@ -46,7 +49,7 @@
}, },
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。", "explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
"show_note": "メモを表示", "show_note": "メモを表示",
"warning_will_not_see_again": "あなた <b>できません</b> このノートをもう一度見る", "warning_will_not_see_again": "このノートを再度表示することは<b>できません</b>",
"download_all": "すべてダウンロード", "download_all": "すべてダウンロード",
"links_found": "メモ内にあるリンク:" "links_found": "メモ内にあるリンク:"
}, },
+7 -4
View File
@@ -6,8 +6,8 @@
"create": "utwórz", "create": "utwórz",
"loading": "ładowanie", "loading": "ładowanie",
"mode": "tryb", "mode": "tryb",
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {# wyświetleń}}", "views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {{n} wyświetleń}}",
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minuty}}", "minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {{n} minuty}}",
"max": "maks.", "max": "maks.",
"share_link": "link udostępniania", "share_link": "link udostępniania",
"copy_clipboard": "kopiuj do schowka", "copy_clipboard": "kopiuj do schowka",
@@ -27,7 +27,7 @@
"errors": { "errors": {
"note_to_big": "nie można utworzyć notatki. notatka jest za duża", "note_to_big": "nie można utworzyć notatki. notatka jest za duża",
"note_error": "nie można utworzyć notatki. spróbuj ponownie.", "note_error": "nie można utworzyć notatki. spróbuj ponownie.",
"max": "maks .: {n}", "max": "maks.: {n}",
"empty_content": "notatka jest pusta." "empty_content": "notatka jest pusta."
}, },
"messages": { "messages": {
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.", "explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.",
"custom_password": "własne hasło" "custom_password": "własne hasło"
} },
"pasting": "Wklejanie...",
"pasted_files": "Wklejone pliki",
"remove": "Usuń"
}, },
"show": { "show": {
"errors": { "errors": {
+61 -58
View File
@@ -1,58 +1,61 @@
{ {
"common": { "common": {
"note": "заметка", "note": "заметка",
"file": "файл", "file": "файл",
"advanced": "расширенные", "advanced": "расширенные",
"create": "создать", "create": "создать",
"loading": "загрузка", "loading": "загрузка",
"mode": "режим", "mode": "режим",
"views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}", "views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}",
"minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}", "minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}",
"max": "макс", "max": "макс",
"share_link": "поделиться ссылкой", "share_link": "поделиться ссылкой",
"copy_clipboard": "скопировать в буфер обмена", "copy_clipboard": "скопировать в буфер обмена",
"copied_to_clipboard": "скопировано в буфер обмена", "copied_to_clipboard": "скопировано в буфер обмена",
"encrypting": "шифрование", "encrypting": "шифрование",
"decrypting": "расшифровка", "decrypting": "расшифровка",
"uploading": "загрузка", "uploading": "загрузка",
"downloading": "скачивание", "downloading": "скачивание",
"qr_code": "qr код", "qr_code": "qr код",
"password": "пароль" "password": "пароль"
}, },
"home": { "home": {
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.", "intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
"explanation": "заметка истечет и будет уничтожена после {type}.", "explanation": "заметка истечет и будет уничтожена после {type}.",
"new_note": "новая заметка", "new_note": "новая заметка",
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)", "new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)",
"errors": { "errors": {
"note_to_big": "нельзя создать новую заметку. заметка слишком большая", "note_to_big": "нельзя создать новую заметку. заметка слишком большая",
"note_error": "нельзя создать новую заметку. пожалйста попробуйте позднее.", "note_error": "нельзя создать новую заметку. пожалуйста попробуйте позже.",
"max": "макс: {n}", "max": "макс: {n}",
"empty_content": "пустая заметка." "empty_content": "пустая заметка."
}, },
"messages": { "messages": {
"note_created": "заметка создана." "note_created": "заметка создана."
}, },
"advanced": { "advanced": {
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.", "explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
"custom_password": "пользовательский пароль" "custom_password": "пользовательский пароль"
} },
}, "pasting": "Вставка...",
"show": { "pasted_files": "Вставленные файлы",
"errors": { "remove": "Удалить"
"not_found": "заметка не найдена или была удалена.", },
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.", "show": {
"unsupported_type": "неподдерживаемый тип заметки." "errors": {
}, "not_found": "заметка не найдена или была удалена.",
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела", "decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. заметка уничтожена.",
"show_note": "показать заметку", "unsupported_type": "неподдерживаемый тип заметки."
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.", },
"download_all": "скачать всё", "explanation": "щелкните ниже, чтобы показать и удалить заметку, если счетчик достиг предела",
"links_found": "ссылки внутри заметки:" "show_note": "показать заметку",
}, "warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
"file_upload": { "download_all": "скачать всё",
"selected_files": "Выбранные файлы", "links_found": "ссылки внутри заметки:"
"no_files_selected": "Файлы не выбраны", },
"clear": "Сброс" "file_upload": {
} "selected_files": "Выбранные файлы",
} "no_files_selected": "Файлы не выбраны",
"clear": "Сброс"
}
}
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。", "explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
"custom_password": "自定義密碼" "custom_password": "自定義密碼"
} },
"pasting": "正在粘貼...",
"pasted_files": "粘貼的檔案",
"remove": "移除"
}, },
"show": { "show": {
"errors": { "errors": {
+4 -1
View File
@@ -36,7 +36,10 @@
"advanced": { "advanced": {
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。", "explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
"custom_password": "自定义密码" "custom_password": "自定义密码"
} },
"pasting": "正在粘贴...",
"pasted_files": "粘贴的文件",
"remove": "移除"
}, },
"show": { "show": {
"errors": { "errors": {
+17 -16
View File
@@ -7,31 +7,32 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-check --tsconfig tsconfig.json", "check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker --summary > licenses.csv", "licenses": "license-checker-rseidelsohn --summary > licenses.csv",
"locale:download": "node scripts/locale.js", "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": "^12.1.0", "@lokalise/node-api": "^13.2.1",
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.5.2", "@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^3.0.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@zerodevx/svelte-toast": "^0.9.5", "@zerodevx/svelte-toast": "^0.9.6",
"adm-zip": "^0.5.10", "adm-zip": "^0.5.17",
"dotenv": "^16.4.5", "dotenv": "^17.4.2",
"svelte": "^4.2.12", "license-checker-rseidelsohn": "^5.0.1",
"svelte-check": "^3.6.6", "svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"svelte-intl-precompile": "^0.12.3", "svelte-intl-precompile": "^0.12.3",
"tslib": "^2.6.2", "tslib": "^2.8.1",
"typescript": "^5.3.3", "typescript": "^6.0.3",
"vite": "^5.1.7" "vite": "^8.0.14"
}, },
"dependencies": { "dependencies": {
"@fontsource/fira-mono": "^5.2.7",
"cryptgeon": "workspace:*", "cryptgeon": "workspace:*",
"@fontsource/fira-mono": "^5.0.8",
"occulto": "^2.0.6", "occulto": "^2.0.6",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^7.1.0",
"qrious": "^4.0.2" "uqr": "^0.1.3"
} }
} }
@@ -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

@@ -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>
@@ -6,13 +6,23 @@
import TextInput from '$lib/ui/TextInput.svelte' import TextInput from '$lib/ui/TextInput.svelte'
import type { Note } from 'cryptgeon/shared' import type { Note } from 'cryptgeon/shared'
export let note: Note interface Props {
export let timeExpiration = false note: Note
export let customPassword: string | null = null timeExpiration?: boolean
customPassword?: string | null
}
let hasCustomPassword = false let {
note = $bindable(),
timeExpiration = $bindable(false),
customPassword = $bindable(null),
}: Props = $props()
$: if (!hasCustomPassword) customPassword = null let hasCustomPassword = $state(false)
$effect(() => {
if (!hasCustomPassword) customPassword = null
})
</script> </script>
<div class="flex col"> <div class="flex col">
+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 {
@@ -1,42 +0,0 @@
<script lang="ts">
// @ts-ignore
import QR from 'qrious'
import { t } from 'svelte-intl-precompile'
import { getCSSVariable } from '$lib/utils'
export let value: string
let canvas: HTMLCanvasElement
$: {
new QR({
value,
level: 'Q',
size: 800,
background: getCSSVariable('--ui-bg-0'),
foreground: getCSSVariable('--ui-text-0'),
element: canvas,
})
}
</script>
<small>{$t('common.qr_code')}</small>
<div>
<canvas bind:this={canvas} />
</div>
<style>
div {
padding: 0.5rem;
width: fit-content;
border: 2px solid var(--ui-bg-1);
background-color: var(--ui-bg-0);
margin-top: 0.125rem;
}
canvas {
width: 100%;
height: auto;
}
</style>
+10 -5
View File
@@ -5,8 +5,13 @@
import MaxSize from '$lib/ui/MaxSize.svelte' import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from 'cryptgeon/shared' import type { FileDTO } from 'cryptgeon/shared'
export let label: string = '' interface Props {
export let files: FileDTO[] = [] label?: string
files?: FileDTO[]
[key: string]: any
}
let { label = '', files = $bindable([]), ...rest }: Props = $props()
async function fileToDTO(file: File): Promise<FileDTO> { async function fileToDTO(file: File): Promise<FileDTO> {
return { return {
@@ -35,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>
@@ -45,8 +50,8 @@
{file.name} {file.name}
</div> </div>
{/each} {/each}
<div class="spacer" /> <div class="spacer"></div>
<Button on:click={clear}>{$t('file_upload.clear')}</Button> <Button onclick={clear}>{$t('file_upload.clear')}</Button>
</div> </div>
{:else} {:else}
<div> <div>
+10 -4
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,12 +16,17 @@
</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>
<button type="button" 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}
</button> </button>
@@ -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

+14 -7
View File
@@ -1,4 +1,4 @@
<script lang="ts" context="module"> <script lang="ts" module>
export type NoteResult = { export type NoteResult = {
id: string id: string
password?: string password?: string
@@ -10,12 +10,19 @@
import { status } from '$lib/stores/status' import { status } from '$lib/stores/status'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import TextInput from '$lib/ui/TextInput.svelte' import TextInput from '$lib/ui/TextInput.svelte'
import Canvas from './Canvas.svelte' import QR from './QR.svelte'
export let result: NoteResult interface Props {
result: NoteResult
}
let url = `${window.location.origin}/note/${result.id}` let { result }: Props = $props()
if (result.password) url += `#${result.password}`
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()
@@ -32,7 +39,7 @@
/> />
<div> <div>
<Canvas value={url} /> <QR value={url} />
</div> </div>
{#if $status?.theme_new_note_notice} {#if $status?.theme_new_note_notice}
@@ -41,7 +48,7 @@
</p> </p>
{/if} {/if}
<br /> <br />
<Button on:click={reset}>{$t('home.new_note')}</Button> <Button onclick={reset}>{$t('home.new_note')}</Button>
<style> <style>
div { div {
@@ -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.contents], { type: f.type })))
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.type)}
<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.type}
</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>
+40 -17
View File
@@ -1,4 +1,4 @@
<script lang="ts" context="module"> <script lang="ts" module>
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any } export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
function saveAs(file: File) { function saveAs(file: File) {
@@ -22,29 +22,34 @@
import { copy } from '$lib/utils' import { copy } from '$lib/utils'
import type { FileDTO, NotePublic } from 'cryptgeon/shared' import type { FileDTO, NotePublic } from 'cryptgeon/shared'
export let note: DecryptedNote interface Props {
note: DecryptedNote
}
let { note }: Props = $props()
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g 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) {
// @ts-ignore
const f = new File([file.contents], file.name, { const f = new File([file.contents], file.name, {
type: file.type, type: file.type,
}) })
saveAs(f) saveAs(f)
} }
$: links = typeof note.contents === 'string' ? note.contents.match(RE_URL) : [] $effect(() => {
if (note.meta.type === 'file') {
files = note.contents
}
})
let download = $derived(() => {
for (const file of files) {
downloadFile(file)
}
})
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
</script> </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>
@@ -53,7 +58,7 @@
<div class="note"> <div class="note">
{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} {#if links && links.length}
<div class="links"> <div class="links">
@@ -70,13 +75,22 @@
{:else} {:else}
{#each files as file} {#each files as file}
<div class="note file"> <div class="note file">
<button on:click={() => downloadFile(file)}> <button onclick={() => downloadFile(file)}>
<b>{file.name}</b> <b>{file.name}</b>
</button> </button>
<small> {file.type} {prettyBytes(file.size)}</small> <small> {file.type} {prettyBytes(file.size)}</small>
</div> </div>
{#if file.type.startsWith('image/')}
{#key file.name}
<img
src={URL.createObjectURL(new File([file.contents], file.name, { type: file.type }))}
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>
@@ -125,4 +139,13 @@
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
word-wrap: break-word; word-wrap: break-word;
} }
.preview {
display: block;
max-width: 100%;
max-height: 60vh;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
border: 2px solid var(--ui-bg-1);
}
</style> </style>
+10 -5
View File
@@ -1,13 +1,18 @@
<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>
<label {...$$restProps}> <label {...rest}>
<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>
<style> <style>
+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>
+32 -22
View File
@@ -2,25 +2,35 @@
import Icon from '$lib/ui/Icon.svelte' import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils' import { copy as copyFN } from '$lib/utils'
import { getRandomBytes, Hex } from 'occulto' import { getRandomBytes, Hex } from 'occulto'
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
} }
@@ -30,31 +40,31 @@
</script> </script>
<label> <label>
<small class: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 <Icon
disabled={$$restProps.disabled} disabled={rest.disabled}
class="icon" class="icon"
icon={hidden ? 'eye' : 'eye-off'} icon={hidden ? 'eye' : 'eye-off'}
on:click={toggle} onclick={toggle}
/> />
{/if} {/if}
{#if random} {#if random}
<Icon disabled={$$restProps.disabled} class="icon" icon="dice" on:click={randomFN} /> <Icon disabled={rest.disabled} class="icon" icon="dice" onclick={randomFN} />
{/if} {/if}
{#if copy} {#if copy}
<Icon <Icon
disabled={$$restProps.disabled} disabled={rest.disabled}
class="icon" class="icon"
icon="copy" icon="copy"
on:click={() => copyFN(value.toString())} onclick={() => copyFN(value.toString())}
/> />
{/if} {/if}
</div> </div>
+10 -13
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,7 +37,7 @@
} }
</script> </script>
<button on:click={change}> <button onclick={change}>
<Icon class="icon" icon="contrast" /> <Icon class="icon" icon="contrast" />
{$theme} {$theme}
</button> </button>
+143 -32
View File
@@ -10,32 +10,36 @@
import FileUpload from '$lib/ui/FileUpload.svelte' import FileUpload from '$lib/ui/FileUpload.svelte'
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 PastedFilesPreview from '$lib/ui/PastedFilesPreview.svelte'
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte' import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte' import TextArea from '$lib/ui/TextArea.svelte'
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared' import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared'
let note: Note = { let note: Note = $state({
contents: '', contents: '',
meta: { type: 'text' }, meta: { type: 'text' },
views: 1, views: 1,
expiration: 60, expiration: 60,
} })
let files: FileDTO[] let files: FileDTO[] = $state([])
let result: NoteResult | null = null let result: NoteResult | null = $state(null)
let advanced = false let advanced = $state(false)
let isFile = false let isFile = $state(false)
let timeExpiration = false let timeExpiration = $state(false)
let customPassword: string | null = null let customPassword: string | null = $state(null)
let description = '' let description = $state('')
let loading: string | null = null let loading: string | null = $state(null)
let isPasting = $state(false)
$: if (!advanced) { $effect(() => {
note.views = 1 if (!advanced) {
timeExpiration = false note.views = 1
} 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', {
@@ -43,17 +47,78 @@
}), }),
}, },
}) })
} })
$: note.meta.type = isFile ? 'file' : 'text' $effect(() => {
note.meta.type = isFile ? 'file' : 'text'
})
$: if (!isFile) { $effect(() => {
note.contents = '' if (!isFile) {
note.contents = ''
}
})
async function handlePaste(e: ClipboardEvent) {
e.preventDefault()
const data = e.clipboardData
if (!data) return
const raw: File[] = []
if (data.files.length) {
raw.push(...Array.from(data.files))
}
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i]
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) raw.push(file)
}
}
if (raw.length === 0) return
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 })
return {
name: renamed.name,
size: renamed.size,
type: renamed.type,
contents: new Uint8Array(await renamed.arrayBuffer()),
}
})
)
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')
@@ -103,18 +168,28 @@
<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}>
{#if isFile} <div class="paste-indicator">
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files /> {#if isPasting}
{:else} <div class="pasting-overlay">
<TextArea <div class="pasting-spinner"></div>
data-testid="text-field" <span>{$t('home.pasting')}</span>
label={$t('common.note')} </div>
bind:value={note.contents} {/if}
placeholder="..." {#if isFile}
/> <FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
{/if} {:else}
<TextArea
data-testid="text-field"
label={$t('common.note')}
bind:value={note.contents}
placeholder="..."
/>
{/if}
<PastedFilesPreview bind:files />
</div>
<div class="bottom"> <div class="bottom">
{#if $status?.allow_files} {#if $status?.allow_files}
@@ -132,7 +207,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 />
@@ -173,4 +248,40 @@
.grow { .grow {
flex: 1; flex: 1;
} }
.paste-indicator {
position: relative;
min-height: 200px;
}
.pasting-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
z-index: 10;
}
.pasting-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 0.5rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>
@@ -6,7 +6,9 @@
<footer> <footer>
<ThemeToggle /> <ThemeToggle />
<nav> <nav>
<a href="/">/home</a> {#if $status?.theme_home_link !== false}
<a href="/">/home</a>
{/if}
<a href="/about">/about</a> <a href="/about">/about</a>
{#if $status?.imprint_url} {#if $status?.imprint_url}
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a> <a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
@@ -7,8 +7,10 @@
</script> </script>
<header> <header>
<a on:click={reset} href="/"> <a onclick={reset} href="/">
{#if $status?.theme_image} {#if $status === null}
<!-- waiting for status to load to avoid flashing default logo -->
{:else if $status.theme_image}
<img alt="logo" src={$status.theme_image} /> <img alt="logo" src={$status.theme_image} />
{:else} {:else}
<svg <svg
+6 -1
View File
@@ -8,6 +8,11 @@
import { init as initStores, status } from '$lib/stores/status' import { init as initStores, status } from '$lib/stores/status'
import Footer from '$lib/views/Footer.svelte' import Footer from '$lib/views/Footer.svelte'
import Header from '$lib/views/Header.svelte' import Header from '$lib/views/Header.svelte'
interface Props {
children?: import('svelte').Snippet
}
let { children }: Props = $props()
onMount(() => { onMount(() => {
initStores() initStores()
@@ -22,7 +27,7 @@
{#await waitLocale() then _} {#await waitLocale() then _}
<main> <main>
<Header /> <Header />
<slot /> {@render children?.()}
</main> </main>
<SvelteToast /> <SvelteToast />
@@ -1,18 +1,16 @@
<script lang="ts"> <script lang="ts">
import { get } from 'svelte/store'; import { goto } from '$app/navigation'
import { goto } from '$app/navigation';
import { status } from '$lib/stores/status' import { status } from '$lib/stores/status'
status.subscribe((config) => { status.subscribe((config) => {
if (config != null) { if (config != null) {
if (config.imprint_url) { if (config.imprint_url) {
window.location = config.imprint_url; window.location.href = config.imprint_url
} } else if (config.imprint_html == '') {
else if (config.imprint_html == "") { goto('/about')
goto("/about");
} }
} }
}); })
</script> </script>
<svelte:head> <svelte:head>
@@ -20,9 +18,9 @@
</svelte:head> </svelte:head>
<section class="content"> <section class="content">
{#if $status?.imprint_html} {#if $status?.imprint_html}
{@html $status.imprint_html} {@html $status.imprint_html}
{/if} {/if}
</section> </section>
<style> <style>
@@ -10,18 +10,22 @@
import { Adapters, API, type NoteMeta } from 'cryptgeon/shared' import { Adapters, API, type NoteMeta } from 'cryptgeon/shared'
import type { PageData } from './$types' import type { PageData } from './$types'
export let data: PageData interface Props {
data: PageData
}
let id = data.id let { data }: Props = $props()
let password: string | null = null
let note: DecryptedNote | null = null
let exists = false
let meta: NoteMeta | null = null
let loading: string | null = null let id = $derived(data.id)
let error: string | null = null let password: string | null = $state<string | null>(null)
let note: DecryptedNote | null = $state(null)
let exists = $state(false)
let meta: NoteMeta | null = $state(null)
$: valid = !!password?.length let loading: string | null = $state(null)
let error: string | null = $state(null)
let valid = $derived(!!password?.length)
onMount(async () => { onMount(async () => {
// Check if note exists // Check if note exists
@@ -41,7 +45,8 @@
/** /**
* Get the actual contents of the note and decrypt it. * Get the actual contents of the note and decrypt it.
*/ */
async function show() { async function show(e: SubmitEvent) {
e.preventDefault()
try { try {
if (!valid) { if (!valid) {
error = $t('show.errors.no_password') error = $t('show.errors.no_password')
@@ -86,7 +91,7 @@
{:else if note && !error} {:else if note && !error}
<ShowNote {note} /> <ShowNote {note} />
{:else} {:else}
<form on:submit|preventDefault={show}> <form onsubmit={show}>
<fieldset> <fieldset>
<p>{$t('show.explanation')}</p> <p>{$t('show.explanation')}</p>
{#if meta?.derivation} {#if meta?.derivation}
+3 -2
View File
@@ -2,6 +2,7 @@
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
} "skipLibCheck": true,
},
} }
+10 -7
View File
@@ -1,14 +1,17 @@
import { sveltekit } from '@sveltejs/kit/vite' import { sveltekit } from '@sveltejs/kit/vite'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin' import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
import { defineConfig } from 'vite'
const port = 8001 const port = 3000
/** @type {import('vite').UserConfig} */ export default defineConfig({
const config = {
clearScreen: false, clearScreen: false,
server: { port }, server: {
port,
proxy: {
'/api': 'http://localhost:8000',
},
},
preview: { port }, preview: { port },
plugins: [sveltekit(), precompileIntl('locales')], plugins: [sveltekit(), precompileIntl('locales')],
} })
export default config
-12
View File
@@ -1,12 +0,0 @@
{
"private": true,
"name": "@cryptgeon/proxy",
"type": "module",
"main": "./proxy.js",
"scripts": {
"dev": "node ."
},
"dependencies": {
"http-proxy": "^1.18.1"
}
}
-16
View File
@@ -1,16 +0,0 @@
import http from 'http'
import httpProxy from 'http-proxy'
const proxy = httpProxy.createProxyServer()
proxy.on('error', function (err, req, res) {
console.error(err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('500 Internal Server Error')
})
const server = http.createServer(function (req, res) {
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
proxy.web(req, res, { target, proxyTimeout: 250, timeout: 250 })
})
server.listen(3000)
console.log('Proxy on http://localhost:3000')
+2278 -1716
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -1,2 +1,7 @@
packages: packages:
- "packages/**" - "packages/**"
allowBuilds:
esbuild: true
minimumReleaseAge: 10080 # One week
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { getFileChecksum } from "../../files";
const IMG_PATH = "test/assets/image.jpg";
test.describe("@web", () => {
test("paste image", async ({ page, browserName }) => {
test.skip(
browserName !== "chromium",
"DataTransfer.items.add(File) is only supported in Chromium",
);
const checksum = await getFileChecksum(IMG_PATH);
const imgBuffer = await readFile(IMG_PATH);
const imgBase64 = imgBuffer.toString("base64");
await page.goto("/");
await page.waitForSelector("form");
// Create a paste event with clipboardData containing the image file
await page.evaluate(
async ({ base64, mimeType }) => {
const response = await fetch(`data:${mimeType};base64,${base64}`);
const blob = await response.blob();
const file = new File([blob], "image.jpg", { type: mimeType });
const dt = new DataTransfer();
dt.items.add(file);
const event = new ClipboardEvent("paste", {
bubbles: true,
cancelable: true,
clipboardData: dt,
});
document.querySelector("form")?.dispatchEvent(event);
},
{ base64: imgBase64, mimeType: "image/jpeg" },
);
// Wait for the paste to process and preview to appear
await expect(page.locator(".pasted-files-preview")).toBeVisible({
timeout: 5000,
});
// Create the note
await page.locator('button:has-text("create")').click();
const link = await page.getByTestId("share-link").inputValue();
// Navigate to the note and reveal it
await page.goto("/");
await page.goto(link);
await page.getByTestId("show-note-button").click();
// Verify image preview is visible
await expect(page.locator("img.preview")).toBeVisible({ timeout: 5000 });
// Download and verify checksum
const [download] = await Promise.all([
page.waitForEvent("download"),
page.locator('[data-testid="result"] button').first().click(),
]);
const path = await download.path();
if (!path) throw new Error("Download failed");
const cs = await getFileChecksum(path);
expect(cs).toBe(checksum);
});
});