Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cbe9fabf2 | |||
| 2923aab916 | |||
| b5102dc647 | |||
| 12d8b87cc1 | |||
| f42662812f | |||
| a551b16216 | |||
| 09840dcf0a | |||
| 24e99b84e0 | |||
| 690b955d5d | |||
| 9b0155dc9a | |||
| 0a56c4c572 | |||
| f6bf8c656c | |||
| 1a243cc96a | |||
| 482795dd9a | |||
| 2907e7c002 | |||
| 4cc9d8a758 | |||
| d652c4ee1e | |||
| 096be03966 | |||
| c53cde6886 | |||
| 0fa5a35dae | |||
| ebbb4efa04 | |||
| a248440bfd | |||
| a1db60d159 | |||
| c2653bee84 | |||
| a2d2acc5de | |||
| 4cc821150d | |||
| e7fb844f66 | |||
| 567a0bed68 | |||
| c13e53404c | |||
| 82862f0e3e | |||
| e20f4626e7 | |||
| e440e4b7e0 | |||
| 808d846737 | |||
| 63c16a797b | |||
| ea50590532 | |||
| b22c3122d7 | |||
| 18af2b2f45 | |||
| c2b557246b | |||
| df9cd08473 | |||
| 0b8e1d1b2e | |||
| 70481341b9 | |||
| 6271ec1ee9 | |||
| c7ec587a2d | |||
| 3e8e82f51c | |||
| c314d4b485 | |||
| 57ea5f0b28 | |||
| fca8761515 | |||
| a47b8a482c | |||
| 847fc9677d | |||
| 6979be0c4a | |||
| f61d3ece8b | |||
| 14d3e9eb03 | |||
| 7c6ba654f6 | |||
| cb20224317 | |||
| 085b1c20df | |||
| 4b1f939281 | |||
| 7b919f2a53 | |||
| 868b49c1c3 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 .
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<br/><br/>
|
<br/><br/>
|
||||||
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
||||||
<a href=""><img src="./.github/lokalise.png" height="50">
|
<a href=""><img src="./.github/lokalise.png" height="50">
|
||||||
|
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
|
||||||
<br/><br/>
|
<br/><br/>
|
||||||
|
|
||||||
EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md)
|
EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md)
|
||||||
@@ -39,6 +40,12 @@ npx cryptgeon send text "This is a secret note"
|
|||||||
|
|
||||||
For more documentation about the CLI see the [readme](./packages/cli/README.md).
|
For more documentation about the CLI see the [readme](./packages/cli/README.md).
|
||||||
|
|
||||||
|
### Raycast Extension
|
||||||
|
|
||||||
|
There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cryptgeon).
|
||||||
|
|
||||||
|
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- send text or files
|
- send text or files
|
||||||
@@ -56,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
|
||||||
|
|
||||||

|

