Compare commits

..

8 Commits

Author SHA1 Message Date
0ac151f42c fix default import 2023-04-27 22:52:53 +02:00
86cdb6d5da cli 2023-04-27 19:01:32 +02:00
55b6e9ea51 don't use blob 2023-04-27 19:01:26 +02:00
be0d523d90 actions maintenance 2023-04-27 09:36:33 +02:00
5817fd19b1 update deps 2023-04-27 09:36:21 +02:00
5fb6f65a13 update deps 2023-04-27 09:36:14 +02:00
420370acaf Merge remote-tracking branch 'origin/main' into CLI 2023-04-26 18:10:07 +02:00
4c25ca005e move to packages 2023-04-26 18:06:09 +02:00
122 changed files with 4646 additions and 7176 deletions

View File

View File

@@ -7,27 +7,6 @@ on:
- 'v*.*.*' - 'v*.*.*'
jobs: jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
cache: 'pnpm'
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
- run: |
pnpm install --frozen-lockfile
pnpm run build
- run: npm publish
working-directory: ./packages/cli
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -10,33 +10,26 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v2
- uses: actions/setup-node@v3
# Node
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: with:
cache: 'pnpm'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
# Docker - uses: docker/setup-qemu-action@v2
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v2
- uses: docker/setup-buildx-action@v3
with: with:
install: true install: true
- name: Build docker image
run: npm run test:prepare
- name: Prepare - name: Prepare
run: | run: |
pnpm install npm install playwright
pnpm exec playwright install --with-deps npx playwright install --with-deps
pnpm run test:prepare
- name: Run your tests - name: Run your tests
run: pnpm test run: npm test
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with: with:
name: playwright-report name: test-results
path: test-results/ path: test-results
retention-days: 7

1
.gitignore vendored
View File

@@ -8,4 +8,3 @@ target
# Testing # Testing
test-results test-results
tmp

2
.nvmrc
View File

@@ -1 +1 @@
v22.14.0 v18.16

View File

@@ -5,30 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.4.0] - 2023-11-01 ## [2.2.0] - 2023-01-14
### Changed
- Removed HTML sanitation, display the original message as string
- Links are now displayed under the note in a separate section
## [2.3.1] - 2023-06-23
### Added
- #92: Endpoint (`/api/live/`) for checking health status.
## [2.3.0] - 2023-05-30
### Added
- New CLI 🎉.
- Russian language.
- Option for reducing note id size (`ID_LENGTH`).
### Changed
- Moved to monorepo.
### Changed ### Changed

View File

@@ -1,614 +0,0 @@
{
"info": {
"_postman_id": "3aaeac19-4eac-4911-b3c8-912b17a48634",
"name": "Cryptgeon",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Notes",
"item": [
{
"name": "Preview",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
},
"description": "This endpoint is to query wether a note exists, without actually opening it. No view limits are used here, as contents of the note are not available, only the `meta` field is returned, which is public."
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:24:29 GMT"
}
],
"cookie": [],
"body": "{}"
},
{
"name": "404",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
}
},
"status": "Not Found",
"code": 404,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:25:26 GMT"
}
],
"cookie": [],
"body": ""
}
]
},
{
"name": "Create",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const jsonData = pm.response.json();",
"pm.collectionVariables.set('NOTE_ID', jsonData.id)",
""
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"response": [
{
"name": "Simple",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
},
{
"name": "5 Minutes",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"expiration\": 5,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
},
{
"name": "3 Views",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 3,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": ["{{BASE}}"],
"path": ["notes", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
}
]
},
{
"name": "Read",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
},
"description": "This endpoint gets the actual contents of a note. It's a `DELETE` endpoint, es it decreases the `view` counter, and deletes the note if `0` is reached."
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:59:07 GMT"
}
],
"cookie": [],
"body": "{\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\",\n \"contents\": \"Some encrypted content\"\n}"
},
{
"name": "404",
"originalRequest": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": ["{{BASE}}"],
"path": ["notes", ":id"],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
}
},
"status": "Not Found",
"code": 404,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:59:15 GMT"
}
],
"cookie": [],
"body": ""
}
]
}
]
},
{
"name": "Status",
"item": [
{
"name": "Get server status",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/status/",
"host": ["{{BASE}}"],
"path": ["status", ""]
}
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/status/",
"host": ["{{BASE}}"],
"path": ["status", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:56:45 GMT"
}
],
"cookie": [],
"body": "{\n \"version\": \"2.3.0-beta.4\",\n \"max_size\": 10485760,\n \"max_views\": 100,\n \"max_expiration\": 360,\n \"allow_advanced\": true,\n \"theme_image\": \"\",\n \"theme_text\": \"\",\n \"theme_page_title\": \"\",\n \"theme_favicon\": \"\"\n}"
}
]
},
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": ["{{BASE}}"],
"path": ["live", ""]
},
"description": "Return `200` for healthy service. `503` if service is unavailable."
},
"response": [
{
"name": "Healthy",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": ["{{BASE}}"],
"path": ["live", ""]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Thu, 22 Jun 2023 20:17:58 GMT"
}
],
"cookie": [],
"body": null
},
{
"name": "Service Unavilable",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": ["{{BASE}}"],
"path": ["live", ""]
}
},
"status": "Service Unavailable",
"code": 503,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Thu, 22 Jun 2023 20:18:55 GMT"
}
],
"cookie": [],
"body": null
}
]
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [""]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [""]
}
}
],
"variable": [
{
"key": "BASE",
"value": "http://localhost:3000/api",
"type": "default"
},
{
"key": "NOTE_ID",
"value": "",
"type": "default"
}
]
}

View File

@@ -1,27 +1,26 @@
# FRONTEND # FRONTEND
FROM node:22-alpine as client FROM node:16-alpine as client
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /tmp WORKDIR /tmp
RUN npm install -g pnpm@8
COPY . . COPY . .
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# WORKDIR /tmp/packages/frontend
RUN pnpm run build RUN pnpm run build
# BACKEND # BACKEND
FROM rust:1.85-alpine as backend FROM rust:1.69-alpine as backend
WORKDIR /tmp WORKDIR /tmp
RUN apk add --no-cache libc-dev openssl-dev alpine-sdk RUN apk add libc-dev openssl-dev alpine-sdk
COPY ./packages/backend/Cargo.* ./
RUN cargo fetch
COPY ./packages/backend ./ COPY ./packages/backend ./
RUN RUSTFLAGS="-Ctarget-feature=-crt-static" cargo build --release RUN cargo build --release
# RUNNER # RUNNER
FROM alpine:3.19 FROM alpine
WORKDIR /app WORKDIR /app
RUN apk add --no-cache curl libgcc
COPY --from=backend /tmp/target/release/cryptgeon . COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/packages/frontend/build ./frontend COPY --from=client /tmp/packages/frontend/build ./frontend
ENV FRONTEND_PATH="./frontend" ENV FRONTEND_PATH="./frontend"

102
README.md
View File

