Compare commits

..
Author SHA1 Message Date
cupcakearmyandGitHub 4ea6bc3e39 Merge e15d9eb537 into b60a079bbe 2024-08-26 22:42:26 +00:00
93 changed files with 2892 additions and 4524 deletions
+9 -9
View File
@@ -10,10 +10,10 @@ jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
cache: 'pnpm'
node-version-file: '.nvmrc'
@@ -31,14 +31,14 @@ jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
with:
install: true
- name: Docker Labels
id: meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v4
with:
images: cupcakearmy/cryptgeon
tags: |
@@ -46,12 +46,12 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Login to DockerHub
uses: docker/login-action@v4
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm64
push: true
+3 -3
View File
@@ -13,15 +13,15 @@ jobs:
- uses: actions/checkout@v4
# Node
- uses: pnpm/action-setup@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
cache: 'pnpm'
node-version-file: '.nvmrc'
# Docker
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
with:
install: true
+1 -1
View File
@@ -1 +1 @@
v24
v22.7.0
-47
View File
@@ -1,47 +0,0 @@
# 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
FROM node:24-alpine AS client
FROM node:22-alpine as client
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
@@ -11,7 +11,7 @@ RUN pnpm run build
# BACKEND
FROM rust:1.95-alpine AS backend
FROM rust:1.80-alpine as backend
WORKDIR /tmp
RUN apk add --no-cache libc-dev openssl-dev alpine-sdk
COPY ./packages/backend ./
@@ -19,7 +19,7 @@ RUN RUSTFLAGS="-Ctarget-feature=-crt-static" cargo build --release
# RUNNER
FROM alpine:3
FROM alpine:3.19
WORKDIR /app
RUN apk add --no-cache curl libgcc
COPY --from=backend /tmp/target/release/cryptgeon .
+50 -26
View File
@@ -12,7 +12,6 @@
<br/><br/>
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
<a href=""><img src="./.github/lokalise.png" height="50">
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
<br/><br/>
EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md)
@@ -40,12 +39,6 @@ npx cryptgeon send text "This is a secret note"
For more documentation about the CLI see the [readme](./packages/cli/README.md).
### Raycast Extension
There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cryptgeon).
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
## Features
- send text or files
@@ -63,8 +56,6 @@ 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
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](./design/Screens.png)
@@ -79,17 +70,13 @@ of the notes even if it tried to.
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. |
| `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`. |
| `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. |
| `REDIS_PREFIX` | `""` | Optional prefix for all Redis keys. Useful when sharing a Redis instance with other apps via ACL namespaces. |
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
## Deployment
@@ -104,19 +91,14 @@ Docker is the easiest way. There is the [official image here](https://hub.docker
```yaml
# docker-compose.yml
version: "3.8"
version: '3.8'
services:
redis:
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
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
app:
image: cupcakearmy/cryptgeon:latest
@@ -162,12 +144,54 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
### Written Guides
- French by [zarevskaya](https://belginux.com/installer-cryptgeon-avec-docker/)
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
## Contributing
## Development
See [CONTRIBUTING.md](./CONTRIBUTING.md).
**Requirements**
- `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
+54 -16
View File
@@ -43,7 +43,7 @@ Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/RE
- enviar texto o archivos
- el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente
- restricción de vistas o de tiempo
- restriccion de vistas o de tiempo
- en memoria, sin persistencia
- compatibilidad obligatoria con el modo oscuro
@@ -66,40 +66,34 @@ 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_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. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es útil para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util 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` |
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. |
## Despliegue
> ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación.
> ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptacón.
> ️ Hay un endpoint para verificar el estado, lo encontramos en `/api/health/`. Regresa un código 200 o 503.
### Docker
Docker es la manera más fácil. Aquí encontramos [la imagen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
Docker es la manera más fácil. Aquí encontramos [la imágen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
```yaml
# docker-compose.yml
version: "3.8"
version: '3.8'
services:
redis:
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
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
app:
image: cupcakearmy/cryptgeon:latest
@@ -142,13 +136,57 @@ 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 alemán, por [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
## Contribuir
## Desarrollo
Ver [CONTRIBUTING.md](./CONTRIBUTING.md).
**Requisitos**
- `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
Por favor dirígete a la sección de seguridad [aquí](./SECURITY.md).
Por favor dirigite a la sección de seguridad [aquí](./SECURITY.md).
---
+52 -23
View File
@@ -48,15 +48,14 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
## 环境变量
| 变量名称 | 默认值 | 描述 |
| ---------------- | ---------------- | --------------------------------------------------------------------------------- |
| ----------------- | ---------------- | --------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis 连接 URL。 |
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
| `MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
| ` MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 |
## 部署
@@ -70,19 +69,11 @@ Docker 是最简单的部署方式。这里是[官方镜像的地址](https://hu
```yaml
# docker-compose.yml
version: "3.8"
version: '3.8'
services:
redis:
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:
image: cupcakearmy/cryptgeon:latest
@@ -108,7 +99,7 @@ services:
- 域名 `example.org`
```yaml
version: "3.8"
version: '3.8'
networks:
proxy:
@@ -117,14 +108,7 @@ networks:
services:
redis:
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
restart: unless-stopped
app:
image: cupcakearmy/cryptgeon:latest
@@ -141,9 +125,54 @@ services:
- 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
+3
View File
@@ -11,6 +11,9 @@
},
{
"path": "packages/cli"
},
{
"path": "packages/shared"
}
],
"settings": {
+1 -9
View File
@@ -3,15 +3,7 @@
services:
redis:
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
image: redis:7-alpine
ports:
- 6379:6379
+3 -8
View File
@@ -1,14 +1,9 @@
services:
redis:
image: valkey/valkey:7-alpine
# This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no
image: redis:7-alpine
# 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
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
app:
image: cupcakearmy/cryptgeon:latest
+1 -9
View File
@@ -2,15 +2,7 @@ version: '3.8'
services:
redis:
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
image: redis:7-alpine
app:
image: cupcakearmy/cryptgeon:latest
+2 -9
View File
@@ -108,15 +108,8 @@ networks:
services:
redis:
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
image: redis:7-alpine
restart: unless-stopped
app:
image: cupcakearmy/cryptgeon:latest
+3 -58
View File
@@ -2,7 +2,7 @@
Assumptions:
- Traefik 2/3 installed.
- Traefik 2 installed.
- External proxy docker network `proxy`.
- A certificate resolver `le`.
- A https entrypoint `secure`.
@@ -17,15 +17,8 @@ networks:
services:
redis:
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
image: redis:7-alpine
restart: unless-stopped
app:
image: cupcakearmy/cryptgeon:latest
@@ -41,51 +34,3 @@ services:
- traefik.http.routers.cryptgeon.entrypoints=secure
- traefik.http.routers.cryptgeon.tls.certresolver=le
```
## With basic auth
Some times it's useful to hide the service behind auth. This is easily achieved with traefik middleware. Many reverse proxies support similar features, so while traefik is used in this example, other reverse proxies can do the same.
```yaml
services:
traefik:
image: traefik:v3.0
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
redis:
image: valkey/valkey:7-alpine
# This is required to stay in RAM only.
command: valkey-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
cryptgeon:
image: cupcakearmy/cryptgeon
depends_on:
- redis
labels:
- "traefik.enable=true"
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
- "traefik.http.routers.cryptgeon.entrypoints=web"
- "traefik.http.routers.cryptgeon.middlewares=cryptgeon-auth"
- "traefik.http.middlewares.cryptgeon-auth.basicauth.users=user:$$2y$$05$$juUw0zgc5ebvJ00MFPVVLujF6P.rcEMbGZ99Jfq6ZWEa1dgetacEq"
```
```bash
docker compose up -d
```
1. Open http://cryptgeon.localhost
2. Log in with `user` and `secret`
-11
View File
@@ -1,11 +0,0 @@
[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"
+6 -7
View File
@@ -5,18 +5,17 @@
"dev": "run-p dev:*",
"docker:up": "docker compose -f docker-compose.dev.yaml up",
"docker:build": "docker compose -f docker-compose.dev.yaml build",
"test": "playwright test --project=chrome --project=firefox --project=safari",
"test:local": "playwright test --project=chrome",
"test": "playwright test --project chrome firefox safari",
"test:local": "playwright test --project chrome",
"test:server": "run-s docker:up",
"test:dl-browsers": "playwright install",
"test:prepare": "run-p test:dl-browsers build docker:build",
"test:prepare": "run-p build docker:build",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@types/node": "^24.12.4",
"@playwright/test": "^1.46.1",
"@types/node": "^22.5.0",
"npm-run-all": "^4.1.5",
"shelljs": "^0.8.5"
},
"packageManager": "pnpm@11.5.0"
"packageManager": "pnpm@9.9.0"
}
+449 -801
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,9 +1,9 @@
[package]
name = "cryptgeon"
version = "2.9.3"
version = "2.7.0"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2024"
rust-version = "1.95"
edition = "2021"
rust-version = "1.80"
[[bin]]
name = "cryptgeon"
@@ -11,17 +11,17 @@ path = "src/main.rs"
[dependencies]
# Core
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["full"] }
redis = { version = "1", features = ["tls-native-tls"] }
axum = "0.7.5"
serde = { version = "1.0.208", features = ["derive"] }
tokio = { version = "1.39.3", features = ["full"] }
tower = "0.5.0"
tower-http = { version = "0.5.2", features = ["full"] }
redis = { version = "0.25.2", features = ["tls-native-tls"] }
# Utility
serde_json = "1"
lazy_static = "1"
ring = "0.17"
ring = "0.16"
bs62 = "0.1"
byte-unit = "4"
dotenv = "0.15"
+1 -1
View File
@@ -2,7 +2,7 @@
"private": true,
"name": "@cryptgeon/backend",
"scripts": {
"dev": "watchexec -r -e rs cargo run",
"dev": "cargo watch -x 'run --bin cryptgeon'",
"build": "cargo build --release",
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
"test:prepare": "cargo build"
-16
View File
@@ -34,22 +34,10 @@ pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string())
.parse()
.unwrap();
pub static ref REDIS_PREFIX: String = std::env::var("REDIS_PREFIX")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
.unwrap_or("true".to_string())
.parse()
.unwrap();
pub static ref IMPRINT_URL: String = std::env::var("IMPRINT_URL")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
.unwrap_or("".to_string())
.parse()
.unwrap();
}
// THEME
@@ -74,8 +62,4 @@ lazy_static! {
.unwrap_or("true".to_string())
.parse()
.unwrap();
pub static ref THEME_HOME_LINK: bool = std::env::var("THEME_HOME_LINK")
.unwrap_or("true".to_string())
.parse()
.unwrap();
}
-16
View File
@@ -1,16 +0,0 @@
use axum::{body::Body, extract::Request, http::HeaderValue, middleware::Next, response::Response};
const CUSTOM_HEADER_NAME: &str = "Content-Security-Policy";
const CUSTOM_HEADER_VALUE: &str = "default-src 'self'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; object-src 'none'; base-uri 'self'; connect-src 'self' data:; font-src 'self'; frame-src 'self'; img-src 'self'; manifest-src 'self'; media-src 'self'; worker-src 'none';";
lazy_static! {
static ref HEADER_VALUE: HeaderValue = HeaderValue::from_static(CUSTOM_HEADER_VALUE);
}
pub async fn add_csp_header(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.append(CUSTOM_HEADER_NAME, HEADER_VALUE.clone());
response
}
-10
View File
@@ -1,10 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct SharedState {
pub locks: LockMap,
}
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
+7 -20
View File
@@ -1,13 +1,9 @@
use std::{collections::HashMap, sync::Arc};
use axum::{
Router, ServiceExt,
extract::{DefaultBodyLimit, Request},
routing::{delete, get, post},
Router, ServiceExt,
};
use dotenv::dotenv;
use lock::SharedState;
use tokio::sync::Mutex;
use tower::Layer;
use tower_http::{
compression::CompressionLayer,
@@ -19,9 +15,7 @@ use tower_http::{
extern crate lazy_static;
mod config;
mod csp;
mod health;
mod lock;
mod note;
mod status;
mod store;
@@ -30,25 +24,21 @@ mod store;
async fn main() {
dotenv().ok();
let shared_state = SharedState {
locks: Arc::new(Mutex::new(HashMap::new())),
};
if !store::can_reach_redis() {
println!("cannot reach redis");
panic!("cannot reach redis");
panic!("canont reach redis");
}
let notes_routes = Router::new()
.route("/", post(note::create))
.route("/{id}", delete(note::delete))
.route("/{id}", get(note::preview));
.route("/:id", delete(note::delete))
.route("/:id", get(note::preview));
let health_routes = Router::new().route("/live", get(health::report_health));
let status_routes = Router::new().route("/status", get(status::get_status));
let api_routes = Router::new()
.nest("/notes", notes_routes)
.merge(health_routes)
.merge(status_routes);
.nest("/", health_routes)
.nest("/", status_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
let serve_dir =
@@ -56,8 +46,6 @@ async fn main() {
let app = Router::new()
.nest("/api", api_routes)
.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(
CompressionLayer::new()
@@ -65,8 +53,7 @@ async fn main() {
.deflate(true)
.gzip(true)
.zstd(true),
)
.with_state(shared_state);
);
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
+9 -21
View File
@@ -1,16 +1,13 @@
use axum::{
extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use axum::extract::Path;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::SystemTime};
use tokio::sync::Mutex;
use std::time::SystemTime;
use crate::config;
use crate::note::{generate_id, Note, NoteInfo};
use crate::store;
use crate::{config, lock::SharedState};
use super::NotePublic;
@@ -81,20 +78,11 @@ pub async fn create(Json(mut n): Json<Note>) -> 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;
pub async fn delete(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response {
let note = store::get(&id);
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(),
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Ok(Some(note)) => {
+2 -7
View File
@@ -1,5 +1,6 @@
use crate::config;
use axum::{http::StatusCode, Json};
use axum::http::StatusCode;
use axum::Json;
use serde::Serialize;
#[derive(Serialize)]
@@ -12,15 +13,12 @@ pub struct Status {
pub max_expiration: u32,
pub allow_advanced: bool,
pub allow_files: bool,
pub imprint_url: String,
pub imprint_html: String,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
pub theme_new_note_notice: bool,
pub theme_home_link: bool,
}
pub async fn get_status() -> (StatusCode, Json<Status>) {
@@ -31,10 +29,7 @@ pub async fn get_status() -> (StatusCode, Json<Status>) {
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
allow_files: *config::ALLOW_FILES,
imprint_url: config::IMPRINT_URL.to_string(),
imprint_html: config::IMPRINT_HTML.to_string(),
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_home_link: *config::THEME_HOME_LINK,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
+5 -13
View File
@@ -1,7 +1,6 @@
use redis;
use redis::Commands;
use crate::config;
use crate::note::now;
use crate::note::Note;
@@ -12,10 +11,6 @@ lazy_static! {
.unwrap();
}
fn prefixed(id: &String) -> String {
format!("{}{}", config::REDIS_PREFIX.as_str(), id)
}
fn get_connection() -> Result<redis::Connection, &'static str> {
let client =
redis::Client::open(REDIS_CLIENT.to_string()).map_err(|_| "Unable to connect to redis")?;
@@ -33,17 +28,16 @@ pub fn can_reach_redis() -> bool {
}
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
let key = prefixed(id);
let serialized = serde_json::to_string(&note.clone()).unwrap();
let mut conn = get_connection()?;
conn.set::<_, _, ()>(key.as_str(), serialized)
conn.set(id, serialized)
.map_err(|_| "Unable to set note in redis")?;
match note.expiration {
Some(e) => {
let seconds = e - now();
conn.expire::<_, ()>(key.as_str(), seconds as i64)
.map_err(|_| "Unable to set expiration on note")?
conn.expire(id, seconds as i64)
.map_err(|_| "Unable to set expiration on notion")?
}
None => {}
};
@@ -51,9 +45,8 @@ pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
}
pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
let key = prefixed(id);
let mut conn = get_connection()?;
let value: Option<String> = conn.get(key.as_str()).map_err(|_| "Could not load note in redis")?;
let value: Option<String> = conn.get(id).map_err(|_| "Could not load note in redis")?;
match value {
None => return Ok(None),
Some(s) => {
@@ -64,8 +57,7 @@ pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
}
pub fn del(id: &String) -> Result<(), &'static str> {
let key = prefixed(id);
let mut conn = get_connection()?;
conn.del::<_, ()>(key.as_str()).map_err(|_| "Unable to delete note in redis")?;
conn.del(id).map_err(|_| "Unable to delete note in redis")?;
Ok(())
}
+2 -3
View File
@@ -1,14 +1,13 @@
import { build } from 'tsup'
import pkg from './package.json' with { type: 'json' }
import { build } from 'tsup'
const watch = process.argv.slice(2)[0] === '--watch'
await build({
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
entry: ['src/index.ts', 'src/cli.ts'],
dts: true,
minify: true,
format: ['esm', 'cjs'],
target: 'es2020',
clean: true,
define: { VERSION: `"${pkg.version}"` },
watch,
+13 -16
View File
@@ -1,6 +1,6 @@
{
"name": "cryptgeon",
"version": "2.9.3",
"version": "2.7.0",
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": {
"type": "git",
@@ -9,11 +9,7 @@
},
"type": "module",
"exports": {
".": "./dist/index.js",
"./shared": {
"import": "./dist/shared/shared.js",
"types": "./dist/shared/shared.d.ts"
}
".": "./dist/index.js"
},
"types": "./dist/index.d.ts",
"bin": {
@@ -29,17 +25,18 @@
"prepublishOnly": "run-s build"
},
"devDependencies": {
"@commander-js/extra-typings": "^12.1.0",
"@types/inquirer": "^9.0.9",
"@types/mime": "^4.0.0",
"@types/node": "^20.19.41",
"commander": "^12.1.0",
"inquirer": "^9.3.8",
"mime": "^4.1.0",
"occulto": "^2.0.6",
"@commander-js/extra-typings": "^12.0.1",
"@cryptgeon/shared": "workspace:*",
"@types/inquirer": "^9.0.7",
"@types/mime": "^3.0.4",
"@types/node": "^20.11.24",
"commander": "^12.0.0",
"inquirer": "^9.2.15",
"mime": "^4.0.1",
"occulto": "^2.0.3",
"pretty-bytes": "^6.1.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
"tsup": "^8.2.4",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18"
+10 -10
View File
@@ -1,14 +1,14 @@
#!/usr/bin/env node
import { Argument, Option, program } from '@commander-js/extra-typings'
import { setOptions, status } from '@cryptgeon/shared'
import prettyBytes from 'pretty-bytes'
import { download } from './actions/download.js'
import { upload } from './actions/upload.js'
import { API } from './shared/api.js'
import { parseFile, parseNumber } from './utils/parsers.js'
import { getStdin } from './utils/stdin.js'
import { checkConstrains, exit } from './utils/utils.js'
import { download } from './download.js'
import { parseFile, parseNumber } from './parsers.js'
import { getStdin } from './stdin.js'
import { upload } from './upload.js'
import { checkConstrains, exit } from './utils.js'
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
@@ -33,8 +33,8 @@ program
.description('show information about the server')
.addOption(server)
.action(async (options) => {
API.setOptions({ server: options.server })
const response = await API.status()
setOptions({ server: options.server })
const response = await status()
const formatted = {
...response,
max_size: prettyBytes(response.max_size),
@@ -54,7 +54,7 @@ send
.addOption(minutes)
.addOption(password)
.action(async (files, options) => {
API.setOptions({ server: options.server })
setOptions({ server: options.server })
await checkConstrains(options)
options.password ||= await getStdin()
try {
@@ -72,7 +72,7 @@ send
.addOption(minutes)
.addOption(password)
.action(async (text, options) => {
API.setOptions({ server: options.server })
setOptions({ server: options.server })
await checkConstrains(options)
options.password ||= await getStdin()
try {
@@ -1,15 +1,14 @@
import { Adapters, get, info, setOptions } from '@cryptgeon/shared'
import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { AES, Hex } from 'occulto'
import pretty from 'pretty-bytes'
import { Adapters } from '../shared/adapters.js'
import { API } from '../shared/api.js'
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
API.setOptions({ server: url.origin })
setOptions({ server: url.origin })
const id = url.pathname.split('/')[2]
const preview = await API.info(id).catch(() => {
const preview = await info(id).catch(() => {
throw new Error('Note does not exist or is expired')
})
@@ -34,7 +33,7 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
}
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
const note = await API.get(id)
const note = await get(id)
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
switch (note.meta.type) {
+4 -4
View File
@@ -1,4 +1,4 @@
export * from './actions/download.js'
export * from './actions/upload.js'
export * from './shared/adapters.js'
export * from './shared/api.js'
export * from '@cryptgeon/shared'
export * from './download.js'
export * from './upload.js'
export * from './utils.js'
@@ -21,7 +21,7 @@ export function parseURL(value: string, _: URL): URL {
}
export function parseNumber(value: string, _: number): number {
const n = Number.parseInt(value, 10)
if (Number.isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
const n = parseInt(value, 10)
if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
return n
}
@@ -18,7 +18,6 @@ export function getStdin(timeout: number = 10): Promise<string> {
resolve('')
}, timeout)
process.stdin.on('error', reject)
process.stdin.on('data', dataHandler)
process.stdin.on('end', endHandler)
})
@@ -1,10 +1,9 @@
import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Adapters, create, getOptions, FileDTO, Note, NoteMeta } from '@cryptgeon/shared'
import mime from 'mime'
import { AES, Hex } from 'occulto'
import { Adapters } from '../shared/adapters.js'
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
@@ -39,8 +38,8 @@ export async function upload(input: string | string[], options: UploadOptions):
// Create the actual note and upload it.
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
const result = await API.create(note)
let url = `${API.getOptions().server}/note/${result.id}`
const result = await create(note)
let url = `${getOptions().server}/note/${result.id}`
if (!derived) url += `#${Hex.encode(key)}`
return url
}
@@ -1,5 +1,5 @@
import { status } from '@cryptgeon/shared'
import { exit as exitNode } from 'node:process'
import { API } from '../shared/api.js'
export function exit(message: string) {
console.error(message)
@@ -11,7 +11,7 @@ export async function checkConstrains(constrains: { views?: number; minutes?: nu
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
if (!views && !minutes) constrains.views = 1
const response = await API.status()
const response = await status()
if (views && views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
if (minutes && minutes > response.max_expiration)
+1 -1
View File
@@ -2,7 +2,7 @@
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "Bundler",
"moduleResolution": "node",
"declaration": true,
"emitDeclarationOnly": true,
"strict": true,
+6 -15
View File
@@ -1,17 +1,8 @@
├─ MIT: 324
├─ ISC: 84
├─ Apache-2.0: 21
├─ BlueOak-1.0.0: 14
├─ 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
├─ MIT: 13
├─ ISC: 2
├─ BSD-3-Clause: 1
├─ (MPL-2.0 OR Apache-2.0): 1
├─ BSD-2-Clause: 1
├─ 0BSD: 1
└─ (MIT OR CC0-1.0): 1
└─ Apache-2.0: 1
1 ├─ MIT: 324 ├─ MIT: 13
2 ├─ ISC: 84 ├─ ISC: 2
3 ├─ Apache-2.0: 21 ├─ BSD-3-Clause: 1
4 ├─ BlueOak-1.0.0: 14 ├─ (MPL-2.0 OR Apache-2.0): 1
5 ├─ BSD-3-Clause: 6 ├─ BSD-2-Clause: 1
├─ 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
6 ├─ 0BSD: 1 ├─ 0BSD: 1
7 └─ (MIT OR CC0-1.0): 1 └─ Apache-2.0: 1
8
-61
View File
@@ -1,61 +0,0 @@
{
"common": {
"note": "poznámka",
"file": "soubor",
"advanced": "pokročilé",
"create": "vytvořit",
"loading": "načítání",
"mode": "režim",
"views": "{n, plural, =0 {zobrazení} =1 {1 zobrazení} other {# zobrazení}}",
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minutách}}",
"max": "max",
"share_link": "sdílet odkaz",
"copy_clipboard": "zkopírovat do schránky",
"copied_to_clipboard": "zkopírováno do schránky",
"encrypting": "šifrování",
"decrypting": "dešifrování",
"uploading": "nahrávání",
"downloading": "stahování",
"qr_code": "QR kód",
"password": "heslo"
},
"home": {
"intro": "Jednoduše posílejte <i>plně šifrované</i> a zabezpečené poznámky nebo soubory jediným kliknutím. Stačí vytvořit poznámku a sdílet odkaz.",
"explanation": "Poznámka vyprší a bude zničena po {n}.",
"new_note": "Nová poznámka",
"new_note_notice": "<b>Dostupnost:</b><br />Poznámka není zaručeně uchována, protože je uložena pouze v paměti RAM. Pokud se paměť zaplní, nejstarší poznámky budou automaticky smazány.<br />(Obvykle to nebývá problém, ale je dobré o tom vědět.)",
"errors": {
"note_too_big": "Poznámku nelze vytvořit. Je příliš velká.",
"note_error": "Poznámku nelze vytvořit. Zkuste to prosím znovu.",
"max": "Max: {n}",
"empty_content": "Poznámka je prázdná."
},
"messages": {
"note_created": "Poznámka byla vytvořena."
},
"advanced": {
"explanation": "Ve výchozím nastavení se pro každou poznámku generuje bezpečné heslo. Pokud chcete, můžete si nastavit vlastní heslo, které nebude součástí odkazu.",
"custom_password": "Vlastní heslo"
},
"pasting": "Vkládání...",
"pasted_files": "Vložené soubory",
"remove": "Odstranit"
},
"show": {
"errors": {
"not_found": "Poznámka nebyla nalezena nebo již byla smazána.",
"decryption_failed": "Špatné heslo. Nelze dešifrovat. Odkaz je pravděpodobně poškozen. Poznámka byla zničena.",
"unsupported_type": "Nepodporovaný typ poznámky."
},
"explanation": "Klikněte níže pro zobrazení a následné smazání poznámky, pokud bylo dosaženo limitu zobrazení.",
"show_note": "Zobrazit poznámku",
"warning_will_not_see_again": "Tuto poznámku již <b>nebudete</b> moci znovu zobrazit.",
"download_all": "Stáhnout vše",
"links_found": "Odkazy nalezené v poznámce:"
},
"file_upload": {
"selected_files": "Vybrané soubory",
"no_files_selected": "Žádné soubory nevybrány",
"clear": "Vymazat"
}
}
+2 -5
View File
@@ -25,7 +25,7 @@
"new_note": "Neue Notiz",
"new_note_notice": "<b>Wichtiger Hinweis zur Verfügbarkeit:</b><br />Es kann nicht garantiert werden, dass diese Notiz gespeichert wird, da diese <b>ausschließlich im Speicher</b> gehalten werden. Ist dieser voll, werden die ältesten Notizen entfernt.<br />(Wahrscheinlich gibt es keine derartigen Probleme, seien Sie nur vorgewarnt).",
"errors": {
"note_too_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
"note_to_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
"note_error": "Notiz konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
"max": "max: {n}",
"empty_content": "Notiz ist leer."
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.",
"custom_password": "Benutzerdefiniertes Passwort"
},
"pasting": "Einfügen...",
"pasted_files": "Eingefügte Dateien",
"remove": "Entfernen"
}
},
"show": {
"errors": {
+2 -5
View File
@@ -25,7 +25,7 @@
"new_note": "new note",
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
"errors": {
"note_too_big": "could not create note. note is too big",
"note_to_big": "could not create note. note is too big",
"note_error": "could not create note. please try again.",
"max": "max: {n}",
"empty_content": "note is empty."
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.",
"custom_password": "custom password"
},
"pasting": "Pasting...",
"pasted_files": "Pasted Files",
"remove": "Remove"
}
},
"show": {
"errors": {
+2 -5
View File
@@ -25,7 +25,7 @@
"new_note": "nueva nota",
"new_note_notice": "<b>disponibilidad:</b><br />no se garantiza que la nota se almacene, ya que todo se guarda en la memoria RAM, si se llena se eliminarán las notas más antiguas.<br />(probablemente estará bien, solo está advertido.)",
"errors": {
"note_too_big": "no se pudo crear la nota. la nota es demasiado grande",
"note_to_big": "no se pudo crear la nota. la nota es demasiado grande",
"note_error": "No se ha podido crear la nota. Por favor, inténtelo de nuevo.",
"max": "max: {n}",
"empty_content": "la nota está vacía."
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.",
"custom_password": "contraseña personalizada"
},
"pasting": "Pegando...",
"pasted_files": "Archivos pegados",
"remove": "Eliminar"
}
},
"show": {
"errors": {
+2 -5
View File
@@ -25,7 +25,7 @@
"new_note": "nouvelle note",
"new_note_notice": "<b>disponibilité :</b><br />il n'est pas garanti que la note reste stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)",
"errors": {
"note_too_big": "Impossible de créer une note. La note est trop grande.",
"note_to_big": "Impossible de créer une note. La note est trop grande.",
"note_error": "n'a pas pu créer de note. Veuillez réessayer.",
"max": "max: {n}",
"empty_content": "La note est vide."
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.",
"custom_password": "mot de passe personnalisé"
},
"pasting": "Collage...",
"pasted_files": "Fichiers collés",
"remove": "Supprimer"
}
},
"show": {
"errors": {
+3 -6
View File
@@ -5,7 +5,7 @@
"advanced": "avanzato",
"create": "crea",
"loading": "carica",
"mode": "modalità",
"mode": "modalita",
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
"max": "max",
@@ -25,7 +25,7 @@
"new_note": "nuova nota",
"new_note_notice": "<b>disponibilità:</b><br />la nota non è garantita per essere memorizzata come tutto è tenuto in ram, se si riempie le note più vecchie saranno rimosse.<br />(probabilmente andrà bene, basta essere avvertiti).",
"errors": {
"note_too_big": "impossibile creare una nota. la nota è troppo grande",
"note_to_big": "impossibile creare una nota. la nota è troppo grande",
"note_error": "Impossibile creare la nota. Riprova.",
"max": "max: {n}",
"empty_content": "la nota è vuota."
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.",
"custom_password": "password personalizzata"
},
"pasting": "Incollando...",
"pasted_files": "File incollati",
"remove": "Rimuovi"
}
},
"show": {
"errors": {
+4 -7
View File
@@ -17,7 +17,7 @@
"uploading": "アップロード中",
"downloading": "ダウンロード中",
"qr_code": "QRコード",
"password": "パスワード"
"password": "暗号"
},
"home": {
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
@@ -25,7 +25,7 @@
"new_note": "新しいメモ",
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
"errors": {
"note_too_big": "メモを作成できませんでした。メモが大きすぎる",
"note_to_big": "メモを作成できませんでした。メモが大きすぎる",
"note_error": "メモを作成できませんでした。もう一度お試しください。",
"max": "最大ファイルサイズ: {n}",
"empty_content": "メモは空です。"
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
"custom_password": "カスタムパスワード"
},
"pasting": "貼り付け中...",
"pasted_files": "貼り付けたファイル",
"remove": "削除"
}
},
"show": {
"errors": {
@@ -49,7 +46,7 @@
},
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
"show_note": "メモを表示",
"warning_will_not_see_again": "このノートを再度表示することは<b>できません</b>",
"warning_will_not_see_again": "あなた <b>できません</b> このノートをもう一度見る",
"download_all": "すべてダウンロード",
"links_found": "メモ内にあるリンク:"
},
+5 -8
View File
@@ -6,8 +6,8 @@
"create": "utwórz",
"loading": "ładowanie",
"mode": "tryb",
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {{n} wyświetleń}}",
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {{n} minuty}}",
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {# wyświetleń}}",
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minuty}}",
"max": "maks.",
"share_link": "link udostępniania",
"copy_clipboard": "kopiuj do schowka",
@@ -25,9 +25,9 @@
"new_note": "nowa notatka",
"new_note_notice": "<b>dostępność:</b><br />nie ma gwarancji, że notatka będzie przechowywana, ponieważ wszystko jest przechowywane w pamięci RAM, jeśli się zapełni, najstarsze notatki zostaną usunięte.<br />(prawdopodobnie nic się nie stanie, ale warto ostrzec.)",
"errors": {
"note_too_big": "nie można utworzyć notatki. notatka jest za duża",
"note_to_big": "nie można utworzyć notatki. notatka jest za duża",
"note_error": "nie można utworzyć notatki. spróbuj ponownie.",
"max": "maks.: {n}",
"max": "maks .: {n}",
"empty_content": "notatka jest pusta."
},
"messages": {
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.",
"custom_password": "własne hasło"
},
"pasting": "Wklejanie...",
"pasted_files": "Wklejone pliki",
"remove": "Usuń"
}
},
"show": {
"errors": {
+5 -8
View File
@@ -25,8 +25,8 @@
"new_note": "новая заметка",
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)",
"errors": {
"note_too_big": "нельзя создать новую заметку. заметка слишком большая",
"note_error": "нельзя создать новую заметку. пожалуйста попробуйте позже.",
"note_to_big": "нельзя создать новую заметку. заметка слишком большая",
"note_error": "нельзя создать новую заметку. пожалйста попробуйте позднее.",
"max": "макс: {n}",
"empty_content": "пустая заметка."
},
@@ -36,18 +36,15 @@
"advanced": {
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
"custom_password": "пользовательский пароль"
},
"pasting": "Вставка...",
"pasted_files": "Вставленные файлы",
"remove": "Удалить"
}
},
"show": {
"errors": {
"not_found": "заметка не найдена или была удалена.",
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. заметка уничтожена.",
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.",
"unsupported_type": "неподдерживаемый тип заметки."
},
"explanation": "щелкните ниже, чтобы показать и удалить заметку, если счетчик достиг предела",
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела",
"show_note": "показать заметку",
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
"download_all": "скачать всё",
-61
View File
@@ -1,61 +0,0 @@
{
"common": {
"note": "筆記",
"file": "檔案",
"advanced": "進階",
"create": "創建",
"loading": "載入中",
"mode": "模式",
"views": "{n, plural, =0 {瀏覽次數} =1 {1 次瀏覽} other {# 次瀏覽}}",
"minutes": "{n, plural, =0 {分鐘} =1 {1 分鐘} other {# 分鐘}}",
"max": "最大",
"share_link": "分享連結",
"copy_clipboard": "複製到剪貼板",
"copied_to_clipboard": "已複製到剪貼板",
"encrypting": "加密中",
"decrypting": "解密中",
"uploading": "上傳中",
"downloading": "下載中",
"qr_code": "QR 碼",
"password": "密碼"
},
"home": {
"intro": "輕鬆地以一鍵傳送<i>完全加密</i>的安全筆記或檔案。只需創建筆記並分享連結。",
"explanation": "筆記將在 {type} 後過期並被銷毀。",
"new_note": "新筆記",
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
"errors": {
"note_too_big": "無法創建筆記。筆記過大",
"note_error": "無法創建筆記。請再試一次。",
"max": "最大值:{n}",
"empty_content": "筆記內容為空。"
},
"messages": {
"note_created": "筆記已創建。"
},
"advanced": {
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
"custom_password": "自定義密碼"
},
"pasting": "正在粘貼...",
"pasted_files": "粘貼的檔案",
"remove": "移除"
},
"show": {
"errors": {
"not_found": "筆記未找到或已被刪除。",
"decryption_failed": "密碼錯誤。無法解密。可能是連結已損壞。筆記已被銷毀。",
"unsupported_type": "不支持的筆記類型。"
},
"explanation": "如果計數器達到限制,請點擊下方以顯示並刪除筆記",
"show_note": "顯示筆記",
"warning_will_not_see_again": "您將<b>無法</b>再次查看筆記。",
"download_all": "全部下載",
"links_found": "在筆記中找到的連結:"
},
"file_upload": {
"selected_files": "已選擇的檔案",
"no_files_selected": "未選擇檔案",
"clear": "重置"
}
}
+2 -5
View File
@@ -25,7 +25,7 @@
"new_note": "新建密信",
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
"errors": {
"note_too_big": "创建失败,密信过大。",
"note_to_big": "创建失败,密信过大。",
"note_error": "创建失败,请稍后重试。",
"max": "次数上限:{n}",
"empty_content": "密信不能为空。"
@@ -36,10 +36,7 @@
"advanced": {
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
"custom_password": "自定义密码"
},
"pasting": "正在粘贴...",
"pasted_files": "粘贴的文件",
"remove": "移除"
}
},
"show": {
"errors": {
+21 -19
View File
@@ -7,32 +7,34 @@
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker-rseidelsohn --summary > licenses.csv",
"licenses": "license-checker --summary > licenses.csv",
"locale:download": "node scripts/locale.js",
"test:prepare": "pnpm run build"
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^13.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@zerodevx/svelte-toast": "^0.9.6",
"adm-zip": "^0.5.17",
"dotenv": "^17.4.2",
"license-checker-rseidelsohn": "^5.0.1",
"svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"@lokalise/node-api": "^12.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@types/file-saver": "^2.0.7",
"@zerodevx/svelte-toast": "^0.9.5",
"adm-zip": "^0.5.10",
"dotenv": "^16.4.5",
"svelte": "^4.2.12",
"svelte-check": "^3.6.6",
"svelte-intl-precompile": "^0.12.3",
"tslib": "^2.8.1",
"typescript": "^6.0.3",
"vite": "^8.0.14"
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.7"
},
"dependencies": {
"@fontsource/fira-mono": "^5.2.7",
"cryptgeon": "workspace:*",
"occulto": "^2.0.6",
"pretty-bytes": "^7.1.0",
"uqr": "^0.1.3"
"@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^5.0.8",
"copy-to-clipboard": "^3.3.3",
"file-saver": "^2.0.5",
"occulto": "^2.0.3",
"pretty-bytes": "^6.1.1",
"qrious": "^4.0.2"
}
}
@@ -1,5 +1,3 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><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"