|
||||||
@@ -70,14 +79,15 @@ of the notes even if it tried to.
|
|||||||
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
|
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
|
||||||
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
|
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
|
||||||
| `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. |
|
| `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. |
|
||||||
| `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_. |
|
| `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. |
|
||||||
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
|
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
|
||||||
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
||||||
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
||||||
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
|
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
|
||||||
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
||||||
|
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
|
||||||
|
| `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.
|
||||||
@@ -96,9 +106,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
|
||||||
@@ -144,54 +159,12 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
|
|||||||
### Written Guides
|
### Written Guides
|
||||||
|
|
||||||
- French by [zarevskaya](https://belginux.com/installer-cryptgeon-avec-docker/)
|
- 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/)
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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,7 +66,7 @@ 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. |
|
||||||
@@ -75,25 +75,30 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
|
|||||||
|
|
||||||
## 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 +141,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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ _加密鸽_ 是一个受 [_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 下方的文本。 |
|
||||||
@@ -69,11 +69,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 +107,7 @@ services:
|
|||||||
- 域名 `example.org`
|
- 域名 `example.org`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
version: '3.8'
|
version: "3.8"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
proxy:
|
proxy:
|
||||||
@@ -108,7 +116,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 +140,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
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "packages/cli"
|
"path": "packages/cli"
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "packages/shared"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings": {
|
"settings": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/manual/eviction/
|
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||||
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||||
|
# This prevents the creation of an anonymous volume.
|
||||||
|
tmpfs:
|
||||||
|
- /data
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:latest
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Assumptions:
|
Assumptions:
|
||||||
|
|
||||||
- Traefik 2 installed.
|
- Traefik 2/3 installed.
|
||||||
- External proxy docker network `proxy`.
|
- External proxy docker network `proxy`.
|
||||||
- A certificate resolver `le`.
|
- A certificate resolver `le`.
|
||||||
- A https entrypoint `secure`.
|
- A https entrypoint `secure`.
|
||||||
@@ -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
|
||||||
@@ -34,3 +41,51 @@ services:
|
|||||||
- traefik.http.routers.cryptgeon.entrypoints=secure
|
- traefik.http.routers.cryptgeon.entrypoints=secure
|
||||||
- traefik.http.routers.cryptgeon.tls.certresolver=le
|
- traefik.http.routers.cryptgeon.tls.certresolver=le
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## With basic auth
|
||||||
|
|
||||||
|
Some times it's useful to hide the service behind auth. This is easily achieved with traefik middleware. Many reverse proxies support similar features, so while traefik is used in this example, other reverse proxies can do the same.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.0
|
||||||
|
command:
|
||||||
|
- "--api.insecure=true"
|
||||||
|
- "--providers.docker=true"
|
||||||
|
- "--providers.docker.exposedbydefault=false"
|
||||||
|
- "--entrypoints.web.address=:80"
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
volumes:
|
||||||
|
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||||
|
|
||||||
|
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`
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[tools]
|
||||||
|
pnpm = "11.5.0"
|
||||||
|
rust = "1.95"
|
||||||
|
watchexec = "latest"
|
||||||
|
# Node loaded below from .nvmrc
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
idiomatic_version_file_enable_tools = ["node"]
|
||||||
@@ -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.9.0"
|
"packageManager": "pnpm@11.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
version = "2.8.0"
|
version = "2.9.1"
|
||||||
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
|
|||||||
.unwrap_or("true".to_string())
|
.unwrap_or("true".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap();
|
.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
|
// THEME
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SharedState {
|
||||||
|
pub locks: LockMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
|
||||||
@@ -1,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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)) => {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ pub struct Status {
|
|||||||
pub max_expiration: u32,
|
pub max_expiration: u32,
|
||||||
pub allow_advanced: bool,
|
pub allow_advanced: bool,
|
||||||
pub allow_files: bool,
|
pub allow_files: bool,
|
||||||
|
pub imprint_url: String,
|
||||||
|
pub imprint_html: String,
|
||||||
// Theme
|
// Theme
|
||||||
pub theme_image: String,
|
pub theme_image: String,
|
||||||
pub theme_text: String,
|
pub theme_text: String,
|
||||||
@@ -28,6 +30,8 @@ pub async fn get_status() -> (StatusCode, Json<Status>) {
|
|||||||
max_expiration: *config::MAX_EXPIRATION,
|
max_expiration: *config::MAX_EXPIRATION,
|
||||||
allow_advanced: *config::ALLOW_ADVANCED,
|
allow_advanced: *config::ALLOW_ADVANCED,
|
||||||
allow_files: *config::ALLOW_FILES,
|
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_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
|
||||||
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(),
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
|
|||||||
let serialized = serde_json::to_string(¬e.clone()).unwrap();
|
let serialized = serde_json::to_string(¬e.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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import pkg from './package.json' with { type: 'json' }
|
|
||||||
import { build } from 'tsup'
|
import { build } from 'tsup'
|
||||||
|
import pkg from './package.json' with { type: 'json' }
|
||||||
|
|
||||||
const watch = process.argv.slice(2)[0] === '--watch'
|
const watch = process.argv.slice(2)[0] === '--watch'
|
||||||
|
|
||||||
await build({
|
await build({
|
||||||
entry: ['src/index.ts', 'src/cli.ts'],
|
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
|
||||||
dts: true,
|
dts: true,
|
||||||
minify: true,
|
minify: true,
|
||||||
format: ['esm', 'cjs'],
|
format: ['esm', 'cjs'],
|
||||||
|
target: 'es2020',
|
||||||
clean: true,
|
clean: true,
|
||||||
define: { VERSION: `"${pkg.version}"` },
|
define: { VERSION: `"${pkg.version}"` },
|
||||||
watch,
|
watch,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cryptgeon",
|
"name": "cryptgeon",
|
||||||
"version": "2.8.0",
|
"version": "2.9.1",
|
||||||
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./dist/index.js"
|
".": "./dist/index.js",
|
||||||
|
"./shared": {
|
||||||
|
"import": "./dist/shared/shared.js",
|
||||||
|
"types": "./dist/shared/shared.d.ts"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -25,18 +29,17 @@
|
|||||||
"prepublishOnly": "run-s build"
|
"prepublishOnly": "run-s build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commander-js/extra-typings": "^12.0.1",
|
"@commander-js/extra-typings": "^12.1.0",
|
||||||
"@cryptgeon/shared": "workspace:*",
|
"@types/inquirer": "^9.0.9",
|
||||||
"@types/inquirer": "^9.0.7",
|
"@types/mime": "^4.0.0",
|
||||||
"@types/mime": "^3.0.4",
|
"@types/node": "^20.19.41",
|
||||||
"@types/node": "^20.11.24",
|
"commander": "^12.1.0",
|
||||||
"commander": "^12.0.0",
|
"inquirer": "^9.3.8",
|
||||||
"inquirer": "^9.2.15",
|
"mime": "^4.1.0",
|
||||||
"mime": "^4.0.1",
|
"occulto": "^2.0.6",
|
||||||
"occulto": "^2.0.3",
|
|
||||||
"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,14 +1,15 @@
|
|||||||
import { Adapters, get, info, setOptions } from '@cryptgeon/shared'
|
|
||||||
import inquirer from 'inquirer'
|
import inquirer from 'inquirer'
|
||||||
import { access, constants, writeFile } from 'node:fs/promises'
|
import { access, constants, writeFile } from 'node:fs/promises'
|
||||||
import { basename, resolve } from 'node:path'
|
import { basename, resolve } from 'node:path'
|
||||||
import { AES, Hex } from 'occulto'
|
import { AES, Hex } from 'occulto'
|
||||||
import pretty from 'pretty-bytes'
|
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) {
|
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
|
||||||
setOptions({ server: url.origin })
|
API.setOptions({ server: url.origin })
|
||||||
const id = url.pathname.split('/')[2]
|
const id = url.pathname.split('/')[2]
|
||||||
const preview = await info(id).catch(() => {
|
const preview = await API.info(id).catch(() => {
|
||||||
throw new Error('Note does not exist or is expired')
|
throw new Error('Note does not exist or is expired')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
|
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
|
||||||
const note = await get(id)
|
const note = await API.get(id)
|
||||||
|
|
||||||
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
|
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
|
||||||
switch (note.meta.type) {
|
switch (note.meta.type) {
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { readFile, stat } from 'node:fs/promises'
|
import { readFile, stat } from 'node:fs/promises'
|
||||||
import { basename } from 'node:path'
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
import { Adapters, create, getOptions, FileDTO, Note, NoteMeta } from '@cryptgeon/shared'
|
|
||||||
import mime from 'mime'
|
import mime from 'mime'
|
||||||
import { AES, Hex } from 'occulto'
|
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 }
|
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
|
||||||
|
|
||||||
@@ -38,8 +39,8 @@ export async function upload(input: string | string[], options: UploadOptions):
|
|||||||
|
|
||||||
// Create the actual note and upload it.
|
// Create the actual note and upload it.
|
||||||
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
|
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
|
||||||
const result = await create(note)
|
const result = await API.create(note)
|
||||||
let url = `${getOptions().server}/note/${result.id}`
|
let url = `${API.getOptions().server}/note/${result.id}`
|
||||||
if (!derived) url += `#${Hex.encode(key)}`
|
if (!derived) url += `#${Hex.encode(key)}`
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { Argument, Option, program } from '@commander-js/extra-typings'
|
import { Argument, Option, program } from '@commander-js/extra-typings'
|
||||||
import { setOptions, status } from '@cryptgeon/shared'
|
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
|
|
||||||
import { download } from './download.js'
|
import { download } from './actions/download.js'
|
||||||
import { parseFile, parseNumber } from './parsers.js'
|
import { upload } from './actions/upload.js'
|
||||||
import { getStdin } from './stdin.js'
|
import { API } from './shared/api.js'
|
||||||
import { upload } from './upload.js'
|
import { parseFile, parseNumber } from './utils/parsers.js'
|
||||||
import { checkConstrains, exit } from './utils.js'
|
import { getStdin } from './utils/stdin.js'
|
||||||
|
import { checkConstrains, exit } from './utils/utils.js'
|
||||||
|
|
||||||
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
|
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
|
||||||
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
|
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')
|
.description('show information about the server')
|
||||||
.addOption(server)
|
.addOption(server)
|
||||||
.action(async (options) => {
|
.action(async (options) => {
|
||||||
setOptions({ server: options.server })
|
API.setOptions({ server: options.server })
|
||||||
const response = await status()
|
const response = await API.status()
|
||||||
const formatted = {
|
const formatted = {
|
||||||
...response,
|
...response,
|
||||||
max_size: prettyBytes(response.max_size),
|
max_size: prettyBytes(response.max_size),
|
||||||
@@ -54,7 +54,7 @@ send
|
|||||||
.addOption(minutes)
|
.addOption(minutes)
|
||||||
.addOption(password)
|
.addOption(password)
|
||||||
.action(async (files, options) => {
|
.action(async (files, options) => {
|
||||||
setOptions({ server: options.server })
|
API.setOptions({ server: options.server })
|
||||||
await checkConstrains(options)
|
await checkConstrains(options)
|
||||||
options.password ||= await getStdin()
|
options.password ||= await getStdin()
|
||||||
try {
|
try {
|
||||||
@@ -72,7 +72,7 @@ send
|
|||||||
.addOption(minutes)
|
.addOption(minutes)
|
||||||
.addOption(password)
|
.addOption(password)
|
||||||
.action(async (text, options) => {
|
.action(async (text, options) => {
|
||||||
setOptions({ server: options.server })
|
API.setOptions({ server: options.server })
|
||||||
await checkConstrains(options)
|
await checkConstrains(options)
|
||||||
options.password ||= await getStdin()
|
options.password ||= await getStdin()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export * from '@cryptgeon/shared'
|
export * from './actions/download.js'
|
||||||
export * from './download.js'
|
export * from './actions/upload.js'
|
||||||
export * from './upload.js'
|
export * from './shared/adapters.js'
|
||||||
export * from './utils.js'
|
export * from './shared/api.js'
|
||||||
|
|||||||
@@ -39,15 +39,15 @@ export let client: ClientOptions = {
|
|||||||
server: '',
|
server: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setOptions(options: Partial<ClientOptions>) {
|
function setOptions(options: Partial<ClientOptions>) {
|
||||||
client = { ...client, ...options }
|
client = { ...client, ...options }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOptions(): ClientOptions {
|
function getOptions(): ClientOptions {
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function call(options: CallOptions) {
|
async function call(options: CallOptions) {
|
||||||
const url = client.server + '/api/' + options.url
|
const url = client.server + '/api/' + options.url
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: options.method,
|
method: options.method,
|
||||||
@@ -65,7 +65,7 @@ export async function call(options: CallOptions) {
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(note: Note) {
|
async function create(note: Note) {
|
||||||
const { meta, ...rest } = note
|
const { meta, ...rest } = note
|
||||||
const body: NoteCreate = {
|
const body: NoteCreate = {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -79,7 +79,7 @@ export async function create(note: Note) {
|
|||||||
return data as { id: string }
|
return data as { id: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function get(id: string): Promise<NotePublic> {
|
async function get(id: string): Promise<NotePublic> {
|
||||||
const data = await call({
|
const data = await call({
|
||||||
url: `notes/${id}`,
|
url: `notes/${id}`,
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
@@ -93,7 +93,7 @@ export async function get(id: string): Promise<NotePublic> {
|
|||||||
return note
|
return note
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function info(id: string): Promise<NoteInfo> {
|
async function info(id: string): Promise<NoteInfo> {
|
||||||
const data = await call({
|
const data = await call({
|
||||||
url: `notes/${id}`,
|
url: `notes/${id}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
@@ -112,6 +112,9 @@ export type Status = {
|
|||||||
max_views: number
|
max_views: number
|
||||||
max_expiration: number
|
max_expiration: number
|
||||||
allow_advanced: boolean
|
allow_advanced: boolean
|
||||||
|
allow_files: boolean
|
||||||
|
imprint_url: string
|
||||||
|
imprint_html: string
|
||||||
theme_image: string
|
theme_image: string
|
||||||
theme_text: string
|
theme_text: string
|
||||||
theme_favicon: string
|
theme_favicon: string
|
||||||
@@ -119,10 +122,19 @@ export type Status = {
|
|||||||
theme_new_note_notice: boolean
|
theme_new_note_notice: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function status() {
|
async function status() {
|
||||||
const data = await call({
|
const data = await call({
|
||||||
url: 'status/',
|
url: 'status/',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
})
|
})
|
||||||
return data as Status
|
return data as Status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const API = {
|
||||||
|
setOptions,
|
||||||
|
getOptions,
|
||||||
|
create,
|
||||||
|
get,
|
||||||
|
info,
|
||||||
|
status,
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export function parseURL(value: string, _: URL): URL {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseNumber(value: string, _: number): number {
|
export function parseNumber(value: string, _: number): number {
|
||||||
const n = parseInt(value, 10)
|
const n = Number.parseInt(value, 10)
|
||||||
if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
|
if (Number.isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ export function getStdin(timeout: number = 10): Promise<string> {
|
|||||||
resolve('')
|
resolve('')
|
||||||
}, timeout)
|
}, timeout)
|
||||||
|
|
||||||
|
process.stdin.on('error', reject)
|
||||||
process.stdin.on('data', dataHandler)
|
process.stdin.on('data', dataHandler)
|
||||||
process.stdin.on('end', endHandler)
|
process.stdin.on('end', endHandler)
|
||||||
})
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { status } from '@cryptgeon/shared'
|
|
||||||
import { exit as exitNode } from 'node:process'
|
import { exit as exitNode } from 'node:process'
|
||||||
|
import { API } from '../shared/api.js'
|
||||||
|
|
||||||
export function exit(message: string) {
|
export function exit(message: string) {
|
||||||
console.error(message)
|
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) exit('cannot set view and minutes constrains simultaneously')
|
||||||
if (!views && !minutes) constrains.views = 1
|
if (!views && !minutes) constrains.views = 1
|
||||||
|
|
||||||
const response = await status()
|
const response = await API.status()
|
||||||
if (views && views > response.max_views)
|
if (views && views > response.max_views)
|
||||||
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
|
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
|
||||||
if (minutes && minutes > response.max_expiration)
|
if (minutes && minutes > response.max_expiration)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"module": "es2022",
|
"module": "es2022",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "Bundler",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"emitDeclarationOnly": true,
|
"emitDeclarationOnly": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"uploading": "アップロード中",
|
"uploading": "アップロード中",
|
||||||
"downloading": "ダウンロード中",
|
"downloading": "ダウンロード中",
|
||||||
"qr_code": "QRコード",
|
"qr_code": "QRコード",
|
||||||
"password": "暗号"
|
"password": "パスワード"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
|
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
|
||||||
@@ -46,7 +46,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": "メモ内にあるリンク:"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"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": "пустая заметка."
|
||||||
},
|
},
|
||||||
@@ -41,10 +41,10 @@
|
|||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "заметка не найдена или была удалена.",
|
"not_found": "заметка не найдена или была удалена.",
|
||||||
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.",
|
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. заметка уничтожена.",
|
||||||
"unsupported_type": "неподдерживаемый тип заметки."
|
"unsupported_type": "неподдерживаемый тип заметки."
|
||||||
},
|
},
|
||||||
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела",
|
"explanation": "щелкните ниже, чтобы показать и удалить заметку, если счетчик достиг предела",
|
||||||
"show_note": "показать заметку",
|
"show_note": "показать заметку",
|
||||||
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
|
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
|
||||||
"download_all": "скачать всё",
|
"download_all": "скачать всё",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"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_to_big": "無法創建筆記。筆記過大",
|
||||||
|
"note_error": "無法創建筆記。請再試一次。",
|
||||||
|
"max": "最大值:{n}",
|
||||||
|
"empty_content": "筆記內容為空。"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"note_created": "筆記已創建。"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
|
||||||
|
"custom_password": "自定義密碼"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": "重置"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,28 +13,25 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
"@types/file-saver": "^2.0.7",
|
"@zerodevx/svelte-toast": "^0.9.6",
|
||||||
"@zerodevx/svelte-toast": "^0.9.5",
|
"adm-zip": "^0.5.17",
|
||||||
"adm-zip": "^0.5.10",
|
"dotenv": "^17.4.2",
|
||||||
"dotenv": "^16.4.5",
|
"svelte": "^5.55.9",
|
||||||
"svelte": "^4.2.12",
|
"svelte-check": "^4.4.8",
|
||||||
"svelte-check": "^3.6.6",
|
|
||||||
"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": {
|
||||||
"@cryptgeon/shared": "workspace:*",
|
"@fontsource/fira-mono": "^5.2.7",
|
||||||
"@fontsource/fira-mono": "^5.0.8",
|
"cryptgeon": "workspace:*",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"occulto": "^2.0.6",
|
||||||
"file-saver": "^2.0.5",
|
"pretty-bytes": "^7.1.0",
|
||||||
"occulto": "^2.0.3",
|
"uqr": "^0.1.3"
|
||||||
"pretty-bytes": "^6.1.1",
|
|
||||||
"qrious": "^4.0.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,8 +1,8 @@
|
|||||||
import { status as getStatus, type Status } from '@cryptgeon/shared'
|
import { API, type Status } from 'cryptgeon/shared'
|
||||||
import { writable } from 'svelte/store'
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
export const status = writable<null | Status>(null)
|
export const status = writable<null | Status>(null)
|
||||||
|
|
||||||
export async function init() {
|
export async function init() {
|
||||||
status.set(await getStatus())
|
status.set(await API.status())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { toast, type SvelteToastOptions } from '@zerodevx/svelte-toast'
|
import { toast } from '@zerodevx/svelte-toast'
|
||||||
|
|
||||||
export enum NotifyType {
|
export enum NotifyType {
|
||||||
Success = 'success',
|
Success = 'success',
|
||||||
Error = 'error',
|
Error = 'error',
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
|
const themeMapping: Record<NotifyType, Record<string, string>> = {
|
||||||
[NotifyType.Success]: {
|
[NotifyType.Success]: {
|
||||||
'--toastBackground': 'var(--ui-clr-primary)',
|
'--toastBackground': 'var(--ui-clr-primary)',
|
||||||
'--toastBarBackground': 'var(--ui-clr-primary-alt)',
|
'--toastBarBackground': 'var(--ui-clr-primary-alt)',
|
||||||
@@ -17,7 +17,7 @@ const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
|
function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
|
||||||
const options: SvelteToastOptions = {
|
const options = {
|
||||||
duration: 5_000,
|
duration: 5_000,
|
||||||
theme: {
|
theme: {
|
||||||
...themeMapping[type],
|
...themeMapping[type],
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let title: string
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
children?: Snippet
|
||||||
|
}
|
||||||
|
|
||||||
|
let { title, children }: Props = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<b>▶ {title}</b>
|
<b>▶ {title}</b>
|
||||||
<slot />
|
{@render children?.()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -4,15 +4,25 @@
|
|||||||
import { status } from '$lib/stores/status'
|
import { status } from '$lib/stores/status'
|
||||||
import Switch from '$lib/ui/Switch.svelte'
|
import Switch from '$lib/ui/Switch.svelte'
|
||||||
import TextInput from '$lib/ui/TextInput.svelte'
|
import TextInput from '$lib/ui/TextInput.svelte'
|
||||||
import type { Note } from '@cryptgeon/shared'
|
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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -3,10 +3,15 @@
|
|||||||
|
|
||||||
import Button from '$lib/ui/Button.svelte'
|
import Button from '$lib/ui/Button.svelte'
|
||||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||||
import type { FileDTO } from '@cryptgeon/shared'
|
import type { FileDTO } from 'cryptgeon/shared'
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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 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 |
@@ -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 { result }: Props = $props()
|
||||||
|
|
||||||
|
let url = $derived.by(() => {
|
||||||
let url = `${window.location.origin}/note/${result.id}`
|
let url = `${window.location.origin}/note/${result.id}`
|
||||||
if (result.password) url += `#${result.password}`
|
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,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>
|
||||||
@@ -1,40 +1,55 @@
|
|||||||
<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) {
|
||||||
|
const url = window.URL.createObjectURL(file)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.style.display = 'none'
|
||||||
|
a.href = url
|
||||||
|
a.download = file.name
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
a.remove()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import pkg from 'file-saver'
|
|
||||||
const { saveAs } = pkg
|
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
import Button from '$lib/ui/Button.svelte'
|
import Button from '$lib/ui/Button.svelte'
|
||||||
import { 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>
|
||||||
@@ -43,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">
|
||||||
@@ -60,13 +75,13 @@
|
|||||||
{: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>
|
||||||
{/each}
|
{/each}
|
||||||
<Button on:click={download}>{$t('show.download_all')}</Button>
|
<Button onclick={download}>{$t('show.download_all')}</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import copyToClipboard from 'copy-to-clipboard'
|
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
import { get } from 'svelte/store'
|
import { get } from 'svelte/store'
|
||||||
|
|
||||||
import { notify } from './toast'
|
import { notify } from './toast'
|
||||||
|
|
||||||
export function copy(value: string) {
|
export function copy(value: string) {
|
||||||
copyToClipboard(value)
|
window.navigator.clipboard.writeText(value)
|
||||||
const msg = get(t)('common.copied_to_clipboard')
|
const msg = get(t)('common.copied_to_clipboard')
|
||||||
notify.success(msg)
|
notify.success(msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,30 +13,31 @@
|
|||||||
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 type { FileDTO, Note } from '@cryptgeon/shared'
|
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared'
|
||||||
import { Adapters, PayloadToLargeError, create } 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)
|
||||||
|
|
||||||
$: if (!advanced) {
|
$effect(() => {
|
||||||
|
if (!advanced) {
|
||||||
note.views = 1
|
note.views = 1
|
||||||
timeExpiration = false
|
timeExpiration = false
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
$: {
|
$effect(() => {
|
||||||
description = $t('home.explanation', {
|
description = $t('home.explanation', {
|
||||||
values: {
|
values: {
|
||||||
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
|
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
|
||||||
@@ -44,17 +45,22 @@
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
})
|
||||||
|
|
||||||
$: note.meta.type = isFile ? 'file' : 'text'
|
$effect(() => {
|
||||||
|
note.meta.type = isFile ? 'file' : 'text'
|
||||||
|
})
|
||||||
|
|
||||||
$: if (!isFile) {
|
$effect(() => {
|
||||||
|
if (!isFile) {
|
||||||
note.contents = ''
|
note.contents = ''
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
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')
|
||||||
|
|
||||||
@@ -77,7 +83,7 @@
|
|||||||
else data.views = parseInt(note.views as any)
|
else data.views = parseInt(note.views as any)
|
||||||
|
|
||||||
loading = $t('common.uploading')
|
loading = $t('common.uploading')
|
||||||
const response = await create(data)
|
const response = await API.create(data)
|
||||||
result = {
|
result = {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
password: customPassword ? undefined : Hex.encode(key),
|
password: customPassword ? undefined : Hex.encode(key),
|
||||||
@@ -104,7 +110,7 @@
|
|||||||
<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}>
|
||||||
<fieldset disabled={loading !== null}>
|
<fieldset disabled={loading !== null}>
|
||||||
{#if isFile}
|
{#if isFile}
|
||||||
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||||
@@ -133,7 +139,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 />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ThemeToggle from '$lib/ui/ThemeToggle.svelte'
|
import ThemeToggle from '$lib/ui/ThemeToggle.svelte'
|
||||||
|
import { status } from '$lib/stores/status'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
@@ -7,6 +8,11 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">/home</a>
|
<a href="/">/home</a>
|
||||||
<a href="/about">/about</a>
|
<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">
|
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
|
||||||
code
|
code
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<a on:click={reset} href="/">
|
<a onclick={reset} href="/">
|
||||||
{#if $status?.theme_image}
|
{#if $status?.theme_image}
|
||||||
<img alt="logo" src={$status.theme_image} />
|
<img alt="logo" src={$status.theme_image} />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<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,28 +7,32 @@
|
|||||||
import Loader from '$lib/ui/Loader.svelte'
|
import Loader from '$lib/ui/Loader.svelte'
|
||||||
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
|
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
|
||||||
import TextInput from '$lib/ui/TextInput.svelte'
|
import TextInput from '$lib/ui/TextInput.svelte'
|
||||||
import { Adapters, get, info, 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
|
||||||
try {
|
try {
|
||||||
loading = $t('common.loading')
|
loading = $t('common.loading')
|
||||||
password = window.location.hash.slice(1)
|
password = window.location.hash.slice(1)
|
||||||
const note = await info(id)
|
const note = await API.info(id)
|
||||||
meta = note.meta
|
meta = note.meta
|
||||||
exists = true
|
exists = true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -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')
|
||||||
@@ -51,7 +56,7 @@
|
|||||||
// Load note
|
// Load note
|
||||||
error = null
|
error = null
|
||||||
loading = $t('common.downloading')
|
loading = $t('common.downloading')
|
||||||
const data = await get(id)
|
const data = await API.get(id)
|
||||||
loading = $t('common.decrypting')
|
loading = $t('common.decrypting')
|
||||||
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
|
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
|
||||||
const key = derived ? derived[0] : Hex.decode(password!)
|
const key = derived ? derived[0] : Hex.decode(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}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"extends": "./.svelte-kit/tsconfig.json",
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
}
|
"skipLibCheck": true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +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'
|
||||||
|
|
||||||
const port = 8001
|
const port = 3000
|
||||||
|
|
||||||
/** @type {import('vite').UserConfig} */
|
/** @type {import('vite').UserConfig} */
|
||||||
const config = {
|
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')],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"private": true,
|
|
||||||
"name": "@cryptgeon/proxy",
|
|
||||||
"type": "module",
|
|
||||||
"main": "./proxy.js",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "node ."
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"http-proxy": "^1.18.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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')
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"incremental": true,
|
|
||||||
"composite": true,
|
|
||||||
"target": "es2022",
|
|
||||||
"module": "es2022",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"outDir": "./dist",
|
|
||||||
"strict": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,7 @@
|
|||||||
packages:
|
packages:
|
||||||
- "packages/**"
|
- "packages/**"
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
|
||||||
|
minimumReleaseAge: 10080 # One week
|
||||||
|
|||||||