@@ -12,15 +12,13 @@
<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)
## About? ## About?
_cryptgeon_ is a secure, open source sharing note or file service inspired by [_PrivNote_](https://privnote.com). _cryptgeon_ is a secure, open source sharing note or file service inspired by [_PrivNote_](https://privnote.com)
It includes a server, a web page and a CLI client.
> 🌍 If you want to translate the project feel free to reach out to me. > 🌍 If you want to translate the project feel free to reach out to me.
> >
@@ -28,27 +26,10 @@ It includes a server, a web page and a CLI client.
## Live Service / Demo ## Live Service / Demo
### Web
Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org) Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org)
### CLI
```
npx cryptgeon send text "This is a secret note"
```
For more documentation about the CLI see the [readme](./packages/cli/README.md).
### Raycast Extension
There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cryptgeon).
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
## Features ## Features
- send text or files
- server cannot decrypt contents due to client side encryption - server cannot decrypt contents due to client side encryption
- view or time constraints - view or time constraints
- in memory, no persistence - in memory, no persistence
@@ -63,36 +44,28 @@ client side with the <code>key</code> and then sent to the server. data is store
never persisted to disk. the server never sees the encryption key and cannot decrypt the contents never persisted to disk. the server never sees the encryption key and cannot decrypt the contents
of the notes even if it tried to. of the notes even if it tried to.
> View counts are guaranteed with one running instance of cryptgeon. Multiple instances connected to the same Redis instance can run into race conditions, where a note might be retrieved more than the view count allows.
## Screenshot ## Screenshot
![screenshot](./design/Screens.png) ![screenshot](./design/Screens.png)
## Environment Variables ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) | | `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. | | `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
| `MAX_VIEWS` | `100` | Maximal number of views. | | `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. | | `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. | | `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. | | `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` |
| `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_. | | `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `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_TEXT` | `""` | Custom text for replacing the description below the logo |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable | | `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo | | `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
## Deployment ## Deployment
> `https` is required otherwise browsers will not support the cryptographic functions. `https` is required otherwise browsers will not support the cryptographic functions.
> There is a health endpoint available at `/api/health/`. It returns either 200 or 503.
### Docker ### Docker
@@ -106,14 +79,9 @@ 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/latest/operate/rs/databases/memory-performance/eviction-policy/ # https://redis.io/docs/manual/eviction/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:latest
@@ -124,14 +92,6 @@ services:
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
ports: ports:
- 80:8000 - 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s
``` ```
### NGINX Proxy ### NGINX Proxy
@@ -152,28 +112,22 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
### YouTube Guides ### YouTube Guides
- English by [Webnestify](https://www.youtube.com/watch?v=XAyD42I7wyI)
- English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE) - English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
- German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg) - German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
### Written Guides
- French by [zarevskaya](https://belginux.com/installer-cryptgeon-avec-docker/)
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
## Development ## Development
**Requirements** **Requirements**
- `pnpm`: `>=9` - `pnpm`: `>=6`
- `node`: `>=22` - `node`: `>=16`
- `rust`: edition `2021` - `rust`: edition `2021`
**Install** **Install**
```bash ```bash
pnpm install pnpm install
pnpm --prefix frontend install
# Also you need cargo watch if you don't already have it installed. # Also you need cargo watch if you don't already have it installed.
# https://lib.rs/crates/cargo-watch # https://lib.rs/crates/cargo-watch
@@ -193,19 +147,19 @@ Running `pnpm run dev` in the root folder will start the following things:
- redis docker container - redis docker container
- rust backend - rust backend
- client - client
- cli
You can see the app under [localhost:3000](http://localhost:3000). You can see the app under [localhost:1234](http://localhost:1234).
> There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json) ## Tests
### Tests
Tests are end to end tests written with Playwright. Tests are end to end tests written with Playwright.
```sh ```sh
pnpm run test:prepare pnpm run test:prepare
docker compose up redis -d
pnpm run test:server
# In another terminal.
# Use the test or test:local script. The local version only runs in one browser for quicker development. # Use the test or test:local script. The local version only runs in one browser for quicker development.
pnpm run test:local pnpm run test:local
``` ```
@@ -214,9 +168,7 @@ pnpm run test:local
Please refer to the security section [here](./SECURITY.md). Please refer to the security section [here](./SECURITY.md).
--- ###### Attributions
_Attributions_
- Test data: - Test data:
- Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/) - Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)

View File

@@ -1,205 +0,0 @@
<p align="center">
<img src="./design/Github.png" alt="logo">
</p>
<a href="https://discord.gg/nuby6RnxZt">
<img alt="discord" src="https://img.shields.io/discord/252403122348097536?style=for-the-badge" />
<img alt="docker pulls" src="https://img.shields.io/docker/pulls/cupcakearmy/cryptgeon?style=for-the-badge" />
<img alt="Docker image size badge" src="https://img.shields.io/docker/image-size/cupcakearmy/cryptgeon?style=for-the-badge" />
<img alt="Latest version" src="https://img.shields.io/github/v/release/cupcakearmy/cryptgeon?style=for-the-badge" />
</a>
<br/><br/>
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
<a href=""><img src="./.github/lokalise.png" height="50">
<br/><br/>
[EN](README.md) | [简体中文](README_zh-CN.md) | ES
## Acerca de
_cryptgeon_ es un servicio seguro y de código abierto para compartir notas o archivos inspirado en [_PrivNote_](https://privnote.com).
Incluye un servidor, una página web y una interfaz de línea de comandos (CLI, por sus siglas en inglés).
> 🌍 Si quieres traducir este proyecto no dudes en ponerte en contacto conmigo.
>
> Gracias a [Lokalise](https://lokalise.com/) por darnos acceso gratis a su plataforma.
## Demo
### Web
Prueba la demo y experimenta por ti mismo [cryptgeon.org](https://cryptgeon.org)
### CLI
```
npx cryptgeon send text "Esto es una nota secreta"
```
Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/README.md).
## Características
- enviar texto o archivos
- el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente
- restriccion de vistas o de tiempo
- en memoria, sin persistencia
- compatibilidad obligatoria con el modo oscuro
## ¿Cómo funciona?
Se genera una <code>id (256bit)</code> y una <code>llave 256(bit)</code> para cada nota. La
<code>id</code>
se usa para guardar y recuperar la nota. Después la nota es encriptada con la <code>llave</code> y con aes en modo gcm del lado del cliente y por último se envía al servidor. La información es almacenada en memoria y nunca persiste en el disco. El servidor nunca ve la llave de encriptación por lo que no puede desencriptar el contenido de las notas aunque lo intentara.
## Capturas de pantalla
![screenshot](./design/Screens.png)
## Variables de entorno
| Variable | Default | Descripción |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Tamaño máximo. Valores aceptados según la [unidad byte](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> El frontend mostrará ese número, incluyendo el ~35% de sobrecarga de codificación. |
| `MAX_VIEWS` | `100` | Número máximo de vistas. |
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
## Despliegue
> Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptacón.
> Hay un endpoint para verificar el estado, lo encontramos en `/api/health/`. Regresa un código 200 o 503.
### Docker
Docker es la manera más fácil. Aquí encontramos [la imágen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
```yaml
# docker-compose.yml
version: '3.8'
services:
redis:
image: redis:7-alpine
# This is required to stay in RAM only.
command: redis-server --save "" --appendonly no
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app:
image: cupcakearmy/cryptgeon:latest
depends_on:
- redis
environment:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s
```
### NGINX Proxy
Ver la carpeta de [ejemplo/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/nginx). Hay un ejemplo con un proxy simple y otro con https. Es necesario que especifiques el nombre del servidor y los certificados.
### Traefik 2
Ver la carpeta de [ejemplo/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik).
### Scratch
Ver la carpeta de [ejemplo/scratch](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/scratch). Ahí encontrarás una guía de cómo configurar el servidor e instalar cryptgeon desde cero.
### Synology
Hay una [guía](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) (en inglés) que puedes seguir.
### Guías en Youtube
- En inglés, por [Webnestify](https://www.youtube.com/watch?v=XAyD42I7wyI)
- En inglés, por [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
- En alemán, por [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
## Desarrollo
**Requisitos**
- `pnpm`: `>=6`
- `node`: `>=18`
- `rust`: edition `2021`
**Instalación**
```bash
pnpm install
# También necesitas cargo-watch, si no lo tienes instalado.
# https://lib.rs/crates/cargo-watch
cargo install cargo-watch
```
**Ejecutar**
Asegurate de que docker se esté ejecutando.
```bash
pnpm run dev
```
Ejecutando `pnpm run dev` en la carpeta raíz iniciará lo siguiente:
- redis docker container
- rust backend
- client
- cli
Puedes ver la app en [localhost:3000](http://localhost:3000).
> Existe una colección de Postman con algunas peticiones de ejemplo [disponible en el repo](./Cryptgeon.postman_collection.json)
### Tests
Los tests son end-to-end tests escritos con Playwright.
```sh
pnpm run test:prepare
# Usa el script test o test:local. La versión local solo corre en el navegador para acelerar el desarrollo.
pnpm run test:local
```
## Seguridad
Por favor dirigite a la sección de seguridad [aquí](./SECURITY.md).
---
_Atribuciones_
- Datos del Test:
- Texto para los tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
- [AES Paper](https://www.cs.miami.edu/home/burt/learning/Csc688.012/rijndael/rijndael_doc_V2.pdf)
- [Unsplash Imágenes](https://unsplash.com/)
- Animación de carga por [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa)
- Iconos hechos por <a href="https://www.freepik.com" title="Freepik">freepik</a> de <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>

View File

@@ -14,7 +14,7 @@
<a href=""><img src="./.github/lokalise.png" height="50"> <a href=""><img src="./.github/lokalise.png" height="50">
<br/> <br/>
[EN](README.md) | 简体中文 | [ES](README_ES.md) [EN](README.md) | 简体中文
## 关于本项目 ## 关于本项目
@@ -74,14 +74,6 @@ 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
@@ -116,14 +108,7 @@ networks:
services: services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# This is required to stay in RAM only. restart: unless-stopped
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
@@ -173,7 +158,7 @@ pnpm run dev
- 无热重载的 rust 后端 - 无热重载的 rust 后端
- 可热重载的客户端 - 可热重载的客户端
你可以通过 3000 端口进入该应用,即 [localhost:3000](http://localhost:3000). 你可以通过 1234 端口进入该应用,即 [localhost:1234](http://localhost:1234).
## 测试 ## 测试

View File

@@ -1,20 +1,17 @@
{ {
"folders": [ "folders": [
{ {
"path": "." "path": "."
}, },
{ {
"path": "packages/backend" "path": "packages/backend"
}, },
{ {
"path": "packages/frontend" "path": "packages/frontend"
}, }
{ ],
"path": "packages/cli"
}
],
"settings": { "settings": {
"i18n-ally.localesPaths": ["locales"], "i18n-ally.localesPaths": ["packages/frontend/locales"],
"cSpell.words": ["cryptgeon"] "cSpell.words": ["cryptgeon"]
} }
} }

View File

@@ -1,32 +1,18 @@
# DEV Compose file. # DEV Compose file.
# For a production file see: README.md # For a production file see: README.md
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
ports: ports:
- 6379:6379 - 6379:6379
app: app:
build: . build: .
env_file: .env.dev env_file: .dev.env
depends_on: depends_on:
- redis - redis
restart: unless-stopped
ports: ports:
- 3000:8000 - 1234:8000
healthcheck:
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/']
interval: 1m
timeout: 3s
retries: 2
start_period: 5s

View File

@@ -1,14 +1,11 @@
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/latest/operate/rs/databases/memory-performance/eviction-policy/ # https://redis.io/docs/manual/eviction/
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine # command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
# This prevents the creation of an anonymous volume.
tmpfs:
- /data
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:latest
@@ -19,11 +16,3 @@ services:
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
ports: ports:
- 80:8000 - 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s

View File

@@ -3,20 +3,12 @@ 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
depends_on: depends_on:
- redis - redis
proxy: proxy:
image: nginx:alpine image: nginx:alpine
depends_on: depends_on:

View File

@@ -109,14 +109,7 @@ networks:
services: services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# This is required to stay in RAM only. restart: unless-stopped
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

View File

@@ -2,7 +2,7 @@
Assumptions: Assumptions:
- Traefik 2/3 installed. - Traefik 2 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`.
@@ -18,14 +18,7 @@ networks:
services: services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
# This is required to stay in RAM only. restart: unless-stopped
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
@@ -41,51 +34,3 @@ 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: 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
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`

View File

@@ -1,21 +1,17 @@
{ {
"scripts": { "scripts": {
"dev:docker": "docker compose -f docker-compose.dev.yaml up redis", "dev:docker": "docker-compose -f docker-compose.dev.yaml up redis",
"dev:packages": "pnpm --parallel run dev", "dev:packages": "pnpm --parallel run dev",
"dev": "run-p dev:*", "dev": "run-p dev:*",
"docker:up": "docker compose -f docker-compose.dev.yaml up", "test": "playwright test --project chrome firefox safari",
"docker:build": "docker compose -f docker-compose.dev.yaml build", "test:local": "playwright test --project local",
"test": "playwright test --project=chrome --project=firefox --project=safari", "test:server": "docker compose -f docker-compose.dev.yaml up",
"test:local": "playwright test --project=chrome", "test:prepare": "docker compose -f docker-compose.dev.yaml build",
"test:server": "run-s docker:up",
"test:prepare": "run-p 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.32.3",
"@types/node": "^22.5.0", "@types/node": "^18.16.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5"
"shelljs": "^0.8.5" }
},
"packageManager": "pnpm@10.3.0"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,28 @@
[package] [package]
name = "cryptgeon" name = "cryptgeon"
version = "2.9.1" version = "2.2.0"
authors = ["cupcakearmy <hi@nicco.io>"] authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021" edition = "2021"
rust-version = "1.85"
[[bin]] [[bin]]
name = "cryptgeon" name = "cryptgeon"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [registries.crates-io]
# Core protocol = "sparse"
axum = "0.7.5"
serde = { version = "1.0.208", features = ["derive"] }
tokio = { version = "1.39.3", features = ["full"] }
tower = "0.5.0"
tower-http = { version = "0.5.2", features = ["full"] }
redis = { version = "0.25.2", features = ["tls-native-tls"] }
# Utility # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4"
actix-files = "0.6"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1" serde_json = "1"
lazy_static = "1" lazy_static = "1"
ring = "0.17" ring = "0.16"
bs62 = "0.1" bs62 = "0.1"
byte-unit = "4" byte-unit = "4"
dotenv = "0.15" dotenv = "0.15"
mime = "0.3"
env_logger = "0.9"
redis = "0.21.5"

View File

@@ -4,7 +4,7 @@
"scripts": { "scripts": {
"dev": "cargo watch -x 'run --bin cryptgeon'", "dev": "cargo watch -x 'run --bin cryptgeon'",
"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:1234 cargo run",
"test:prepare": "cargo build" "test:prepare": "cargo build"
} }
} }

View File

@@ -0,0 +1,12 @@
use actix_web::web;
use crate::note;
use crate::status;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api")
.service(note::init())
.service(status::init()),
);
}

View File

@@ -0,0 +1,17 @@
use actix_files::{Files, NamedFile};
use actix_web::{web, Result};
use crate::config;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
Files::new("/", config::FRONTEND_PATH.to_string())
.index_file("index.html")
.use_etag(true),
);
}
pub async fn index() -> Result<NamedFile> {
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
Ok(NamedFile::open(index)?)
}

View File

@@ -14,38 +14,22 @@ lazy_static! {
// CONFIG // CONFIG
lazy_static! { lazy_static! {
pub static ref LIMIT: usize = pub static ref LIMIT: usize =
Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string())) Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string()))
.unwrap() .unwrap()
.get_bytes() as usize; .get_bytes() as usize;
pub static ref MAX_VIEWS: u32 = std::env::var("MAX_VIEWS") pub static ref MAX_VIEWS: u32 = std::env::var("MAX_VIEWS")
.unwrap_or("100".to_string()) .unwrap_or("100".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref MAX_EXPIRATION: u32 = std::env::var("MAX_EXPIRATION") pub static ref MAX_EXPIRATION: u32 = std::env::var("MAX_EXPIRATION")
.unwrap_or("360".to_string()) // 6 hours in minutes .unwrap_or("360".to_string()) // 6 hours in minutes
.parse() .parse()
.unwrap(); .unwrap();
pub static ref ALLOW_ADVANCED: bool = std::env::var("ALLOW_ADVANCED") pub static ref ALLOW_ADVANCED: bool = std::env::var("ALLOW_ADVANCED")
.unwrap_or("true".to_string()) .unwrap_or("true".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string())
.parse()
.unwrap();
pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
.unwrap_or("true".to_string())
.parse()
.unwrap();
pub static ref IMPRINT_URL: String = std::env::var("IMPRINT_URL")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
.unwrap_or("".to_string())
.parse()
.unwrap();
} }
// THEME // THEME
@@ -66,8 +50,4 @@ lazy_static! {
.unwrap_or("".to_string()) .unwrap_or("".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref THEME_NEW_NOTE_NOTICE: bool = std::env::var("THEME_NEW_NOTE_NOTICE")
.unwrap_or("true".to_string())
.parse()
.unwrap();
} }

View File

@@ -1,16 +0,0 @@
use axum::{body::Body, extract::Request, http::HeaderValue, middleware::Next, response::Response};
const CUSTOM_HEADER_NAME: &str = "Content-Security-Policy";
const CUSTOM_HEADER_VALUE: &str = "default-src 'self'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; object-src 'none'; base-uri 'self'; connect-src 'self' data:; font-src 'self'; frame-src 'self'; img-src 'self'; manifest-src 'self'; media-src 'self'; worker-src 'none';";
lazy_static! {
static ref HEADER_VALUE: HeaderValue = HeaderValue::from_static(CUSTOM_HEADER_VALUE);
}
pub async fn add_csp_header(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.append(CUSTOM_HEADER_NAME, HEADER_VALUE.clone());
response
}

View File

@@ -1,10 +0,0 @@
use crate::store;
use axum::http::StatusCode;
pub async fn report_health() -> (StatusCode,) {
if store::can_reach_redis() {
return (StatusCode::OK,);
} else {
return (StatusCode::SERVICE_UNAVAILABLE,);
}
}

View File

@@ -1,10 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct SharedState {
pub locks: LockMap,
}
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;

View File

@@ -1,80 +1,36 @@
use std::{collections::HashMap, sync::Arc}; use actix_web::{
middleware::{self, Logger},
use axum::{ web, App, HttpServer,
extract::{DefaultBodyLimit, Request},
routing::{delete, get, post},
Router, ServiceExt,
}; };
use dotenv::dotenv; use dotenv::dotenv;
use lock::SharedState;
use tokio::sync::Mutex;
use tower::Layer;
use tower_http::{
compression::CompressionLayer,
normalize_path::NormalizePathLayer,
services::{ServeDir, ServeFile},
};
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
mod api;
mod client;
mod config; mod config;
mod csp;
mod health;
mod lock;
mod note; mod note;
mod size;
mod status; mod status;
mod store; mod store;
#[tokio::main] #[actix_web::main]
async fn main() { async fn main() -> std::io::Result<()> {
dotenv().ok(); dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
let shared_state = SharedState { return HttpServer::new(|| {
locks: Arc::new(Mutex::new(HashMap::new())), App::new()
}; .wrap(Logger::new("\"%r\" %s %b %T"))
.wrap(middleware::Compress::default())
if !store::can_reach_redis() { .wrap(middleware::DefaultHeaders::default())
println!("cannot reach redis"); .configure(size::init)
panic!("cannot reach redis"); .configure(api::init)
} .configure(client::init)
.default_service(web::to(client::index))
let notes_routes = Router::new() })
.route("/", post(note::create)) .bind(config::LISTEN_ADDR.to_string())?
.route("/:id", delete(note::delete)) .run()
.route("/:id", get(note::preview)); .await;
let health_routes = Router::new().route("/live", get(health::report_health));
let status_routes = Router::new().route("/status", get(status::get_status));
let api_routes = Router::new()
.nest("/notes", notes_routes)
.nest("/", health_routes)
.nest("/", status_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
let serve_dir =
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
let app = Router::new()
.nest("/api", api_routes)
.fallback_service(serve_dir)
// Disabled for now, as svelte inlines scripts
// .layer(middleware::from_fn(csp::add_csp_header))
.layer(DefaultBodyLimit::max(*config::LIMIT))
.layer(
CompressionLayer::new()
.br(true)
.deflate(true)
.gzip(true)
.zstd(true),
)
.with_state(shared_state);
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
let listener = tokio::net::TcpListener::bind(config::LISTEN_ADDR.to_string())
.await
.unwrap();
println!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, ServiceExt::<Request>::into_make_service(app))
.await
.unwrap();
} }

View File

@@ -2,8 +2,6 @@ use bs62;
use ring::rand::SecureRandom; use ring::rand::SecureRandom;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct Note { pub struct Note {
pub meta: String, pub meta: String,
@@ -12,25 +10,18 @@ pub struct Note {
pub expiration: Option<u32>, pub expiration: Option<u32>,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize, Clone)]
pub struct NoteInfo { pub struct NoteInfo {}
pub meta: String,
}
#[derive(Serialize)] #[derive(Serialize, Deserialize, Clone)]
pub struct NotePublic { pub struct NotePublic {
pub meta: String, pub meta: String,
pub contents: String, pub contents: String,
} }
pub fn generate_id() -> String { pub fn generate_id() -> String {
let mut result = "".to_owned(); let mut id: [u8; 32] = [0; 32];
let mut id: [u8; 1] = [0; 1];
let sr = ring::rand::SystemRandom::new(); let sr = ring::rand::SystemRandom::new();
let _ = sr.fill(&mut id);
for _ in 0..*config::ID_LENGTH { return bs62::encode_data(&id);
let _ = sr.fill(&mut id);
result.push_str(&bs62::encode_data(&id));
}
return result;
} }

View File

@@ -1,18 +1,10 @@
use axum::{ use actix_web::{delete, get, post, web, HttpResponse, Responder, Scope};
extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::SystemTime}; use std::time::SystemTime;
use tokio::sync::Mutex;
use crate::note::{generate_id, Note, NoteInfo}; use crate::config;
use crate::note::{generate_id, Note, NoteInfo, NotePublic};
use crate::store; use crate::store;
use crate::{config, lock::SharedState};
use super::NotePublic;
pub fn now() -> u32 { pub fn now() -> u32 {
SystemTime::now() SystemTime::now()
@@ -21,18 +13,20 @@ pub fn now() -> u32 {
.as_secs() as u32 .as_secs() as u32
} }
#[derive(Deserialize)] #[derive(Serialize, Deserialize)]
pub struct OneNoteParams { struct NotePath {
id: String, id: String,
} }
pub async fn preview(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response { #[get("/{id}")]
let note = store::get(&id); async fn one(path: web::Path<NotePath>) -> impl Responder {
let p = path.into_inner();
let note = store::get(&p.id);
match note { match note {
Ok(Some(n)) => (StatusCode::OK, Json(NoteInfo { meta: n.meta })).into_response(), Ok(Some(_)) => HttpResponse::Ok().json(NoteInfo {}),
Ok(None) => (StatusCode::NOT_FOUND).into_response(), Ok(None) => HttpResponse::NotFound().finish(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
} }
} }
@@ -41,16 +35,13 @@ struct CreateResponse {
id: String, id: String,
} }
pub async fn create(Json(mut n): Json<Note>) -> Response { #[post("/")]
// let mut n = note.into_inner(); async fn create(note: web::Json<Note>) -> impl Responder {
let mut n = note.into_inner();
let id = generate_id(); let id = generate_id();
// let bad_req = HttpResponse::BadRequest().finish(); let bad_req = HttpResponse::BadRequest().finish();
if n.views == None && n.expiration == None { if n.views == None && n.expiration == None {
return ( return bad_req;
StatusCode::BAD_REQUEST,
"At least views or expiration must be set",
)
.into_response();
} }
if !*config::ALLOW_ADVANCED { if !*config::ALLOW_ADVANCED {
n.views = Some(1); n.views = Some(1);
@@ -59,7 +50,7 @@ pub async fn create(Json(mut n): Json<Note>) -> Response {
match n.views { match n.views {
Some(v) => { Some(v) => {
if v > *config::MAX_VIEWS || v < 1 { if v > *config::MAX_VIEWS || v < 1 {
return (StatusCode::BAD_REQUEST, "Invalid views").into_response(); return bad_req;
} }
n.expiration = None; // views overrides expiration n.expiration = None; // views overrides expiration
} }
@@ -67,8 +58,8 @@ pub async fn create(Json(mut n): Json<Note>) -> Response {
} }
match n.expiration { match n.expiration {
Some(e) => { Some(e) => {
if e > *config::MAX_EXPIRATION || e < 1 { if e > *config::MAX_EXPIRATION {
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response(); return bad_req;
} }
let expiration = now() + (e * 60); let expiration = now() + (e * 60);
n.expiration = Some(expiration); n.expiration = Some(expiration);
@@ -76,49 +67,38 @@ pub async fn create(Json(mut n): Json<Note>) -> Response {
_ => {} _ => {}
} }
match store::set(&id.clone(), &n.clone()) { match store::set(&id.clone(), &n.clone()) {
Ok(_) => (StatusCode::OK, Json(CreateResponse { id })).into_response(), Ok(_) => return HttpResponse::Ok().json(CreateResponse { id: id }),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
} }
} }
pub async fn delete( #[delete("/{id}")]
Path(OneNoteParams { id }): Path<OneNoteParams>, async fn delete(path: web::Path<NotePath>) -> impl Responder {
state: axum::extract::State<SharedState>, let p = path.into_inner();
) -> Response { let note = store::get(&p.id);
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);
match note { match note {
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
Ok(None) => (StatusCode::NOT_FOUND).into_response(), Ok(None) => return HttpResponse::NotFound().finish(),
Ok(Some(note)) => { Ok(Some(note)) => {
let mut changed = note.clone(); let mut changed = note.clone();
if changed.views == None && changed.expiration == None { if changed.views == None && changed.expiration == None {
return (StatusCode::BAD_REQUEST).into_response(); return HttpResponse::BadRequest().finish();
} }
match changed.views { match changed.views {
Some(v) => { Some(v) => {
changed.views = Some(v - 1); changed.views = Some(v - 1);
let id = id.clone(); let id = p.id.clone();
if v <= 1 { if v <= 1 {
match store::del(&id) { match store::del(&id) {
Err(e) => { Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) return HttpResponse::InternalServerError().body(e.to_string())
.into_response();
} }
_ => {} _ => {}
} }
} else { } else {
match store::set(&id, &changed.clone()) { match store::set(&id, &changed.clone()) {
Err(e) => { Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) return HttpResponse::InternalServerError().body(e.to_string())
.into_response();
} }
_ => {} _ => {}
} }
@@ -131,26 +111,33 @@ pub async fn delete(
match changed.expiration { match changed.expiration {
Some(e) => { Some(e) => {
if e < n { if e < n {
match store::del(&id.clone()) { match store::del(&p.id.clone()) {
Ok(_) => return (StatusCode::BAD_REQUEST).into_response(), Ok(_) => return HttpResponse::BadRequest().finish(),
Err(e) => { Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) return HttpResponse::InternalServerError().body(e.to_string())
.into_response()
} }
} }
} }
} }
_ => {} _ => {}
} }
return HttpResponse::Ok().json(NotePublic {
return ( contents: changed.contents,
StatusCode::OK, meta: changed.meta,
Json(NotePublic { });
contents: changed.contents,
meta: changed.meta,
}),
)
.into_response();
} }
} }
} }
#[derive(Serialize, Deserialize)]
struct Status {
version: String,
max_size: usize,
}
pub fn init() -> Scope {
web::scope("/notes")
.service(one)
.service(create)
.service(delete)
}

View File

@@ -0,0 +1,12 @@
use crate::config;
use actix_web::web;
use mime;
pub fn init(cfg: &mut web::ServiceConfig) {
let json = web::JsonConfig::default().limit(*config::LIMIT);
let plain = web::PayloadConfig::default()
.limit(*config::LIMIT)
.mimetype(mime::STAR_STAR);
// cfg.app_data(plain);
cfg.app_data(json).app_data(plain);
}

View File

@@ -1,43 +1,5 @@
use crate::config; mod model;
use axum::{http::StatusCode, Json}; mod routes;
use serde::Serialize;
#[derive(Serialize)] pub use model::*;
pub struct Status { pub use routes::*;
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
pub allow_files: bool,
pub imprint_url: String,
pub imprint_html: String,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
pub theme_new_note_notice: bool,
}
pub async fn get_status() -> (StatusCode, Json<Status>) {
let status = Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
allow_files: *config::ALLOW_FILES,
imprint_url: config::IMPRINT_URL.to_string(),
imprint_html: config::IMPRINT_HTML.to_string(),
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string(),
};
(StatusCode::OK, Json(status))
}

View File

@@ -0,0 +1,17 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Status {
// General
pub version: String,
// Config
pub max_size: u32,
pub max_views: u32,
pub max_expiration: u32,
pub allow_advanced: bool,
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
}

View File

@@ -0,0 +1,23 @@
use actix_web::{get, web, HttpResponse, Responder, Scope};
use crate::config;
use crate::status::Status;
#[get("/")]
async fn get_status() -> impl Responder {
return HttpResponse::Ok().json(Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string()
});
}
pub fn init() -> Scope {
web::scope("/status").service(get_status)
}

View File

@@ -19,14 +19,6 @@ fn get_connection() -> Result<redis::Connection, &'static str> {
.map_err(|_| "Unable to connect to redis") .map_err(|_| "Unable to connect to redis")
} }
pub fn can_reach_redis() -> bool {
let conn = get_connection();
return match conn {
Ok(_) => true,
Err(_) => false,
};
}
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> { pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
let serialized = serde_json::to_string(&note.clone()).unwrap(); let serialized = serde_json::to_string(&note.clone()).unwrap();
let mut conn = get_connection()?; let mut conn = get_connection()?;
@@ -36,7 +28,7 @@ pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
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 usize)
.map_err(|_| "Unable to set expiration on notion")? .map_err(|_| "Unable to set expiration on notion")?
} }
None => {} None => {}

View File

@@ -1,54 +0,0 @@
# Cryptgeon CLI
The CLI is a functionally identical way to interact with cryptgeon notes.
It supports text, files, expiration, password, etc.
## Installation
```bash
npx cryptgeon
# Or install globally
npm -g install cryptgeon
cryptgeon
```
## Examples
```bash
# Create simple note
cryptgeon send text "Foo bar"
# Send two files
cryptgeon send file my.pdf picture.png
# 3 views
cryptgeon send text "My message" --views 3
# 10 minutes
cryptgeon send text "My message" --minutes 10
# Custom password
cryptgeon send text "My message" --password "1337"
# Password from stdin
echo "1337" | cryptgeon send text "My message"
# Open a link
cryptgeon open https://cryptgeon.org/note/16gOIkxWjCxYNuXM8tCqMUzl...
```
## Options
### Custom server
The default server is `cryptgeon.org`, however you can use any cryptgeon server by passing the `-s` or `--server` option, or by setting the `CRYPTGEON_SERVER` environment variable.
### Password
Optionally, just like in the web ui, you can choose to use a manual password. You can do that by passing the `-p` or `--password` options, or by piping it into stdin.
```bash
echo "my pw" | cryptgeon send text "my text"
cat pass.txt | cryptgeon send text "my text"
```

View File

@@ -1,15 +0,0 @@
import { build } from 'tsup'
import pkg from './package.json' with { type: 'json' }
const watch = process.argv.slice(2)[0] === '--watch'
await build({
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
dts: true,
minify: true,
format: ['esm', 'cjs'],
target: 'es2020',
clean: true,
define: { VERSION: `"${pkg.version}"` },
watch,
})

View File

@@ -1,47 +1,45 @@
{ {
"name": "cryptgeon", "name": "@cryptgeon/cli",
"version": "2.9.1",
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": {
"type": "git",
"url": "https://github.com/cupcakearmy/cryptgeon.git",
"directory": "packages/cli"
},
"type": "module", "type": "module",
"exports": { "scripts": {
".": "./dist/index.js", "dev": "esbuild ./src/index.ts --bundle --platform=node --outfile=dist/index.cjs --watch",
"./shared": { "build": "esbuild ./src/index.ts --bundle --platform=node --outfile=dist/index.cjs",
"import": "./dist/shared/shared.js", "package": "pkg .",
"types": "./dist/shared/shared.d.ts" "bin": "run-s build package"
}
}, },
"types": "./dist/index.d.ts",
"bin": { "bin": {
"cryptgeon": "./dist/cli.cjs" "cryptgeon": "./dist/index.cjs"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"scripts": { "pkg": {
"bin": "run-s build package", "scripts": [],
"build": "tsc && node build.js", "targets": [
"dev": "node build.js --watch", "node18-macos-arm64",
"prepublishOnly": "run-s build" "node18-macos-x64",
"node18-linux-arm64",
"node18-linux-x64",
"node18-win-arm64",
"node18-win-x64"
],
"outputPath": "bin"
}, },
"devDependencies": { "devDependencies": {
"@commander-js/extra-typings": "^12.1.0", "@types/inquirer": "^9.0.3",
"@types/inquirer": "^9.0.7", "@types/mime": "^3.0.1",
"@types/mime": "^4.0.0", "@types/node": "^18.16.1",
"@types/node": "^20.11.24", "esbuild": "^0.17.18",
"commander": "^12.1.0", "pkg": "^5.8.1",
"inquirer": "^9.2.15", "typescript": "^4.9.5"
"mime": "^4.0.1",
"occulto": "^2.0.6",
"pretty-bytes": "^6.1.1",
"tsup": "^8.2.4",
"typescript": "^5.3.3"
}, },
"engines": { "dependencies": {
"node": ">=18" "@commander-js/extra-typings": "^9.5.0",
"@cryptgeon/shared": "workspace:*",
"commander": "^9.5.0",
"inquirer": "^9.2.0",
"mime": "^3.0.0",
"occulto": "^2.0.1",
"pretty-bytes": "^6.1.0"
} }
} }

View File

@@ -1,90 +0,0 @@
import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { AES, Hex } from 'occulto'
import pretty from 'pretty-bytes'
import { Adapters } from '../shared/adapters.js'
import { API } from '../shared/api.js'
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
API.setOptions({ server: url.origin })
const id = url.pathname.split('/')[2]
const preview = await API.info(id).catch(() => {
throw new Error('Note does not exist or is expired')
})
// Password
let password: string
const derivation = preview?.meta.derivation
if (derivation) {
if (suggestedPassword) {
password = suggestedPassword
} else {
const response = await inquirer.prompt([
{
type: 'password',
message: 'Note password',
name: 'password',
},
])
password = response.password
}
} else {
password = url.hash.slice(1)
}
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
const note = await API.get(id)
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
switch (note.meta.type) {
case 'file':
const files = await Adapters.Files.decrypt(note.contents, key).catch(() => {
throw couldNotDecrypt
})
if (!files) {
throw new Error('No files found in note')
}
let selected: typeof files
if (all) {
selected = files
} else {
const { names } = await inquirer.prompt([
{
type: 'checkbox',
message: 'What files should be saved?',
name: 'names',
choices: files.map((file) => ({
value: file.name,
name: `${file.name} - ${file.type} - ${pretty(file.size, { binary: true })}`,
checked: true,
})),
},
])
selected = files.filter((file) => names.includes(file.name))
}
if (!selected.length) throw new Error('No files selected')
await Promise.all(
selected.map(async (file) => {
let filename = resolve(file.name)
try {
// If exists -> prepend timestamp to not overwrite the current file
await access(filename, constants.R_OK)
filename = resolve(`${Date.now()}-${file.name}`)
} catch {}
await writeFile(filename, file.contents)
console.log(`Saved: ${basename(filename)}`)
})
)
break
case 'text':
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(() => {
throw couldNotDecrypt
})
console.log(plaintext)
break
}
}

View File

@@ -1,46 +0,0 @@
import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import mime from 'mime'
import { AES, Hex } from 'occulto'
import { Adapters } from '../shared/adapters.js'
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
const { password, ...noteOptions } = options
const derived = options.password ? await AES.derive(options.password) : undefined
const key = derived ? derived[0] : await AES.generateKey()
let contents: string
let type: NoteMeta['type']
if (typeof input === 'string') {
contents = await Adapters.Text.encrypt(input, key)
type = 'text'
} else {
const files: FileDTO[] = await Promise.all(
input.map(async (path) => {
const data = new Uint8Array(await readFile(path))
const stats = await stat(path)
const extension = path.substring(path.indexOf('.') + 1)
const type = mime.getType(extension) ?? 'application/octet-stream'
return {
name: basename(path),
size: stats.size,
contents: data,
type,
} satisfies FileDTO
})
)
contents = await Adapters.Files.encrypt(files, key)
type = 'file'
}
// Create the actual note and upload it.
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
const result = await API.create(note)
let url = `${API.getOptions().server}/note/${result.id}`
if (!derived) url += `#${Hex.encode(key)}`
return url
}