Before

Width:  |  Height:  |  Size: 316 B

After

Width:  |  Height:  |  Size: 287 B

@@ -1,5 +1,3 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><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"

Before

Width:  |  Height:  |  Size: 354 B

After

Width:  |  Height:  |  Size: 325 B

@@ -1,5 +1,3 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><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"

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 736 B

@@ -1,5 +1,3 @@
<script lang="ts"></script>
<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
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: 512 B

After

Width:  |  Height:  |  Size: 483 B

@@ -1,5 +1,3 @@
<script lang="ts"></script>
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><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"

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 732 B

+2 -2
View File
@@ -1,8 +1,8 @@
import { API, type Status } from 'cryptgeon/shared'
import { status as getStatus, type Status } from '@cryptgeon/shared'
import { writable } from 'svelte/store'
export const status = writable<null | Status>(null)
export async function init() {
status.set(await API.status())
status.set(await getStatus())
}
+3 -3
View File
@@ -1,11 +1,11 @@
import { toast } from '@zerodevx/svelte-toast'
import { toast, type SvelteToastOptions } from '@zerodevx/svelte-toast'
export enum NotifyType {
Success = 'success',
Error = 'error',
}
const themeMapping: Record<NotifyType, Record<string, string>> = {
const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
[NotifyType.Success]: {
'--toastBackground': 'var(--ui-clr-primary)',
'--toastBarBackground': 'var(--ui-clr-primary-alt)',
@@ -17,7 +17,7 @@ const themeMapping: Record<NotifyType, Record<string, string>> = {
}
function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
const options = {
const options: SvelteToastOptions = {
duration: 5_000,
theme: {
...themeMapping[type],
@@ -1,17 +1,10 @@
<script lang="ts">
import type { Snippet } from 'svelte'
interface Props {
title: string
children?: Snippet
}
let { title, children }: Props = $props()
export let title: string
</script>
<p>
<b>{title}</b>
{@render children?.()}
<slot />
</p>
<style>
@@ -4,25 +4,15 @@
import { status } from '$lib/stores/status'
import Switch from '$lib/ui/Switch.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import type { Note } from 'cryptgeon/shared'
import type { Note } from '@cryptgeon/shared'
interface Props {
note: Note
timeExpiration?: boolean
customPassword?: string | null
}
export let note: Note
export let timeExpiration = false
export let customPassword: string | null = null
let {
note = $bindable(),
timeExpiration = $bindable(false),
customPassword = $bindable(null),
}: Props = $props()
let hasCustomPassword = false
let hasCustomPassword = $state(false)
$effect(() => {
if (!hasCustomPassword) customPassword = null
})
$: if (!hasCustomPassword) customPassword = null
</script>
<div class="flex col">
+1 -11
View File
@@ -1,14 +1,4 @@
<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>
<button {...$$restProps} on:click><slot /></button>
<style>
button {
@@ -0,0 +1,42 @@
<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>
+6 -11
View File
@@ -3,15 +3,10 @@
import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from 'cryptgeon/shared'
import type { FileDTO } from '@cryptgeon/shared'
interface Props {
label?: string
files?: FileDTO[]
[key: string]: any
}
let { label = '', files = $bindable([]), ...rest }: Props = $props()
export let label: string = ''
export let files: FileDTO[] = []
async function fileToDTO(file: File): Promise<FileDTO> {
return {
@@ -40,7 +35,7 @@
<small>
{label}
</small>
<input {...rest} type="file" onchange={onInput} multiple />
<input {...$$restProps} type="file" on:change={onInput} multiple />
<div class="box">
{#if files.length}
<div>
@@ -50,8 +45,8 @@
{file.name}
</div>
{/each}
<div class="spacer"></div>
<Button onclick={clear}>{$t('file_upload.clear')}</Button>
<div class="spacer" />
<Button on:click={clear}>{$t('file_upload.clear')}</Button>
</div>
{:else}
<div>
+4 -10
View File
@@ -1,10 +1,9 @@
<script lang="ts" module>
<script lang="ts" context="module">
import IconContrast from '$lib/icons/IconContrast.svelte'
import IconCopy from '$lib/icons/IconCopy.svelte'
import IconDice from '$lib/icons/IconDice.svelte'
import IconEye from '$lib/icons/IconEye.svelte'
import IconEyeOff from '$lib/icons/IconEyeOff.svelte'
import type { HTMLButtonAttributes } from 'svelte/elements'
const map = {
contrast: IconContrast,
@@ -16,17 +15,12 @@
</script>
<script lang="ts">
interface Props {
icon: keyof typeof map
}
let { icon, ...rest }: HTMLButtonAttributes & Props = $props()
export let icon: keyof typeof map
</script>
<button type="button" {...rest}>
<button on:click {...$$restProps}>
{#if map[icon]}
{@const SvelteComponent = map[icon]}
<SvelteComponent />
<svelte:component this={map[icon]} />
{/if}
</button>
@@ -1,5 +1,3 @@
<script lang="ts"></script>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 813 B

After

Width:  |  Height:  |  Size: 784 B

+5 -12
View File
@@ -1,4 +1,4 @@
<script lang="ts" module>
<script lang="ts" context="module">
export type NoteResult = {
id: string
password?: string
@@ -10,19 +10,12 @@
import { status } from '$lib/stores/status'
import Button from '$lib/ui/Button.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import QR from './QR.svelte'
import Canvas from './Canvas.svelte'
interface Props {
result: NoteResult
}
export let result: NoteResult
let { result }: Props = $props()
let url = $derived.by(() => {
let url = `${window.location.origin}/note/${result.id}`
if (result.password) url += `#${result.password}`
return url
})
function reset() {
window.location.reload()
@@ -39,7 +32,7 @@
/>
<div>
<QR value={url} />
<Canvas value={url} />
</div>
{#if $status?.theme_new_note_notice}
@@ -48,7 +41,7 @@
</p>
{/if}
<br />
<Button onclick={reset}>{$t('home.new_note')}</Button>
<Button on:click={reset}>{$t('home.new_note')}</Button>
<style>
div {
@@ -1,155 +0,0 @@
<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
@@ -1,45 +0,0 @@
<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>
+20 -53
View File
@@ -1,55 +1,40 @@
<script lang="ts" module>
<script lang="ts" context="module">
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
function saveAs(file: File) {
const url = window.URL.createObjectURL(file)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = file.name
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
a.remove()
}
</script>
<script lang="ts">
import pkg from 'file-saver'
const { saveAs } = pkg
import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile'
import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils'
import type { FileDTO, NotePublic } from 'cryptgeon/shared'
import type { FileDTO, NotePublic } from '@cryptgeon/shared'
interface Props {
note: DecryptedNote
}
let { note }: Props = $props()
export let note: DecryptedNote
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
let files: FileDTO[] = $state([])
let files: FileDTO[] = []
$: if (note.meta.type === 'file') {
files = note.contents
}
$: download = () => {
for (const file of files) {
downloadFile(file)
}
}
async function downloadFile(file: FileDTO) {
// @ts-ignore
const f = new File([file.contents], file.name, {
type: file.type,
})
saveAs(f)
}
$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) : [])
$: links = typeof note.contents === 'string' ? note.contents.match(RE_URL) : []
</script>
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
@@ -58,7 +43,7 @@
<div class="note">
{note.contents}
</div>
<Button onclick={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
{#if links && links.length}
<div class="links">
@@ -75,22 +60,13 @@
{:else}
{#each files as file}
<div class="note file">
<button onclick={() => downloadFile(file)}>
<button on:click={() => downloadFile(file)}>
<b>{file.name}</b>
</button>
<small> {file.type} {prettyBytes(file.size)}</small>
</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}
<Button onclick={download}>{$t('show.download_all')}</Button>
<Button on:click={download}>{$t('show.download_all')}</Button>
{/if}
</div>
@@ -139,13 +115,4 @@
margin-bottom: 0.5rem;
word-wrap: break-word;
}
.preview {
display: block;
max-width: 100%;
max-height: 60vh;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
border: 2px solid var(--ui-bg-1);
}
</style>
+5 -10
View File
@@ -1,18 +1,13 @@
<script lang="ts">
interface Props {
label?: string
value: boolean
color?: boolean
[key: string]: any
}
let { label = '', value = $bindable(), color = true, ...rest }: Props = $props()
export let label: string = ''
export let value: boolean
export let color = true
</script>
<label {...rest}>
<label {...$$restProps}>
<small>{label}</small>
<input type="checkbox" bind:checked={value} />
<span class:color class="slider"></span>
<span class:color class="slider" />
</label>
<style>
+3 -8
View File
@@ -1,16 +1,11 @@
<script lang="ts">
interface Props {
label?: string
value: string
[key: string]: any
}
let { label = '', value = $bindable(), ...rest }: Props = $props()
export let label: string = ''
export let value: string
</script>
<label>
<small>
{label}
</small>
<textarea class="box" {...rest} bind:value></textarea>
<textarea class="box" {...$$restProps} bind:value />
</label>
+20 -40
View File
@@ -2,35 +2,25 @@
import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils'
import { getRandomBytes, Hex } from 'occulto'
import type { HTMLInputAttributes } from 'svelte/elements'
interface Props {
label?: string
value: any
validate?: (value: any) => boolean | string
copy?: boolean
random?: boolean
export let label: string = ''
export let value: any
export let validate: (value: any) => boolean | string = () => true
export let copy: boolean = false
export let random: boolean = false
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() {
console.debug('toggle')
hidden = !hidden
}
@@ -40,32 +30,22 @@
</script>
<label>
<small class:disabled={rest.disabled}>
<small class:disabled={$$restProps.disabled}>
{label}
{#if valid !== true}
<span class="error-text">{valid}</span>
{/if}
</small>
<input bind:value {...rest} {type} autocomplete="off" class:valid={valid === true} />
<input bind:value {...$$restProps} class:valid={valid === true} />
<div class="icons">
{#if isPassword}
<Icon
disabled={rest.disabled}
class="icon"
icon={hidden ? 'eye' : 'eye-off'}
onclick={toggle}
/>
<Icon class="icon" icon={hidden ? 'eye' : 'eye-off'} on:click={toggle} />
{/if}
{#if random}
<Icon disabled={rest.disabled} class="icon" icon="dice" onclick={randomFN} />
<Icon class="icon" icon="dice" on:click={randomFN} />
{/if}
{#if copy}
<Icon
disabled={rest.disabled}
class="icon"
icon="copy"
onclick={() => copyFN(value.toString())}
/>
<Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} />
{/if}
</div>
</label>
+13 -10
View File
@@ -1,21 +1,24 @@
<script lang="ts" module>
<script lang="ts" context="module">
import { writable } from 'svelte/store'
const themes = ['dark', 'light', 'auto'] as const
type Theme = (typeof themes)[number]
enum Theme {
Dark = 'dark',
Light = 'light',
Auto = 'auto',
}
const NextTheme: Record<Theme, Theme> = {
auto: 'light',
light: 'dark',
dark: 'auto',
const NextTheme = {
[Theme.Auto]: Theme.Light,
[Theme.Light]: Theme.Dark,
[Theme.Dark]: Theme.Auto,
}
function init(): Theme {
if (typeof window !== 'undefined') {
const saved = window.localStorage.getItem('theme') as Theme
if (themes.includes(saved)) return saved
if (Object.values(Theme).includes(saved)) return saved
}
return 'auto'
return Theme.Auto
}
export const theme = writable<Theme>(init())
@@ -37,7 +40,7 @@
}
</script>
<button onclick={change}>
<button on:click={change}>
<Icon class="icon" icon="contrast" />
{$theme}
</button>
+2 -1
View File
@@ -1,10 +1,11 @@
import copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile'
import { get } from 'svelte/store'
import { notify } from './toast'
export function copy(value: string) {
window.navigator.clipboard.writeText(value)
copyToClipboard(value)
const msg = get(t)('common.copied_to_clipboard')
notify.success(msg)
}
+22 -132
View File
@@ -10,36 +10,33 @@
import FileUpload from '$lib/ui/FileUpload.svelte'
import Loader from '$lib/ui/Loader.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 Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte'
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared'
import type { FileDTO, Note } from '@cryptgeon/shared'
import { Adapters, PayloadToLargeError, create } from '@cryptgeon/shared'
let note: Note = $state({
let note: Note = {
contents: '',
meta: { type: 'text' },
views: 1,
expiration: 60,
})
let files: FileDTO[] = $state([])
let result: NoteResult | null = $state(null)
let advanced = $state(false)
let isFile = $state(false)
let timeExpiration = $state(false)
let customPassword: string | null = $state(null)
let description = $state('')
let loading: string | null = $state(null)
let isPasting = $state(false)
}
let files: FileDTO[]
let result: NoteResult | null = null
let advanced = false
let isFile = false
let timeExpiration = false
let customPassword: string | null = null
let description = ''
let loading: string | null = null
$effect(() => {
if (!advanced) {
$: if (!advanced) {
note.views = 1
timeExpiration = false
}
})
$effect(() => {
$: {
description = $t('home.explanation', {
values: {
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
@@ -47,78 +44,17 @@
}),
},
})
})
}
$effect(() => {
note.meta.type = isFile ? 'file' : 'text'
})
$: note.meta.type = isFile ? 'file' : 'text'
$effect(() => {
if (!isFile) {
$: if (!isFile) {
note.contents = ''
}
})
async function handlePaste(e: ClipboardEvent) {
const data = e.clipboardData
if (!data) return
const raw: File[] = []
if (data.files.length) {
raw.push(...Array.from(data.files))
}
for (let i = 0; i < data.items.length; i++) {
const item = data.items[i]
if (item.kind === 'file') {
const file = item.getAsFile()
if (file) raw.push(file)
}
}
if (raw.length === 0) return
e.preventDefault()
const seen = new Set<string>()
const pasted: File[] = []
for (const f of raw) {
const key = `${f.name}|${f.size}`
if (!seen.has(key)) {
seen.add(key)
pasted.push(f)
}
}
isPasting = true
const dtos: FileDTO[] = await Promise.all(
pasted.map(async (file) => {
const ext = file.name.includes('.') ? '' : `.${file.type.split('/')[1] || 'bin'}`
const name =
file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`
const renamed = new File([file], name, { type: file.type })
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 {}
async function submit(e: SubmitEvent) {
e.preventDefault()
async function submit() {
try {
loading = $t('common.encrypting')
@@ -141,7 +77,7 @@
else data.views = parseInt(note.views as any)
loading = $t('common.uploading')
const response = await API.create(data)
const response = await create(data)
result = {
id: response.id,
password: customPassword ? undefined : Hex.encode(key),
@@ -149,7 +85,7 @@
notify.success($t('home.messages.note_created'))
} catch (e) {
if (e instanceof PayloadToLargeError) {
notify.error($t('home.errors.note_too_big'))
notify.error($t('home.errors.note_to_big'))
} else if (e instanceof EmptyContentError) {
notify.error($t('home.errors.empty_content'))
} else {
@@ -168,15 +104,8 @@
<p>
{@html $status?.theme_text || $t('home.intro')}
</p>
<form onsubmit={submit} onpaste={handlePaste}>
<form on:submit|preventDefault={submit}>
<fieldset disabled={loading !== null}>
<div class="paste-indicator">
{#if isPasting}
<div class="pasting-overlay">
<div class="pasting-spinner"></div>
<span>{$t('home.pasting')}</span>
</div>
{/if}
{#if isFile}
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
{:else}
@@ -188,9 +117,6 @@
/>
{/if}
<PastedFilesPreview bind:files />
</div>
<div class="bottom">
{#if $status?.allow_files}
<Switch
@@ -207,7 +133,7 @@
bind:value={advanced}
/>
{/if}
<div class="grow"></div>
<div class="grow" />
<div class="tr">
<small>{$t('common.max')}: <MaxSize /> </small>
<br />
@@ -248,40 +174,4 @@
.grow {
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>
@@ -1,20 +1,12 @@
<script lang="ts">
import ThemeToggle from '$lib/ui/ThemeToggle.svelte'
import { status } from '$lib/stores/status'
</script>
<footer>
<ThemeToggle />
<nav>
{#if $status?.theme_home_link !== false}
<a href="/">/home</a>
{/if}
<a href="/about">/about</a>
{#if $status?.imprint_url}
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
{:else if $status?.imprint_html}
<a href="/imprint">/imprint</a>
{/if}
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
code
</a>
@@ -7,10 +7,8 @@
</script>
<header>
<a onclick={reset} href="/">
{#if $status === null}
<!-- waiting for status to load to avoid flashing default logo -->
{:else if $status.theme_image}
<a on:click={reset} href="/">
{#if $status?.theme_image}
<img alt="logo" src={$status.theme_image} />
{:else}
<svg
+1 -6
View File
@@ -8,11 +8,6 @@
import { init as initStores, status } from '$lib/stores/status'
import Footer from '$lib/views/Footer.svelte'
import Header from '$lib/views/Header.svelte'
interface Props {
children?: import('svelte').Snippet
}
let { children }: Props = $props()
onMount(() => {
initStores()
@@ -27,7 +22,7 @@
{#await waitLocale() then _}
<main>
<Header />
{@render children?.()}
<slot />
</main>
<SvelteToast />
@@ -1,33 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { status } from '$lib/stores/status'
status.subscribe((config) => {
if (config != null) {
if (config.imprint_url) {
window.location.href = config.imprint_url
} else if (config.imprint_html == '') {
goto('/about')
}
}
})
</script>
<svelte:head>
<title>Imprint</title>
</svelte:head>
<section class="content">
{#if $status?.imprint_html}
{@html $status.imprint_html}
{/if}
</section>
<style>
section {
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
</style>
@@ -7,32 +7,28 @@
import Loader from '$lib/ui/Loader.svelte'
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import { Adapters, API, type NoteMeta } from 'cryptgeon/shared'
import { Adapters, get, info, type NoteMeta } from '@cryptgeon/shared'
import type { PageData } from './$types'
interface Props {
data: PageData
}
export let data: PageData
let { data }: Props = $props()
let id = data.id
let password: string | null = null
let note: DecryptedNote | null = null
let exists = false
let meta: NoteMeta | null = null
let id = $derived(data.id)
let password: string | null = $state<string | null>(null)
let note: DecryptedNote | null = $state(null)
let exists = $state(false)
let meta: NoteMeta | null = $state(null)
let loading: string | null = null
let error: string | null = null
let loading: string | null = $state(null)
let error: string | null = $state(null)
let valid = $derived(!!password?.length)
$: valid = !!password?.length
onMount(async () => {
// Check if note exists
try {
loading = $t('common.loading')
password = window.location.hash.slice(1)
const note = await API.info(id)
const note = await info(id)
meta = note.meta
exists = true
} catch {
@@ -45,8 +41,7 @@
/**
* Get the actual contents of the note and decrypt it.
*/
async function show(e: SubmitEvent) {
e.preventDefault()
async function show() {
try {
if (!valid) {
error = $t('show.errors.no_password')
@@ -56,7 +51,7 @@
// Load note
error = null
loading = $t('common.downloading')
const data = await API.get(id)
const data = await get(id)
loading = $t('common.decrypting')
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
const key = derived ? derived[0] : Hex.decode(password!)
@@ -91,7 +86,7 @@
{:else if note && !error}
<ShowNote {note} />
{:else}
<form onsubmit={show}>
<form on:submit|preventDefault={show}>
<fieldset>
<p>{$t('show.explanation')}</p>
{#if meta?.derivation}
+2 -3
View File
@@ -2,7 +2,6 @@
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"strict": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
},
"allowSyntheticDefaultImports": true
}
}
+7 -10
View File
@@ -1,17 +1,14 @@
import { sveltekit } from '@sveltejs/kit/vite'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
import { defineConfig } from 'vite'
const port = 3000
const port = 8001
export default defineConfig({
/** @type {import('vite').UserConfig} */
const config = {
clearScreen: false,
server: {
port,
proxy: {
'/api': 'http://localhost:8000',
},
},
server: { port },
preview: { port },
plugins: [sveltekit(), precompileIntl('locales')],
})
}
export default config
+12
View File
@@ -0,0 +1,12 @@
{
"private": true,
"name": "@cryptgeon/proxy",
"type": "module",
"main": "./proxy.js",
"scripts": {
"dev": "node ."
},
"dependencies": {
"http-proxy": "^1.18.1"
}
}
+16
View File
@@ -0,0 +1,16 @@
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')
+22
View File
@@ -0,0 +1,22 @@
{
"private": true,
"name": "@cryptgeon/shared",
"type": "module",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"dev": "tsc -w",
"build": "tsc"
},
"devDependencies": {
"typescript": "^5.3.3"
},
"dependencies": {
"occulto": "^2.0.3"
}
}
@@ -39,15 +39,15 @@ export let client: ClientOptions = {
server: '',
}
function setOptions(options: Partial<ClientOptions>) {
export function setOptions(options: Partial<ClientOptions>) {
client = { ...client, ...options }
}
function getOptions(): ClientOptions {
export function getOptions(): ClientOptions {
return client
}
async function call(options: CallOptions) {
export async function call(options: CallOptions) {
const url = client.server + '/api/' + options.url
const response = await fetch(url, {
method: options.method,
@@ -65,7 +65,7 @@ async function call(options: CallOptions) {
return response.json()
}
async function create(note: Note) {
export async function create(note: Note) {
const { meta, ...rest } = note
const body: NoteCreate = {
...rest,
@@ -79,7 +79,7 @@ async function create(note: Note) {
return data as { id: string }
}
async function get(id: string): Promise<NotePublic> {
export async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
@@ -93,7 +93,7 @@ async function get(id: string): Promise<NotePublic> {
return note
}
async function info(id: string): Promise<NoteInfo> {
export async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
@@ -112,30 +112,17 @@ export type Status = {
max_views: number
max_expiration: number
allow_advanced: boolean
allow_files: boolean
imprint_url: string
imprint_html: string
theme_image: string
theme_text: string
theme_favicon: string
theme_page_title: string
theme_new_note_notice: boolean
theme_home_link: boolean
}
async function status() {
export async function status() {
const data = await call({
url: 'status/',
method: 'get',
})
return data as Status
}
export const API = {
setOptions,
getOptions,
create,
get,
info,
status,
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"incremental": true,
"composite": true,
"target": "es2022",
"module": "es2022",
"rootDir": "./src",
"moduleResolution": "node",
"outDir": "./dist",
"strict": true
}
}
+1 -3
View File
@@ -10,11 +10,9 @@ const config: PlaywrightTestConfig = {
outputDir: './test-results',
testDir: './test',
timeout: 30_000,
fullyParallel: true,
retries: 2,
webServer: {
command: 'pnpm run docker:up',
command: 'docker compose -f docker-compose.dev.yaml up',
port: 3000,
reuseExistingServer: true,
},
+1790 -2311
View File
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,7 +1,2 @@
packages:
- "packages/**"
allowBuilds:
esbuild: true
minimumReleaseAge: 10080 # One week
-68
View File
@@ -1,68 +0,0 @@
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);
});
});
+2 -2
View File
@@ -3,8 +3,8 @@ import { Files } from '../../files'
import { createNoteError } from '../../utils'
test.describe('@web', () => {
test.skip('to big zip', async ({ page }) => {
test('to big zip', async ({ page }) => {
const files = [Files.Zip]
await createNoteError(page, { files }, 'could not create note. note is too big')
await createNoteError(page, { files }, 'note is to big')
})
})
+1 -2
View File
@@ -2,8 +2,7 @@ import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNoteSuccessfully } from '../../utils'
test.describe('@web', () => {
test('1 minute', async ({ page, browserName }) => {
test.skip(browserName === 'webkit')
test('1 minute', async ({ page }) => {
const text = `Virtues value ascetic revaluation sea dead strong burying.`
const minutes = 1
const timeout = minutes * 60_000