View File

@@ -1,106 +0,0 @@
#!/usr/bin/env node
import { Argument, Option, program } from '@commander-js/extra-typings'
import prettyBytes from 'pretty-bytes'
import { download } from './actions/download.js'
import { upload } from './actions/upload.js'
import { API } from './shared/api.js'
import { parseFile, parseNumber } from './utils/parsers.js'
import { getStdin } from './utils/stdin.js'
import { checkConstrains, exit } from './utils/utils.js'
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile)
const text = new Argument('<text>', 'Text content of the note')
const password = new Option('-p --password <string>', 'manually set a password')
const all = new Option('-a --all', 'Save all files without prompt').default(false)
const url = new Argument('<url>', 'The url to open')
const views = new Option('-v --views <number>', 'Amount of views before getting destroyed').argParser(parseNumber)
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
// Node 18 guard
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required')
// @ts-ignore
const version: string = VERSION
program.name('cryptgeon').version(version).configureHelp({ showGlobalOptions: true })
program
.command('info')
.description('show information about the server')
.addOption(server)
.action(async (options) => {
API.setOptions({ server: options.server })
const response = await API.status()
const formatted = {
...response,
max_size: prettyBytes(response.max_size),
}
for (const key of Object.keys(formatted)) {
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
}
console.table(formatted)
})
const send = program.command('send').description('send a note')
send
.command('file')
.addArgument(files)
.addOption(server)
.addOption(views)
.addOption(minutes)
.addOption(password)
.action(async (files, options) => {
API.setOptions({ server: options.server })
await checkConstrains(options)
options.password ||= await getStdin()
try {
const url = await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
console.log(`Note created:\n\n${url}`)
} catch {
exit('Could not create note')
}
})
send
.command('text')
.addArgument(text)
.addOption(server)
.addOption(views)
.addOption(minutes)
.addOption(password)
.action(async (text, options) => {
API.setOptions({ server: options.server })
await checkConstrains(options)
options.password ||= await getStdin()
try {
const url = await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
console.log(`Note created:\n\n${url}`)
} catch {
exit('Could not create note')
}
})
program
.command('open')
.description('open a link with text or files inside')
.addArgument(url)
.addOption(password)
.addOption(all)
.action(async (note, options) => {
try {
const url = new URL(note)
options.password ||= await getStdin()
try {
await download(url, options.all, options.password)
} catch (e) {
exit(e instanceof Error ? e.message : 'Unknown error occurred')
}
} catch {
exit('Invalid URL')
}
})
program.parse()

View File

@@ -0,0 +1,62 @@
import { Adapters, get, info, setBase } from '@cryptgeon/shared'
import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { Hex } from 'occulto'
import pretty from 'pretty-bytes'
import { exit } from './utils'
export async function download(url: URL) {
setBase(url.origin)
const id = url.pathname.split('/')[2]
await info(id).catch(() => exit('Note does not exist or is expired'))
const note = await get(id)
const password = url.hash.slice(1)
const key = Hex.decode(password)
const couldNotDecrypt = () => exit('Could not decrypt note. Probably an invalid password')
switch (note.meta.type) {
case 'file':
const files = await Adapters.Files.decrypt(note.contents, key).catch(couldNotDecrypt)
if (!files) {
exit('No files found in note')
return
}
const { names } = await inquirer.prompt([
{
type: 'checkbox',
message: 'What files should be saved?',
name: 'names',
choices: files.map((file) => ({
value: file.name,
name: `${file.name} - ${file.type} - ${pretty(file.size, { binary: true })}`,
checked: true,
})),
},
])
const selected = files.filter((file) => names.includes(file.name))
if (!selected.length) exit('No files selected')
await Promise.all(
files.map(async (file) => {
let filename = resolve(file.name)
try {
// If exists -> prepend timestamp to not overwrite the current file
await access(filename, constants.R_OK)
filename = resolve(`${Date.now()}-${file.name}`)
} catch {}
await writeFile(filename, file.contents)
console.log(`Saved: ${basename(filename)}`)
})
)
break
case 'text':
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(couldNotDecrypt)
console.log(plaintext)
break
}
}

View File

@@ -1,4 +1,87 @@
export * from './actions/download.js' #!/usr/bin/env node
export * from './actions/upload.js'
export * from './shared/adapters.js' import { Argument, Option, program } from '@commander-js/extra-typings'
export * from './shared/api.js' import { setBase, status } from '@cryptgeon/shared'
import prettyBytes from 'pretty-bytes'
import { download } from './download.js'
import { parseFile, parseNumber } from './parsers.js'
import { uploadFiles, uploadText } from './upload.js'
import { exit } from './utils.js'
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile)
const text = new Argument('<text>', 'Text content of the note')
const password = new Option('-p --password <string>', 'manually set a password')
const url = new Argument('<url>', 'The url to open')
const views = new Option('-v --views <number>', 'Amount of views before getting destroyed').argParser(parseNumber)
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
async function checkConstrains(constrains: { views?: number; minutes?: number }) {
const { views, minutes } = constrains
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
if (!views && !minutes) constrains.views = 1
const response = await status()
if (views && views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
if (minutes && minutes > response.max_expiration)
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
}
program.name('cryptgeon').version('1.0.0').configureHelp({ showGlobalOptions: true })
program
.command('info')
.addOption(server)
.action(async (options) => {
setBase(options.server)
const response = await status()
const formatted = {
...response,
max_size: prettyBytes(response.max_size),
}
for (const key of Object.keys(formatted)) {
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
}
console.table(formatted)
})
const send = program.command('send')
send
.command('file')
.addArgument(files)
.addOption(server)
.addOption(views)
.addOption(minutes)
.action(async (files, options) => {
setBase(options.server!)
await checkConstrains(options)
await uploadFiles(files, { views: options.views, expiration: options.minutes })
})
send
.command('text')
.addArgument(text)
.addOption(server)
.addOption(views)
.addOption(minutes)
.action(async (text, options) => {
setBase(options.server!)
await checkConstrains(options)
await uploadText(text, { views: options.views, expiration: options.minutes })
})
program
.command('open')
.addArgument(url)
.action(async (note, options) => {
try {
const url = new URL(note)
await download(url)
} catch {
exit('Invalid URL')
}
})
program.parse()

View File

@@ -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 = Number.parseInt(value, 10) const n = parseInt(value, 10)
if (Number.isNaN(n)) throw new InvalidOptionArgumentError('invalid number') if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
return n return n
} }

View File

@@ -0,0 +1,49 @@
import { Blob } from 'node:buffer'
import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Adapters, BASE, create, FileDTO, Note } from '@cryptgeon/shared'
import * as mime from 'mime'
import { AES, Hex, TypedArray } from 'occulto'
import { exit } from './utils.js'
type UploadOptions = Pick<Note, 'views' | 'expiration'>
export async function upload(key: TypedArray, note: Note) {
try {
const result = await create(note)
const password = Hex.encode(key)
const url = `${BASE}/note/${result.id}#${password}`
console.log(`Note created under:\n\n${url}`)
} catch {
exit('Could not create note')
}
}
export async function uploadFiles(paths: string[], options: UploadOptions) {
const key = await AES.generateKey()
const files: FileDTO[] = await Promise.all(
paths.map(async (path) => {
const data = new Uint8Array(await readFile(path))
const stats = await stat(path)
const extension = path.substring(path.indexOf('.') + 1)
const type = mime.getType(extension) ?? 'application/octet-stream'
return {
name: basename(path),
size: stats.size,
contents: data,
type,
} satisfies FileDTO
})
)
const contents = await Adapters.Files.encrypt(files, key)
await upload(key, { ...options, contents, meta: { type: 'file' } })
}
export async function uploadText(text: string, options: UploadOptions) {
const key = await AES.generateKey()
const contents = await Adapters.Text.encrypt(text, key)
await upload(key, { ...options, contents, meta: { type: 'text' } })
}

View File

@@ -0,0 +1,6 @@
import { exit as exitNode } from 'node:process'
export function exit(message: string) {
console.error(message)
exitNode(1)
}

View File

@@ -1,25 +0,0 @@
export function getStdin(timeout: number = 10): Promise<string> {
return new Promise<string>((resolve, reject) => {
// Store the data from stdin in a buffer
let buffer = ''
let t: NodeJS.Timeout
const dataHandler = (d: Buffer) => (buffer += d.toString())
const endHandler = () => {
clearTimeout(t)
resolve(buffer.trim())
}
// Stop listening for data after the timeout, otherwise hangs indefinitely
t = setTimeout(() => {
process.stdin.removeListener('data', dataHandler)
process.stdin.removeListener('end', endHandler)
process.stdin.pause()
resolve('')
}, timeout)
process.stdin.on('error', reject)
process.stdin.on('data', dataHandler)
process.stdin.on('end', endHandler)
})
}

View File

@@ -1,19 +0,0 @@
import { exit as exitNode } from 'node:process'
import { API } from '../shared/api.js'
export function exit(message: string) {
console.error(message)
exitNode(1)
}
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
const { views, minutes } = constrains
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
if (!views && !minutes) constrains.views = 1
const response = await API.status()
if (views && views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
if (minutes && minutes > response.max_expiration)
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
}

View File

@@ -2,12 +2,8 @@
"compilerOptions": { "compilerOptions": {
"target": "es2022", "target": "es2022",
"module": "es2022", "module": "es2022",
"moduleResolution": "Bundler", "moduleResolution": "node",
"declaration": true, "noEmit": true,
"emitDeclarationOnly": true, "strict": true
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"allowSyntheticDefaultImports": true
} }
} }

View File

@@ -1,58 +1,51 @@
{ {
"common": { "common": {
"note": "Notiz", "note": "Hinweis",
"file": "Datei", "file": "Datei",
"advanced": "Erweiterte Optionen", "advanced": "erweitert",
"create": "Erstellen", "create": "erstellen",
"loading": "Lädt...", "loading": "läd",
"mode": "Modus", "mode": "Modus",
"views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}", "views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}",
"minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}", "minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}",
"max": "max", "max": "max",
"share_link": "Link teilen", "share_link": "Link teilen",
"copy_clipboard": "In die Zwischenablage kopieren", "copy_clipboard": "in die Zwischenablage kopieren",
"copied_to_clipboard": "In die Zwischenablage kopiert.", "copied_to_clipboard": "in die Zwischenablage kopiert",
"encrypting": "Wird verschlüsselt...", "encrypting": "verschlüsseln",
"decrypting": "Wird entschlüsselt...", "decrypting": "entschlüsselt",
"uploading": "Hochladen", "uploading": "hochladen",
"downloading": "Wird heruntergeladen", "downloading": "wird heruntergeladen",
"qr_code": "QR-Code", "qr_code": "qr-code"
"password": "Passwort"
}, },
"home": { "home": {
"intro": "Erstellen Sie mit einem Klick <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien und teilen Sie diese über einen Link.", "intro": "Senden Sie ganz einfach <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien mit einem Klick. Erstellen Sie einfach eine Notiz und teilen Sie den Link.",
"explanation": "Die Notiz verfällt nach {type}.", "explanation": "die Notiz verfällt und wird nach {type} zerstört.",
"new_note": "Neue Notiz", "new_note": "neue Note",
"new_note_notice": "<b>Wichtiger Hinweis zur Verfügbarkeit:</b><br />Es kann nicht garantiert werden, dass diese Notiz gespeichert wird, da diese <b>ausschließlich im Speicher</b> gehalten werden. Ist dieser voll, werden die ältesten Notizen entfernt.<br />(Wahrscheinlich gibt es keine derartigen Probleme, seien Sie nur vorgewarnt).", "new_note_notice": "<b>Verfügbarkeit:</b><br />es ist nicht garantiert, dass die Notiz gespeichert wird, da alles im Speicher gehalten wird. Wenn dieser voll ist, werden die ältesten Notizen entfernt.<br />(Sie werden wahrscheinlich keine Probleme haben, seien Sie nur gewarnt).",
"errors": { "errors": {
"note_to_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.", "note_to_big": "Notiz konnte nicht erstellt werden. Notiz ist zu groß",
"note_error": "Notiz konnte nicht erstellt werden. Bitte versuchen Sie es erneut.", "note_error": "konnte keine Notiz erstellen. Bitte versuchen Sie es erneut.",
"max": "max: {n}", "max": "max: {n}",
"empty_content": "Notiz ist leer." "empty_content": "Notiz ist leer."
}, },
"messages": { "messages": {
"note_created": "Notiz wurde erstellt." "note_created": "notiz erstellt."
},
"advanced": {
"explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.",
"custom_password": "Benutzerdefiniertes Passwort"
} }
}, },
"show": { "show": {
"errors": { "errors": {
"not_found": "Notiz konnte nicht gefunden werden oder wurde bereits gelöscht.", "not_found": "wurde nicht gefunden oder wurde bereits gelöscht.",
"decryption_failed": "Notiz konnte nicht entschlüsselt werden. Vermutlich ist das Passwort falsch oder der Link defekt. Die Notiz wurde daher gelöscht.", "decryption_failed": "falsches Passwort. konnte nicht entziffert werden. wahrscheinlich ein defekter Link. Notiz wurde zerstört.",
"unsupported_type": "Nicht unterstützter Notiztyp." "unsupported_type": "nicht unterstützter Notiztyp."
}, },
"explanation": "Klicken Sie auf den Button, um die Notiz anzuzeigen und anschließend zu löschen, falls ein festgelegtes Limit erreicht wurde.", "explanation": "Klicken Sie unten, um die Notiz anzuzeigen und zu löschen, wenn der Zähler sein Limit erreicht hat",
"show_note": "Notiz anzeigen", "show_note": "Notiz anzeigen",
"warning_will_not_see_again": "ACHTUNG! Sie werden anschließend <b>keine</b> Gelegenheit mehr haben, die Notiz erneut anzusehen.", "warning_will_not_see_again": "haben Sie <b>keine</b> Gelegenheit, die Notiz noch einmal zu sehen.",
"download_all": "Alle Dateien herunterladen", "download_all": "alle herunterladen"
"links_found": "Gefundene Links in der Notiz:"
}, },
"file_upload": { "file_upload": {
"selected_files": "Ausgewählte Dateien", "selected_files": "Ausgewählte Dateien",
"no_files_selected": "Keine Dateien ausgewählt", "no_files_selected": "Keine Dateien ausgewählt"
"clear": "Zurücksetzen"
} }
} }

View File

@@ -16,8 +16,7 @@
"decrypting": "decrypting", "decrypting": "decrypting",
"uploading": "uploading", "uploading": "uploading",
"downloading": "downloading", "downloading": "downloading",
"qr_code": "qr code", "qr_code": "qr code"
"password": "password"
}, },
"home": { "home": {
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.", "intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
@@ -25,17 +24,13 @@
"new_note": "new note", "new_note": "new note",
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)", "new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
"errors": { "errors": {
"note_to_big": "could not create note. note is too big", "note_to_big": "could not create note. note is to big",
"note_error": "could not create note. please try again.", "note_error": "could not create note. please try again.",
"max": "max: {n}", "max": "max: {n}",
"empty_content": "note is empty." "empty_content": "note is empty."
}, },
"messages": { "messages": {
"note_created": "note created." "note_created": "note created."
},
"advanced": {
"explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.",
"custom_password": "custom password"
} }
}, },
"show": { "show": {
@@ -44,15 +39,13 @@
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.", "decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.",
"unsupported_type": "unsupported note type." "unsupported_type": "unsupported note type."
}, },
"explanation": "click below to show and delete the note if the counter has reached its limit", "explanation": "click below to show and delete the note if the counter has reached it's limit",
"show_note": "show note", "show_note": "show note",
"warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.", "warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.",
"download_all": "download all", "download_all": "download all"
"links_found": "links found inside the note:"
}, },
"file_upload": { "file_upload": {
"selected_files": "Selected Files", "selected_files": "Selected Files",
"no_files_selected": "No Files Selected", "no_files_selected": "No Files Selected"
"clear": "Reset"
} }
} }

View File

@@ -16,14 +16,13 @@
"decrypting": "descifrando", "decrypting": "descifrando",
"uploading": "cargando", "uploading": "cargando",
"downloading": "descargando", "downloading": "descargando",
"qr_code": "código qr", "qr_code": "código qr"
"password": "contraseña"
}, },
"home": { "home": {
"intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.", "intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.",
"explanation": "la nota expirará y se destruirá después de {type}.", "explanation": "la nota expirará y se destruirá después de {type}.",
"new_note": "nueva nota", "new_note": "nueva nota",
"new_note_notice": "<b>disponibilidad:</b><br />no se garantiza que la nota se almacene, ya que todo se guarda en la memoria RAM, si se llena se eliminarán las notas más antiguas.<br />(probablemente estará bien, solo está advertido.)", "new_note_notice": "<b>disponibilidad:</b><br />no se garantiza que la nota se almacene, ya que todo se guarda en la memoria RAM, si se llena se eliminarán las notas más antiguas.<br />(probablemente estará bien, sólo está advertido.)",
"errors": { "errors": {
"note_to_big": "no se pudo crear la nota. la nota es demasiado grande", "note_to_big": "no se pudo crear la nota. la nota es demasiado grande",
"note_error": "No se ha podido crear la nota. Por favor, inténtelo de nuevo.", "note_error": "No se ha podido crear la nota. Por favor, inténtelo de nuevo.",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "nota creada." "note_created": "nota creada."
},
"advanced": {
"explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.",
"custom_password": "contraseña personalizada"
} }
}, },
"show": { "show": {
@@ -46,13 +41,11 @@
}, },
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite", "explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
"show_note": "mostrar nota", "show_note": "mostrar nota",
"warning_will_not_see_again": "<b>no</b> tendrás la oportunidad de volver a ver la nota.", "warning_will_not_see_again": " <b>no</b> tendrás la oportunidad de volver a ver la nota.",
"download_all": "descargar todo", "download_all": "descargar todo"
"links_found": "enlaces que se encuentran dentro de la nota:"
}, },
"file_upload": { "file_upload": {
"selected_files": "Archivos seleccionados", "selected_files": "Archivos seleccionados",
"no_files_selected": "No hay archivos seleccionados", "no_files_selected": "No hay archivos seleccionados"
"clear": "Restablecer"
} }
} }

View File

@@ -12,30 +12,25 @@
"share_link": "partager le lien", "share_link": "partager le lien",
"copy_clipboard": "copier dans le presse-papiers", "copy_clipboard": "copier dans le presse-papiers",
"copied_to_clipboard": "copié dans le presse-papiers", "copied_to_clipboard": "copié dans le presse-papiers",
"encrypting": "chiffrer", "encrypting": "cryptage",
"decrypting": "déchiffrer", "decrypting": "déchiffrer",
"uploading": "téléversement", "uploading": "téléchargement",
"downloading": "téléchargement", "downloading": "téléchargement",
"qr_code": "code qr", "qr_code": "code qr"
"password": "mot de passe"
}, },
"home": { "home": {
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement chiffrés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.", "intro": "Envoyez facilement des notes ou des fichiers <i>entièrement cryptés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.",
"explanation": "la note expirera et sera détruite après {type}.", "explanation": "la note expirera et sera détruite après {type}.",
"new_note": "nouvelle note", "new_note": "nouvelle note",
"new_note_notice": "<b>disponibilité :</b><br />il n'est pas garanti que la note reste stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)", "new_note_notice": "<b>disponibilité :</b><br />la note n'est pas garantie d'être stockée car tout est conservé dans la mémoire vive, si elle se remplit les notes les plus anciennes seront supprimées.<br />(vous serez probablement bien, soyez juste averti.)",
"errors": { "errors": {
"note_to_big": "Impossible de créer une note. La note est trop grande.", "note_to_big": "Impossible de créer une note. La note est trop grande",
"note_error": "n'a pas pu créer de note. Veuillez réessayer.", "note_error": "n'a pas pu créer de note. Veuillez réessayer.",
"max": "max: {n}", "max": "max: {n}",
"empty_content": "La note est vide." "empty_content": "La note est vide."
}, },
"messages": { "messages": {
"note_created": "note créée." "note_created": "note créée."
},
"advanced": {
"explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.",
"custom_password": "mot de passe personnalisé"
} }
}, },
"show": { "show": {
@@ -45,14 +40,12 @@
"unsupported_type": "type de note non supporté." "unsupported_type": "type de note non supporté."
}, },
"explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.", "explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.",
"show_note": "afficher la note", "show_note": "note de présentation",
"warning_will_not_see_again": "vous <b>n'aurez pas</b> la chance de revoir la note.", "warning_will_not_see_again": "vous <b>n'aurez pas</b> la chance de revoir la note.",
"download_all": "tout télécharger", "download_all": "télécharger tout"
"links_found": "liens trouvés à lintérieur de la note :"
}, },
"file_upload": { "file_upload": {
"selected_files": "Fichiers sélectionnés", "selected_files": "Fichiers sélectionnés",
"no_files_selected": "Aucun fichier sélectionné", "no_files_selected": "Aucun fichier sélectionné"
"clear": "Réinitialiser"
} }
} }

View File

@@ -16,8 +16,7 @@
"decrypting": "decifrando", "decrypting": "decifrando",
"uploading": "caricamento", "uploading": "caricamento",
"downloading": "scaricando", "downloading": "scaricando",
"qr_code": "codice qr", "qr_code": "codice qr"
"password": "password"
}, },
"home": { "home": {
"intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.", "intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "nota creata." "note_created": "nota creata."
},
"advanced": {
"explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.",
"custom_password": "password personalizzata"
} }
}, },
"show": { "show": {
@@ -46,13 +41,11 @@
}, },
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite", "explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
"show_note": "mostra la nota", "show_note": "mostra la nota",
"warning_will_not_see_again": "<b>non</b> avrete la possibilità di rivedere la nota.", "warning_will_not_see_again": " <b>non</b> avrete la possibilità di rivedere la nota.",
"download_all": "scarica tutti", "download_all": "scarica tutti"
"links_found": "link presenti all'interno della nota:"
}, },
"file_upload": { "file_upload": {
"selected_files": "File selezionati", "selected_files": "File selezionati",
"no_files_selected": "Nessun file selezionato", "no_files_selected": "Nessun file selezionato"
"clear": "Reset"
} }
} }

View File

@@ -1,58 +0,0 @@
{
"common": {
"note": "新しいメモ",
"file": "ファイル",
"advanced": "アドバンスド",
"create": "作成",
"loading": "読み込み中",
"mode": "モード",
"views": "{n, plural, =0 {表示可能な時間} =1 { 1 ビュー} other {#ビュー}}",
"minutes": "{n, plural, =0 {有効期間(分)} =1 {1 分} other {# 分}}",
"max": "マックス",
"share_link": "共有リンク",
"copy_clipboard": "クリップボードにコピーする",
"copied_to_clipboard": "クリップボードにコピーされました",
"encrypting": "暗号化",
"decrypting": "復号化",
"uploading": "アップロード中",
"downloading": "ダウンロード中",
"qr_code": "QRコード",
"password": "暗号"
},
"home": {
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
"explanation": "メモは{type}後に期限切れになり、破棄されます。",
"new_note": "新しいメモ",
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <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": "リセット"
}
}

View File

@@ -1,58 +0,0 @@
{
"common": {
"note": "notatka",
"file": "plik",
"advanced": "zaawansowane",
"create": "utwórz",
"loading": "ładowanie",
"mode": "tryb",
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {# wyświetleń}}",
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minuty}}",
"max": "maks.",
"share_link": "link udostępniania",
"copy_clipboard": "kopiuj do schowka",
"copied_to_clipboard": "skopiowano do schowka",
"encrypting": "szyfrowanie",
"decrypting": "odszyfrowywanie",
"uploading": "wysyłanie",
"downloading": "pobieranie",
"qr_code": "kod QR",
"password": "hasło"
},
"home": {
"intro": "Łatwo wysyłaj <i>w pełni zaszyfrowane</i>, bezpieczne notatki lub pliki jednym kliknięciem. Po prostu utwórz notatkę i udostępnij link.",
"explanation": "notatka wygaśnie i zostanie zniszczona po {type}.",
"new_note": "nowa notatka",
"new_note_notice": "<b>dostępność:</b><br />nie ma gwarancji, że notatka będzie przechowywana, ponieważ wszystko jest przechowywane w pamięci RAM, jeśli się zapełni, najstarsze notatki zostaną usunięte.<br />(prawdopodobnie nic się nie stanie, ale warto ostrzec.)",
"errors": {
"note_to_big": "nie można utworzyć notatki. notatka jest za duża",
"note_error": "nie można utworzyć notatki. spróbuj ponownie.",
"max": "maks .: {n}",
"empty_content": "notatka jest pusta."
},
"messages": {
"note_created": "notatka utworzona."
},
"advanced": {
"explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.",
"custom_password": "własne hasło"
}
},
"show": {
"errors": {
"not_found": "notatka nie została znaleziona lub została już usunięta.",
"decryption_failed": "błędne hasło. nie można odszyfrować. prawdopodobnie uszkodzony link. notatka została zniszczona.",
"unsupported_type": "nieobsługiwany typ notatki."
},
"explanation": "kliknij poniżej, aby wyświetlić i usunąć notatkę, jeśli licznik osiągnie swój limit",
"show_note": "pokaż notatkę",
"warning_will_not_see_again": "<b>nie będziesz mieć</b> możliwości ponownego zobaczenia notatki.",
"download_all": "pobierz wszystko",
"links_found": "linki znalezione w notatce:"
},
"file_upload": {
"selected_files": "Wybrane pliki",
"no_files_selected": "Nie wybrano plików",
"clear": "Wyczyść"
}
}

View File

@@ -1,58 +0,0 @@
{
"common": {
"note": "заметка",
"file": "файл",
"advanced": "расширенные",
"create": "создать",
"loading": "загрузка",
"mode": "режим",
"views": "{n, plural, =0 {просмотры} =1 {1 просмотр} other {# просмотры}}",
"minutes": "{n, plural, =0 {минут} =1 {1 минута} other {# минуты}}",
"max": "макс",
"share_link": "поделиться ссылкой",
"copy_clipboard": "скопировать в буфер обмена",
"copied_to_clipboard": "скопировано в буфер обмена",
"encrypting": "шифрование",
"decrypting": "расшифровка",
"uploading": "загрузка",
"downloading": "скачивание",
"qr_code": "qr код",
"password": "пароль"
},
"home": {
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
"explanation": "заметка истечет и будет уничтожена после {type}.",
"new_note": "новая заметка",
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<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": "Сброс"
}
}

View File

@@ -1,58 +0,0 @@
{
"common": {
"note": "筆記",
"file": "檔案",
"advanced": "進階",
"create": "創建",
"loading": "載入中",
"mode": "模式",
"views": "{n, plural, =0 {瀏覽次數} =1 {1 次瀏覽} other {# 次瀏覽}}",
"minutes": "{n, plural, =0 {分鐘} =1 {1 分鐘} other {# 分鐘}}",
"max": "最大",
"share_link": "分享連結",
"copy_clipboard": "複製到剪貼板",
"copied_to_clipboard": "已複製到剪貼板",
"encrypting": "加密中",
"decrypting": "解密中",
"uploading": "上傳中",
"downloading": "下載中",
"qr_code": "QR 碼",
"password": "密碼"
},
"home": {
"intro": "輕鬆地以一鍵傳送<i>完全加密</i>的安全筆記或檔案。只需創建筆記並分享連結。",
"explanation": "筆記將在 {type} 後過期並被銷毀。",
"new_note": "新筆記",
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
"errors": {
"note_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": "重置"
}
}

View File

@@ -16,8 +16,7 @@
"decrypting": "解密", "decrypting": "解密",
"uploading": "上传", "uploading": "上传",
"downloading": "下载", "downloading": "下载",
"qr_code": "二维码", "qr_code": "二维码"
"password": "密码"
}, },
"home": { "home": {
"intro": "飞鸽传书,一键传输完全加密的密信或文件,阅后即焚。", "intro": "飞鸽传书,一键传输完全加密的密信或文件,阅后即焚。",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "密信创建成功。" "note_created": "密信创建成功。"
},
"advanced": {
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
"custom_password": "自定义密码"
} }
}, },
"show": { "show": {
@@ -47,12 +42,10 @@
"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": "注释中找到的链接:"
}, },
"file_upload": { "file_upload": {
"selected_files": "已选中的文件", "selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中", "no_files_selected": "没有文件被选中"
"clear": "重置"
} }
} }

View File

@@ -13,25 +13,30 @@
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@lokalise/node-api": "^13.2.1", "@lokalise/node-api": "^9.8.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^1.0.6",
"@sveltejs/kit": "^2.17.3", "@sveltejs/kit": "^1.15.9",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/dompurify": "^3.0.2",
"@zerodevx/svelte-toast": "^0.9.6", "@types/file-saver": "^2.0.5",
"adm-zip": "^0.5.16", "@zerodevx/svelte-toast": "^0.7.2",
"dotenv": "^16.4.7", "adm-zip": "^0.5.10",
"svelte": "^5.20.5", "dotenv": "^16.0.3",
"svelte-check": "^4.1.4", "svelte": "^3.58.0",
"svelte-intl-precompile": "^0.12.3", "svelte-check": "^2.10.3",
"tslib": "^2.8.1", "svelte-intl-precompile": "^0.10.1",
"typescript": "^5.7.3", "svelte-preprocess": "^4.10.7",
"vite": "^6.2.0" "tslib": "^2.5.0",
"typescript": "^4.9.5",
"vite": "^4.3.3"
}, },
"dependencies": { "dependencies": {
"@fontsource/fira-mono": "^5.1.1", "@cryptgeon/shared": "workspace:*",
"cryptgeon": "workspace:*", "@fontsource/fira-mono": "^4.5.10",
"occulto": "^2.0.6", "copy-to-clipboard": "^3.3.3",
"pretty-bytes": "^6.1.1", "dompurify": "^3.0.2",
"file-saver": "^2.0.5",
"occulto": "^2.0.1",
"pretty-bytes": "^6.1.0",
"qrious": "^4.0.2" "qrious": "^4.0.2"
} }
} }

View File

@@ -5,15 +5,10 @@ import https from 'https'
dotenv.config() dotenv.config()
function exit(msg) {
console.error(msg)
process.exit(1)
}
const apiKey = process.env.LOKALISE_API_KEY const apiKey = process.env.LOKALISE_API_KEY
const project_id = process.env.LOKALISE_PROJECT const project_id = process.env.LOKALISE_PROJECT
if (!apiKey) exit('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"') if (!apiKey) throw new Error('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
if (!project_id) exit('No project id set for Lokalize! Set with "LOKALISE_PROJECT"') if (!project_id) throw new Error('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
const client = new LokaliseApi({ apiKey }) const client = new LokaliseApi({ apiKey })
const WGet = (url) => const WGet = (url) =>

View File

@@ -92,7 +92,7 @@ button {
} }
*:disabled, *:disabled,
.disabled { *[disabled='true'] {
opacity: 0.5; opacity: 0.5;
} }
@@ -126,13 +126,3 @@ fieldset {
.tr { .tr {
text-align: right; text-align: right;
} }
hr {
border: none;
border-bottom: 2px solid var(--ui-bg-1);
margin: 1rem 0;
}
p {
margin: 0;
}

View File

@@ -1,5 +1,3 @@
<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: 316 B

After

Width:  |  Height:  |  Size: 287 B

View File

@@ -1,5 +1,3 @@
<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: 354 B

After

Width:  |  Height:  |  Size: 325 B

View File

@@ -1,5 +1,3 @@
<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: 765 B

After

Width:  |  Height:  |  Size: 736 B

View File

@@ -1,5 +1,3 @@
<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: 512 B

After

Width:  |  Height:  |  Size: 483 B

View File

@@ -1,5 +1,3 @@
<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: 761 B

After

Width:  |  Height:  |  Size: 732 B

View File

@@ -1,8 +1,8 @@
import { API, type Status } from 'cryptgeon/shared' import { status as getStatus, type Status } from '@cryptgeon/shared'
import { writable } from 'svelte/store' 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 API.status()) status.set(await getStatus())
} }

View File

@@ -1,11 +1,11 @@
import { toast } from '@zerodevx/svelte-toast' import { toast, type SvelteToastOptions } from '@zerodevx/svelte-toast'
export enum NotifyType { export enum NotifyType {
Success = 'success', Success = 'success',
Error = 'error', Error = 'error',
} }
const themeMapping: Record<NotifyType, Record<string, string>> = { const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
[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, Record<string, string>> = {
} }
function notifyFN(message: string, type: NotifyType = NotifyType.Success) { function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
const options = { const options: SvelteToastOptions = {
duration: 5_000, duration: 5_000,
theme: { theme: {
...themeMapping[type], ...themeMapping[type],

View File

@@ -1,17 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte' export let title: string
interface Props {
title: string
children?: Snippet
}
let { title, children }: Props = $props()
</script> </script>
<p> <p>
<b>{title}</b> <b>{title}</b>
{@render children?.()} <slot />
</p> </p>
<style> <style>

View File

@@ -4,89 +4,52 @@
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'
interface Props { export let note: Note
note: Note export let timeExpiration = false
timeExpiration?: boolean
customPassword?: string | null
}
let {
note = $bindable(),
timeExpiration = $bindable(false),
customPassword = $bindable(null),
}: Props = $props()
let hasCustomPassword = $state(false)
$effect(() => {
if (!hasCustomPassword) customPassword = null
})
</script> </script>
<div class="flex col"> <div class="fields">
<div class="flex"> <TextInput
<TextInput data-testid="field-views"
data-testid="field-views" type="number"
type="number" label={$t('common.views', { values: { n: 0 } })}
label={$t('common.views', { values: { n: 0 } })} bind:value={note.views}
bind:value={note.views} disabled={timeExpiration}
disabled={timeExpiration} max={$status?.max_views}
max={$status?.max_views} min={1}
min={1} validate={(v) =>
validate={(v) => ($status && v <= $status?.max_views && v > 0) ||
($status && v <= $status?.max_views && v > 0) || $t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })} />
/> <div class="middle-switch">
<Switch <Switch
data-testid="switch-advanced-toggle" data-testid="switch-advanced-toggle"
label={$t('common.mode')} label={$t('common.mode')}
bind:value={timeExpiration} bind:value={timeExpiration}
color={false} color={false}
/> />
<TextInput
data-testid="field-expiration"
type="number"
label={$t('common.minutes', { values: { n: 0 } })}
bind:value={note.expiration}
disabled={!timeExpiration}
max={$status?.max_expiration}
validate={(v) =>
($status && v < $status?.max_expiration) ||
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
/>
</div>
<div class="flex">
<Switch
data-testid="custom-password"
bind:value={hasCustomPassword}
label={$t('home.advanced.custom_password')}
/>
<TextInput
data-testid="password"
type="password"
bind:value={customPassword}
label={$t('common.password')}
disabled={!hasCustomPassword}
random
/>
</div>
<div>
{$t('home.advanced.explanation')}
</div> </div>
<TextInput
data-testid="field-expiration"
type="number"
label={$t('common.minutes', { values: { n: 0 } })}
bind:value={note.expiration}
disabled={!timeExpiration}
max={$status?.max_expiration}
validate={(v) =>
($status && v < $status?.max_expiration) ||
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
/>
</div> </div>
<style> <style>
.flex { .middle-switch {
display: flex; margin: 0 1rem;
align-items: flex-end;
gap: 1rem;
width: 100%;
} }
.col { .fields {
gap: 1.5rem; display: flex;
flex-direction: column;
} }
</style> </style>

View File

@@ -1,14 +1,4 @@
<script lang="ts"> <button {...$$restProps} on:click><slot /></button>
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 {

View File

@@ -5,15 +5,11 @@
import { getCSSVariable } from '$lib/utils' import { getCSSVariable } from '$lib/utils'
interface Props { export let value: string
value: string
}
let { value }: Props = $props() let canvas: HTMLCanvasElement
let canvas: HTMLCanvasElement | null = $state(null) $: {
$effect(() => {
new QR({ new QR({
value, value,
level: 'Q', level: 'Q',
@@ -22,12 +18,12 @@
foreground: getCSSVariable('--ui-text-0'), foreground: getCSSVariable('--ui-text-0'),
element: canvas, element: canvas,
}) })
}) }
</script> </script>
<small>{$t('common.qr_code')}</small> <small>{$t('common.qr_code')}</small>
<div> <div>
<canvas bind:this={canvas}></canvas> <canvas bind:this={canvas} />
</div> </div>
<style> <style>

View File

@@ -3,15 +3,10 @@
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte' import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from 'cryptgeon/shared' import type { FileDTO } from '@cryptgeon/shared'
interface Props { export let label: string = ''
label?: string export let files: FileDTO[] = []
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 {
@@ -40,7 +35,7 @@
<small> <small>
{label} {label}
</small> </small>
<input {...rest} type="file" onchange={onInput} multiple /> <input {...$$restProps} type="file" on:change={onInput} multiple />
<div class="box"> <div class="box">
{#if files.length} {#if files.length}
<div> <div>
@@ -50,8 +45,8 @@
{file.name} {file.name}
</div> </div>
{/each} {/each}
<div class="spacer"></div> <div class="spacer" />
<Button onclick={clear}>{$t('file_upload.clear')}</Button> <Button on:click={clear}>Clear</Button>
</div> </div>
{:else} {:else}
<div> <div>

View File

@@ -1,10 +1,9 @@
<script lang="ts" module> <script lang="ts" context="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,
@@ -16,27 +15,22 @@
</script> </script>
<script lang="ts"> <script lang="ts">
interface Props { export let icon: keyof typeof map
icon: keyof typeof map
}
let { icon, ...rest }: HTMLButtonAttributes & Props = $props()
</script> </script>
<button type="button" {...rest}> <div on:click {...$$restProps}>
{#if map[icon]} {#if map[icon]}
{@const SvelteComponent = map[icon]} <svelte:component this={map[icon]} />
<SvelteComponent />
{/if} {/if}
</button> </div>
<style> <style>
button { div {
display: inline-block; display: inline-block;
contain: strict; contain: strict;
box-sizing: content-box; box-sizing: content-box;
} }
button > :global(svg) { div > :global(svg) {
display: block; display: block;
fill: currentColor; fill: currentColor;
} }

View File

@@ -1,5 +1,3 @@
<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: 813 B

After

Width:  |  Height:  |  Size: 784 B

View File

@@ -1,25 +1,20 @@
<script lang="ts" module> <script lang="ts" context="module">
export type NoteResult = { export type NoteResult = {
password: string
id: string id: string
password?: string
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import { status } from '$lib/stores/status'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import TextInput from '$lib/ui/TextInput.svelte' import TextInput from '$lib/ui/TextInput.svelte'
import Canvas from './Canvas.svelte' import Canvas from './Canvas.svelte'
interface Props { export let result: NoteResult
result: NoteResult
}
let { result }: Props = $props() $: url = `${window.location.origin}/note/${result.id}#${result.password}`
let url = $state(`${window.location.origin}/note/${result.id}`)
if (result.password) url += `#${result.password}`
function reset() { function reset() {
window.location.reload() window.location.reload()
@@ -39,13 +34,11 @@
<Canvas value={url} /> <Canvas value={url} />
</div> </div>
{#if $status?.theme_new_note_notice} <p>
<p> {@html $t('home.new_note_notice')}
{@html $t('home.new_note_notice')} </p>
</p>
{/if}
<br /> <br />
<Button onclick={reset}>{$t('home.new_note')}</Button> <Button on:click={reset}>{$t('home.new_note')}</Button>
<style> <style>
div { div {

View File

@@ -1,35 +1,31 @@
<script lang="ts" module> <script lang="ts" context="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 DOMPurify from 'dompurify'
import { saveAs } from 'file-saver'
import prettyBytes from 'pretty-bytes' import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import 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'
interface Props { export let note: DecryptedNote
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[] = $state([]) let files: FileDTO[] = []
$: if (note.meta.type === 'file') {
files = note.contents
}
$: download = () => {
for (const file of files) {
downloadFile(file)
}
}
async function downloadFile(file: FileDTO) { async function downloadFile(file: FileDTO) {
const f = new File([file.contents], file.name, { const f = new File([file.contents], file.name, {
@@ -38,49 +34,32 @@
saveAs(f) saveAs(f)
} }
$effect(() => { function contentWithLinks(content: string): string {
if (note.meta.type === 'file') { const replaced = content.replace(
files = note.contents RE_URL,
} (url) => `<a href="${url}" rel="noreferrer">${url}</a>`
}) )
let download = $derived(() => { return DOMPurify.sanitize(replaced, { USE_PROFILES: { html: true } })
for (const file of files) { }
downloadFile(file)
}
})
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
</script> </script>
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p> <p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
<div data-testid="result"> <div data-testid="result">
{#if note.meta.type === 'text'} {#if note.meta.type === 'text'}
<div class="note"> <div class="note">
{note.contents} {@html contentWithLinks(note.contents)}
</div> </div>
<Button onclick={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button> <Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
{#if links && links.length}
<div class="links">
{$t('show.links_found')}
<ul>
{#each links as link}
<li>
<a href={link} target="_blank" rel="noopener noreferrer">{link}</a>
</li>
{/each}
</ul>
</div>
{/if}
{:else} {:else}
{#each files as file} {#each files as file}
<div class="note file"> <div class="note file">
<button onclick={() => downloadFile(file)}> <button on:click={() => 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 onclick={download}>{$t('show.download_all')}</Button> <Button on:click={download}>{$t('show.download_all')}</Button>
{/if} {/if}
</div> </div>
@@ -113,20 +92,4 @@
.note.file small { .note.file small {
padding-left: 1rem; padding-left: 1rem;
} }
.links {
margin-top: 2rem;
}
.links ul {
margin: 0;
padding: 0;
margin-top: 0.5rem;
padding-left: 1rem;
list-style: square;
}
.links ul li {
margin-bottom: 0.5rem;
word-wrap: break-word;
}
</style> </style>

View File

@@ -1,43 +1,46 @@
<script lang="ts"> <script lang="ts">
interface Props { export let label: string = ''
label?: string export let value: boolean
value: boolean export let color = true
color?: boolean
[key: string]: any
}
let { label = '', value = $bindable(), color = true, ...rest }: Props = $props()
</script> </script>
<label {...rest}> <div {...$$restProps}>
<small>{label}</small> <label class="switch">
<input type="checkbox" bind:checked={value} /> <small>{label}</small>
<span class:color class="slider"></span> <input type="checkbox" bind:checked={value} />
</label> <span class:color class="slider" />
</label>
</div>
<style> <style>
label { div {
height: 3.75rem;
}
.switch {
position: relative; position: relative;
display: inline-block; display: inline-block;
width: 4rem;
height: 2.5rem;
} }
label input { .switch input {
display: none; opacity: 0;
} width: 0;
height: 0;
small {
display: block;
width: max-content;
} }
.slider { .slider {
display: block; position: absolute;
width: 4rem;
height: 2.5rem;
position: relative;
cursor: pointer; cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px solid var(--ui-bg-1); border: 2px solid var(--ui-bg-1);
background-color: var(--ui-bg-0); background-color: var(--ui-bg-0);
transition: var(--ui-anim);
transform: translateY(1.2rem);
} }
.slider:before { .slider:before {

View File

@@ -1,16 +1,11 @@
<script lang="ts"> <script lang="ts">
interface Props { export let label: string = ''
label?: string export let value: 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" {...rest} bind:value></textarea> <textarea class="box" {...$$restProps} bind:value />
</label> </label>

View File

@@ -2,34 +2,25 @@
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'
interface Props { export let label: string = ''
label?: string export let value: any
value: any export let validate: (value: any) => boolean | string = () => true
validate?: (value: any) => boolean | string export let copy: boolean = false
copy?: boolean export let random: boolean = false
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()
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
} }
@@ -39,32 +30,22 @@
</script> </script>
<label> <label>
<small class:disabled={rest.disabled}> <small disabled={$$restProps.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 {...rest} {type} autocomplete="off" class:valid={valid === true} /> <input bind:value {...$$restProps} class:valid={valid === true} />
<div class="icons"> <div class="icons">
{#if isPassword} {#if isPassword}
<Icon <Icon class="icon" icon={hidden ? 'eye' : 'eye-off'} on:click={toggle} />
disabled={rest.disabled}
class="icon"
icon={hidden ? 'eye' : 'eye-off'}
onclick={toggle}
/>
{/if} {/if}
{#if random} {#if random}
<Icon disabled={rest.disabled} class="icon" icon="dice" onclick={randomFN} /> <Icon class="icon" icon="dice" on:click={randomFN} />
{/if} {/if}
{#if copy} {#if copy}
<Icon <Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} />
disabled={rest.disabled}
class="icon"
icon="copy"
onclick={() => copyFN(value.toString())}
/>
{/if} {/if}
</div> </div>
</label> </label>
@@ -73,7 +54,6 @@
label { label {
position: relative; position: relative;
display: block; display: block;
width: 100%;
} }
label > small { label > small {

View File

@@ -1,21 +1,24 @@
<script lang="ts" module> <script lang="ts" context="module">
import { writable } from 'svelte/store' import { writable } from 'svelte/store'
const themes = ['dark', 'light', 'auto'] as const enum Theme {
type Theme = (typeof themes)[number] Dark = 'dark',
Light = 'light',
Auto = 'auto',
}
const NextTheme: Record<Theme, Theme> = { const NextTheme = {
auto: 'light', [Theme.Auto]: Theme.Light,
light: 'dark', [Theme.Light]: Theme.Dark,
dark: 'auto', [Theme.Dark]: Theme.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 (themes.includes(saved)) return saved if (Object.values(Theme).includes(saved)) return saved
} }
return 'auto' return Theme.Auto
} }
export const theme = writable<Theme>(init()) export const theme = writable<Theme>(init())
@@ -37,7 +40,7 @@
} }
</script> </script>
<button onclick={change}> <button on:click={change}>
<Icon class="icon" icon="contrast" /> <Icon class="icon" icon="contrast" />
{$theme} {$theme}
</button> </button>

View File

@@ -1,10 +1,11 @@
import * as 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) {
window.navigator.clipboard.writeText(value) copyToClipboard(value)
const msg = get(t)('common.copied_to_clipboard') const msg = get(t)('common.copied_to_clipboard')
notify.success(msg) notify.success(msg)
} }

View File

@@ -13,31 +13,29 @@
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte' import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte' import TextArea from '$lib/ui/TextArea.svelte'
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared' import type { FileDTO, Note } from '@cryptgeon/shared'
import { Adapters, create, PayloadToLargeError } from '@cryptgeon/shared'
let note: Note = $state({ let note: Note = {
contents: '', contents: '',
meta: { type: 'text' }, meta: { type: 'text' },
views: 1, views: 1,
expiration: 60, expiration: 60,
}) }
let files: FileDTO[] = $state([]) let files: FileDTO[]
let result: NoteResult | null = $state(null) let result: NoteResult | null = null
let advanced = $state(false) let advanced = false
let isFile = $state(false) let isFile = false
let timeExpiration = $state(false) let timeExpiration = false
let customPassword: string | null = $state(null) let description = ''
let description = $state('') let loading: string | null = null
let loading: string | null = $state(null)
$effect(() => { $: if (!advanced) {
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', {
@@ -45,33 +43,27 @@
}), }),
}, },
}) })
}) }
$effect(() => { $: note.meta.type = isFile ? 'file' : 'text'
note.meta.type = isFile ? 'file' : 'text'
})
$effect(() => { $: if (!isFile) {
if (!isFile) { note.contents = ''
note.contents = '' }
}
})
class EmptyContentError extends Error {} class EmptyContentError extends Error {}
async function submit(e: SubmitEvent) { async function submit() {
e.preventDefault()
try { try {
loading = $t('common.encrypting') loading = $t('common.encrypting')
const derived = customPassword && (await AES.derive(customPassword)) const key = await AES.generateKey()
const key = derived ? derived[0] : await AES.generateKey() const password = Hex.encode(key)
const data: Note = { const data: Note = {
contents: '', contents: '',
meta: note.meta, meta: note.meta,
} }
if (derived) data.meta.derivation = derived[1]
if (isFile) { if (isFile) {
if (files.length === 0) throw new EmptyContentError() if (files.length === 0) throw new EmptyContentError()
data.contents = await Adapters.Files.encrypt(files, key) data.contents = await Adapters.Files.encrypt(files, key)
@@ -83,10 +75,10 @@
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 API.create(data) const response = await create(data)
result = { result = {
password: password,
id: response.id, id: response.id,
password: customPassword ? undefined : Hex.encode(key),
} }
notify.success($t('home.messages.note_created')) notify.success($t('home.messages.note_created'))
} catch (e) { } catch (e) {
@@ -110,7 +102,7 @@
<p> <p>
{@html $status?.theme_text || $t('home.intro')} {@html $status?.theme_text || $t('home.intro')}
</p> </p>
<form onsubmit={submit}> <form on:submit|preventDefault={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 />
@@ -124,14 +116,12 @@
{/if} {/if}
<div class="bottom"> <div class="bottom">
{#if $status?.allow_files} <Switch
<Switch data-testid="switch-file"
data-testid="switch-file" class="file"
class="file" label={$t('common.file')}
label={$t('common.file')} bind:value={isFile}
bind:value={isFile} />
/>
{/if}
{#if $status?.allow_advanced} {#if $status?.allow_advanced}
<Switch <Switch
data-testid="switch-advanced" data-testid="switch-advanced"
@@ -139,7 +129,7 @@
bind:value={advanced} bind:value={advanced}
/> />
{/if} {/if}
<div class="grow"></div> <div class="grow" />
<div class="tr"> <div class="tr">
<small>{$t('common.max')}: <MaxSize /> </small> <small>{$t('common.max')}: <MaxSize /> </small>
<br /> <br />
@@ -157,9 +147,9 @@
</p> </p>
{#if advanced} {#if advanced}
<div transition:blur|global={{ duration: 250 }}> <div transition:blur={{ duration: 250 }}>
<hr /> <br />
<AdvancedParameters bind:note bind:timeExpiration bind:customPassword /> <AdvancedParameters bind:note bind:timeExpiration />
</div> </div>
{/if} {/if}
</fieldset> </fieldset>

View File

@@ -1,6 +1,5 @@
<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>
@@ -8,11 +7,6 @@
<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>
@@ -29,7 +23,6 @@
right: 0; right: 0;
width: 100%; width: 100%;
background-color: var(--ui-bg-0-85); background-color: var(--ui-bg-0-85);
backdrop-filter: blur(2px);
} }
a { a {

View File

@@ -1,13 +1,9 @@
<script lang="ts"> <script lang="ts">
import { status } from '$lib/stores/status' import { status } from '$lib/stores/status'
function reset() {
window.location.reload()
}
</script> </script>
<header> <header>
<a onclick={reset} href="/"> <a 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}

View File

@@ -8,11 +8,6 @@
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()
@@ -27,7 +22,7 @@
{#await waitLocale() then _} {#await waitLocale() then _}
<main> <main>
<Header /> <Header />
{@render children?.()} <slot />
</main> </main>
<SvelteToast /> <SvelteToast />

View File

@@ -40,8 +40,8 @@
<br /> <br />
you are welcomed to check & audit the you are welcomed to check & audit the
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer"> <a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
source code</a source code
>. </a>.
</span> </span>
</AboutParagraph> </AboutParagraph>
@@ -75,9 +75,6 @@
<style> <style>
section { section {
width: 100%; width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
} }
ul { ul {

View File

@@ -1,33 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { status } from '$lib/stores/status'
status.subscribe((config) => {
if (config != null) {
if (config.imprint_url) {
window.location.href = config.imprint_url
} else if (config.imprint_html == '') {
goto('/about')
}
}
})
</script>
<svelte:head>
<title>Imprint</title>
</svelte:head>
<section class="content">
{#if $status?.imprint_html}
{@html $status.imprint_html}
{/if}
</section>
<style>
section {
width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
}
</style>

View File

@@ -1,39 +1,30 @@
<script lang="ts"> <script lang="ts">
import { AES, Hex } from 'occulto' import { Hex } from 'occulto'
import { onMount } from 'svelte' import { onMount } from 'svelte'
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 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 { Adapters, get, info } from '@cryptgeon/shared'
import { Adapters, API, type NoteMeta } from 'cryptgeon/shared'
import type { PageData } from './$types' import type { PageData } from './$types'
interface Props { export let data: PageData
data: PageData
}
let { data }: Props = $props()
let id = data.id let id = data.id
let password: string | null = $state<string | null>(null) let password: string
let note: DecryptedNote | null = $state(null) let note: DecryptedNote | null = null
let exists = $state(false) let exists = false
let meta: NoteMeta | null = $state(null)
let loading: string | null = $state(null) let loading: string | null = null
let error: string | null = $state(null) let error: string | null = 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 API.info(id) await info(id)
meta = note.meta
exists = true exists = true
} catch { } catch {
exists = false exists = false
@@ -45,21 +36,13 @@
/** /**
* Get the actual contents of the note and decrypt it. * Get the actual contents of the note and decrypt it.
*/ */
async function show(e: SubmitEvent) { async function show() {
e.preventDefault()
try { try {
if (!valid) {
error = $t('show.errors.no_password')
return
}
// Load note
error = null error = null
loading = $t('common.downloading') loading = $t('common.downloading')
const data = await API.get(id) const data = await get(id)
loading = $t('common.decrypting') loading = $t('common.decrypting')
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation)) const key = Hex.decode(password)
const key = derived ? derived[0] : Hex.decode(password!)
switch (data.meta.type) { switch (data.meta.type) {
case 'text': case 'text':
note = { note = {
@@ -91,21 +74,12 @@
{:else if note && !error} {:else if note && !error}
<ShowNote {note} /> <ShowNote {note} />
{:else} {:else}
<form onsubmit={show}> <form on:submit|preventDefault={show}>
<fieldset> <fieldset>
<p>{$t('show.explanation')}</p> <p>{$t('show.explanation')}</p>
{#if meta?.derivation} <Button data-testid="show-note-button" type="submit">{$t('show.show_note')}</Button>
<TextInput
data-testid="show-note-password"
type="password"
bind:value={password}
label={$t('common.password')}
/>
{/if}
<Button disabled={!valid} data-testid="show-note-button" type="submit"
>{$t('show.show_note')}</Button
>
{#if error} {#if error}
<br />
<p class="error-text"> <p class="error-text">
{error} {error}
<br /> <br />
@@ -123,10 +97,4 @@
.loader { .loader {
text-align: center; text-align: center;
} }
fieldset {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style> </style>

View File

@@ -1,9 +1,9 @@
import adapter from '@sveltejs/adapter-static' import adapter from '@sveltejs/adapter-static'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin' import preprocess from 'svelte-preprocess'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default { export default {
preprocess: vitePreprocess([precompileIntl('locales')]), preprocess: preprocess(),
kit: { kit: {
adapter: adapter({ adapter: adapter({
fallback: 'index.html', fallback: 'index.html',

View File

@@ -1,17 +1,12 @@
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 = 3000 const port = 8001
/** @type {import('vite').UserConfig} */ /** @type {import('vite').UserConfig} */
const config = { const config = {
clearScreen: false, clearScreen: false,
server: { server: { port },
port,
proxy: {
'/api': 'http://localhost:8000',
},
},
preview: { port }, preview: { port },
plugins: [sveltekit(), precompileIntl('locales')], plugins: [sveltekit(), precompileIntl('locales')],
} }

View File

@@ -0,0 +1,12 @@
{
"private": true,
"name": "@cryptgeon/proxy",
"type": "module",
"main": "./proxy.js",
"scripts": {
"dev": "node ."
},
"dependencies": {
"http-proxy": "^1.18.1"
}
}

16
packages/proxy/proxy.js Normal file
View File

@@ -0,0 +1,16 @@
import http from 'http'
import httpProxy from 'http-proxy'
const proxy = httpProxy.createProxyServer()
proxy.on('error', function (err, req, res) {
console.error(err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('500 Internal Server Error')
})
const server = http.createServer(function (req, res) {
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
proxy.web(req, res, { target })
})
server.listen(1234)
console.log('Proxy on http://localhost:1234')

View File

@@ -0,0 +1,22 @@
{
"private": true,
"name": "@cryptgeon/shared",
"type": "module",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"dev": "tsc -w",
"build": "tsc"
},
"devDependencies": {
"typescript": "^4.9.5"
},
"dependencies": {
"occulto": "^2.0.1"
}
}

View File

@@ -1,9 +1,6 @@
import type { KeyData, TypedArray } from 'occulto' import type { TypedArray } from 'occulto'
export type NoteMeta = { export type NoteMeta = { type: 'text' | 'file' }
type: 'text' | 'file'
derivation?: KeyData
}
export type Note = { export type Note = {
contents: string contents: string
@@ -11,7 +8,7 @@ export type Note = {
views?: number views?: number
expiration?: number expiration?: number
} }
export type NoteInfo = Pick<Note, 'meta'> export type NoteInfo = {}
export type NotePublic = Pick<Note, 'contents' | 'meta'> export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string } export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
@@ -23,10 +20,6 @@ export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string contents: string
} }
type ClientOptions = {
server: string
}
type CallOptions = { type CallOptions = {
url: string url: string
method: string method: string
@@ -35,21 +28,14 @@ type CallOptions = {
export class PayloadToLargeError extends Error {} export class PayloadToLargeError extends Error {}
export let client: ClientOptions = { export let BASE = ''
server: '',
export function setBase(url: string) {
BASE = url
} }
function setOptions(options: Partial<ClientOptions>) { export async function call(options: CallOptions) {
client = { ...client, ...options } const response = await fetch(BASE + '/api/' + options.url, {
}
function getOptions(): ClientOptions {
return client
}
async function call(options: CallOptions) {
const url = client.server + '/api/' + options.url
const response = await fetch(url, {
method: options.method, method: options.method,
body: options.body === undefined ? undefined : JSON.stringify(options.body), body: options.body === undefined ? undefined : JSON.stringify(options.body),
mode: 'cors', mode: 'cors',
@@ -65,7 +51,7 @@ async function call(options: CallOptions) {
return response.json() return response.json()
} }
async function create(note: Note) { export async function create(note: Note) {
const { meta, ...rest } = note const { meta, ...rest } = note
const body: NoteCreate = { const body: NoteCreate = {
...rest, ...rest,
@@ -79,31 +65,24 @@ async function create(note: Note) {
return data as { id: string } return data as { id: string }
} }
async function get(id: string): Promise<NotePublic> { export 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',
}) })
const { contents, meta } = data const { contents, meta } = data
const note = { return {
contents, contents,
meta: JSON.parse(meta), meta: JSON.parse(meta) as NoteMeta,
} satisfies NotePublic }
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
} }
async function info(id: string): Promise<NoteInfo> { export 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',
}) })
const { meta } = data return data
const note = {
meta: JSON.parse(meta),
} satisfies NoteInfo
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
} }
export type Status = { export type Status = {
@@ -112,29 +91,16 @@ 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
theme_page_title: string theme_page_title: string
theme_new_note_notice: boolean
} }
async function status() { export 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,
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"incremental": true,
"composite": true,
"target": "es2022",
"module": "es2022",
"rootDir": "./src",
"moduleResolution": "node",
"outDir": "./dist",
"strict": true
}
}

View File

@@ -3,19 +3,17 @@ import { devices, type PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
use: { use: {
video: 'retain-on-failure', video: 'retain-on-failure',
baseURL: 'http://localhost:3000', baseURL: 'http://localhost:1234',
actionTimeout: 30_000, actionTimeout: 60_000,
}, },
outputDir: './test-results', outputDir: './test-results',
testDir: './test', testDir: './test',
timeout: 30_000, timeout: 60_000,
fullyParallel: true,
retries: 2,
webServer: { webServer: {
command: 'pnpm run docker:up', command: 'docker compose -f docker-compose.dev.yaml up',
port: 3000, port: 1234,
reuseExistingServer: true, reuseExistingServer: true,
}, },
@@ -23,6 +21,10 @@ const config: PlaywrightTestConfig = {
{ name: 'chrome', use: { ...devices['Desktop Chrome'] } }, { name: 'chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'safari', use: { ...devices['Desktop Safari'] } }, { name: 'safari', use: { ...devices['Desktop Safari'] } },
{
name: 'local',
use: { ...devices['Desktop Chrome'] },
},
], ],
} }

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