Compare commits

...

20 Commits

Author SHA1 Message Date
fdc2722fb9 add password to frontend 2023-05-23 09:39:19 +02:00
6da28a701e translations 2023-05-23 09:39:06 +02:00
e6d1e0f44a add password to CLI 2023-05-23 09:39:00 +02:00
6000553b95 include meta in info endpoint 2023-05-23 09:38:23 +02:00
85204776d7 demo postman collection 2023-05-23 09:38:00 +02:00
c2bfe9dd0d add derivation to metadata 2023-05-23 09:37:49 +02:00
b05841a562 add timeout 2023-05-23 09:37:33 +02:00
d7e5a34b14 CLI (#84)
* move to packages

* update deps

* update deps

* actions maintenance

* don't use blob

* cli

* fix default import

* use synthetic default imports

* remove comment

* cli packaging

* node 18 guard

* packages

* build system

* testing

* test pipeline

* pipelines

* changelog

* version bump

* update locales

* update deps

* update deps

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-13 19:21:06 +01:00
7ee1b8370a update deps 2023-01-14 18:54:27 +01:00
e7750699cc chose: changelog 2023-01-14 18:40:45 +01:00
e14042ea28 chinese language code 2023-01-14 18:40:16 +01:00
6fb7518b6a moved to occulto 2023-01-13 21:24:27 +01:00
436ae2a7e5 move dev ports 2023-01-13 19:36:26 +01:00
fe5ce580ab locales 2023-01-04 19:45:25 +01:00
0f882da5d1 bump version 2023-01-04 19:45:18 +01:00
ad6f136dd0 qr code 2023-01-04 19:40:37 +01:00
91 changed files with 3919 additions and 1472 deletions

View File

@@ -1,15 +1,15 @@
*
!/packages/backend/src
!/packages/backend/Cargo.lock
!/packages/backend/Cargo.toml
!/packages
!/package.json
!/pnpm-lock.yaml
!/pnpm-workspace.yaml
!/packages/frontend/locales
!/packages/frontend/src
!/packages/frontend/static
!/packages/frontend/.npmrc
!/packages/frontend/package.json
!/packages/frontend/pnpm-lock.yaml
!/packages/frontend/svelte.config.js
!/packages/frontend/tsconfig.json
!/packages/frontend/vite.config.js
**/target
**/node_modules
**/dist
**/bin
**/*.tsbuildinfo
**/build
**/.svelte
**/.svelte-kit

View File

@@ -4,21 +4,34 @@ on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
- 'v*.*.*'
jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- uses: pnpm/action-setup@v2
- run: |
pnpm install --frozen-lockfile
pnpm run build
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
with:
install: true
- name: Docker Labels
id: meta
uses: crazy-max/ghaction-docker-meta@v2
uses: docker/metadata-action@v4
with:
images: cupcakearmy/cryptgeon
tags: |
@@ -26,12 +39,12 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm64
push: true

View File

@@ -10,27 +10,29 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "16"
- uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
# Node
- uses: actions/setup-node@v3
with:
node-version-file: '.nvmrc'
- uses: pnpm/action-setup@v2
# Docker
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build docker image
run: npm run test:prepare
- name: Prepare
run: |
npm install playwright
npx playwright install --with-deps
pnpm install --frozen-lockfile
pnpm exec playwright install --with-deps
pnpm run test:prepare
- name: Run your tests
run: npm test
- uses: actions/upload-artifact@v2
if: always()
run: pnpm test
- uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results

16
.gitignore vendored
View File

@@ -1,14 +1,10 @@
.env
*.tsbuildinfo
node_modules
dist
bin
# Backend
target
# Client
.DS_Store
node_modules
/.svelte
/build
/functions
.env
General
# Testing
test-results

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v18.16

View File

@@ -5,6 +5,38 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.3.0] - 2023-05-XX
### Added
- New CLI 🎉
- Russian language
### Changed
- Moved to monorepo
## [2.2.0] - 2023-01-14
### Changed
- Default port is now 8000, not 5000.
- Moved to generic encryption library `occulto`.
### Fixed
- Bad chinese language code.
### Security
- Updated dependencies.
## [2.1.0] - 2023-01-04
### Added
- QR Code to more easily copy and share links.
## [2.0.7] - 2022-12-26
### Changed

View File

@@ -0,0 +1,593 @@
{
"info": {
"_postman_id": "52d9e661-2d99-47f8-b09a-40b6a1c0b364",
"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": null
}
]
},
{
"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": null
}
]
}
]
},
{
"name": "Status",
"item": [
{
"name": "Get",
"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}"
}
]
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
}
],
"variable": [
{
"key": "BASE",
"value": "http://localhost:1234/api",
"type": "default"
},
{
"key": "NOTE_ID",
"value": "",
"type": "default"
}
]
}

View File

@@ -1,32 +1,28 @@
# FRONTEND
FROM node:16-alpine as client
FROM node:18-alpine as client
WORKDIR /tmp
RUN npm install -g pnpm@7
COPY ./packages/frontend ./
RUN pnpm install
RUN pnpm exec svelte-kit sync
RUN npm install -g pnpm@8
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm run build
# BACKEND
FROM rust:1.64-alpine as backend
FROM rust:1.69-alpine as backend
WORKDIR /tmp
RUN apk add libc-dev openssl-dev alpine-sdk
COPY ./packages/backend/Cargo.* ./
# https://blog.rust-lang.org/2022/06/22/sparse-registry-testing.html
RUN rustup update nightly
ENV CARGO_UNSTABLE_SPARSE_REGISTRY=true
RUN cargo +nightly fetch
RUN cargo fetch
COPY ./packages/backend ./
RUN cargo +nightly build --release
RUN cargo build --release
# RUNNER
FROM alpine
WORKDIR /app
COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/build ./frontend
COPY --from=client /tmp/packages/frontend/build ./frontend
ENV FRONTEND_PATH="./frontend"
ENV REDIS="redis://redis/"
EXPOSE 5000
EXPOSE 8000
ENTRYPOINT [ "/app/cryptgeon" ]

View File

@@ -91,7 +91,7 @@ services:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
- 80:8000
```
### NGINX Proxy
@@ -112,6 +112,7 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
### 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)
- German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
@@ -138,9 +139,6 @@ cargo install cargo-watch
Make sure you have docker running.
> If you are on `macOS` you might need to disable AirPlay Receiver as it uses port 5000 (So stupid...)
> https://developer.apple.com/forums/thread/682332
```bash
pnpm run dev
```

View File

@@ -82,7 +82,7 @@ services:
environment:
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
- 80:8000
```
### NGINX 反向代理
@@ -148,9 +148,6 @@ cargo install cargo-watch
确保你的 Docker 正在运行
> 如果你用的是 `macOS` 的话你可能需要关闭 AirPlay 接收功能因为该功能需要占用 5000 端口...)
> https://developer.apple.com/forums/thread/682332
```bash
pnpm run dev
```

View File

@@ -5,12 +5,19 @@
},
{
"path": "packages/backend"
},
{
"path": "packages/frontend"
},
{
"path": "packages/cli"
},
{
"path": "packages/shared"
}
],
"settings": {
"cSpell.words": ["ciphertext", "cryptgeon"],
"i18n-ally.enabledFrameworks": ["svelte"],
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": ["packages/frontend/locales"]
"i18n-ally.localesPaths": ["packages/frontend/locales"],
"cSpell.words": ["cryptgeon"]
}
}

View File

@@ -10,10 +10,9 @@ services:
- 6379:6379
app:
image: cupcakearmy/cryptgeon:test
build: .
env_file: .dev.env
depends_on:
- redis
ports:
- 1234:5000
- 1234:8000

View File

@@ -15,4 +15,4 @@ services:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
- 80:8000

View File

@@ -4,7 +4,7 @@ server {
server_name _;
location / {
proxy_pass http://app:5000/;
proxy_pass http://app:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -20,7 +20,7 @@ server {
ssl_trusted_certificate /path/to/fullchain.pem;
location / {
proxy_pass http://app:5000/;
proxy_pass http://app:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -1,18 +1,21 @@
{
"packageManager": "pnpm@8.4.0",
"scripts": {
"dev:docker": "docker-compose up redis",
"dev:docker": "docker-compose -f docker-compose.dev.yaml up redis",
"dev:packages": "pnpm --parallel run dev",
"dev:proxy": "node proxy.mjs",
"dev": "run-p dev:*",
"docker:up": "docker compose -f docker-compose.dev.yaml up",
"docker:build": "docker compose -f docker-compose.dev.yaml build",
"test": "playwright test --project chrome firefox safari",
"test:local": "playwright test --project local",
"test:server": "docker compose -f docker-compose.dev.yaml up",
"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"
},
"devDependencies": {
"@playwright/test": "^1.29.1",
"@types/node": "^16.18.10",
"http-proxy": "^1.18.1",
"npm-run-all": "^4.1.5"
"@playwright/test": "^1.33.0",
"@types/node": "^20.1.3",
"npm-run-all": "^4.1.5",
"shelljs": "^0.8.5"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "cryptgeon"
version = "2.0.7"
version = "2.3.0-beta.4"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021"
@@ -8,6 +8,9 @@ edition = "2021"
name = "cryptgeon"
path = "src/main.rs"
[registries.crates-io]
protocol = "sparse"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"private": true,
"name": "@cryptgeon/backend",
"scripts": {
"dev": "cargo watch -x 'run --bin cryptgeon'",
"build": "cargo build --release",

View File

@@ -8,7 +8,7 @@ lazy_static! {
pub static ref FRONTEND_PATH: String =
std::env::var("FRONTEND_PATH").unwrap_or("../frontend/build".to_string());
pub static ref LISTEN_ADDR: String =
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:5000".to_string());
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:8000".to_string());
pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string());
}

View File

@@ -11,7 +11,9 @@ pub struct Note {
}
#[derive(Serialize, Deserialize, Clone)]
pub struct NoteInfo {}
pub struct NoteInfo {
pub meta: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct NotePublic {

View File

@@ -24,7 +24,7 @@ async fn one(path: web::Path<NotePath>) -> impl Responder {
let note = store::get(&p.id);
match note {
Ok(Some(_)) => HttpResponse::Ok().json(NoteInfo {}),
Ok(Some(n)) => HttpResponse::Ok().json(NoteInfo { meta: n.meta }),
Ok(None) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}

37
packages/cli/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"version": "2.3.0-beta.4",
"name": "cryptgeon",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"dev": "./scripts/build.js --watch",
"build": "./scripts/build.js",
"package": "./scripts/package.js",
"bin": "run-s build package",
"prepublishOnly": "run-s build"
},
"main": "./dist/index.cjs",
"bin": {
"cryptgeon": "./dist/index.cjs"
},
"files": [
"dist"
],
"devDependencies": {
"@commander-js/extra-typings": "^10.0.3",
"@cryptgeon/shared": "workspace:*",
"@types/inquirer": "^9.0.3",
"@types/mime": "^3.0.1",
"@types/node": "^20.1.3",
"commander": "^10.0.1",
"esbuild": "^0.17.19",
"inquirer": "^9.2.2",
"mime": "^3.0.0",
"occulto": "^2.0.1",
"pkg": "^5.8.1",
"pretty-bytes": "^6.1.0",
"typescript": "^5.0.4"
}
}

17
packages/cli/scripts/build.js Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { build, context } from 'esbuild'
import pkg from '../package.json' assert { type: 'json' }
const options = {
entryPoints: ['./src/index.ts'],
bundle: true,
minify: true,
platform: 'node',
outfile: './dist/index.cjs',
define: { VERSION: `"${pkg.version}"` },
}
const watch = process.argv.slice(2)[0] === '--watch'
if (watch) (await context(options)).watch()
else await build(options)

17
packages/cli/scripts/package.js Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { exec } from 'pkg'
const targets = [
'node18-macos-arm64',
'node18-macos-x64',
'node18-linux-arm64',
'node18-linux-x64',
'node18-win-arm64',
'node18-win-x64',
]
for (const target of targets) {
console.log(`🚀 Building ${target}`)
await exec(['./dist/index.cjs', '--target', target, '--output', `./bin/${target.replace('node18', 'cryptgeon')}`])
}

View File

@@ -0,0 +1,77 @@
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 { AES, 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]
const preview = await info(id).catch(() => exit('Note does not exist or is expired'))
// Password
let password: string
const derivation = preview?.meta.derivation
if (derivation) {
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 get(id)
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
}
}

98
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env node
import { Argument, Option, program } from '@commander-js/extra-typings'
import { setBase, status } from '@cryptgeon/shared'
import prettyBytes from 'pretty-bytes'
import { download } from './download.js'
import { parseFile, parseNumber } from './parsers.js'
import { getStdin } from './stdin.js'
import { upload } 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)
// Node 18 guard
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required')
// @ts-ignore
const version: string = VERSION
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(version).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)
.addOption(password)
.action(async (files, options) => {
setBase(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
})
send
.command('text')
.addArgument(text)
.addOption(server)
.addOption(views)
.addOption(minutes)
.addOption(password)
.action(async (text, options) => {
setBase(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
})
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

@@ -0,0 +1,27 @@
import { InvalidArgumentError, InvalidOptionArgumentError } from '@commander-js/extra-typings'
import { accessSync, constants } from 'node:fs'
import { resolve } from 'node:path'
export function parseFile(value: string, before: string[] = []) {
try {
const file = resolve(value)
accessSync(file, constants.R_OK)
return [...before, file]
} catch {
throw new InvalidArgumentError('cannot access file')
}
}
export function parseURL(value: string, _: URL): URL {
try {
return new URL(value)
} catch {
throw new InvalidArgumentError('is not a valid url')
}
}
export function parseNumber(value: string, _: number): number {
const n = parseInt(value, 10)
if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
return n
}

20
packages/cli/src/stdin.ts Normal file
View File

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

View File

@@ -0,0 +1,51 @@
import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Adapters, BASE, create, FileDTO, Note, NoteMeta } from '@cryptgeon/shared'
import mime from 'mime'
import { AES, Hex, TypedArray } from 'occulto'
import { exit } from './utils.js'
type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
export async function upload(input: string | string[], options: UploadOptions) {
try {
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 create(note)
let url = `${BASE}/note/${result.id}`
if (!derived) url += `#${Hex.encode(key)}`
console.log(`Note created:\n\n${url}`)
} catch {
exit('Could not create note')
}
}

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

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "node",
"noEmit": true,
"strict": true,
"allowSyntheticDefaultImports": true
}
}

View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -15,7 +15,9 @@
"encrypting": "verschlüsseln",
"decrypting": "entschlüsselt",
"uploading": "hochladen",
"downloading": "wird heruntergeladen"
"downloading": "wird heruntergeladen",
"qr_code": "qr-code",
"password": "Passwort"
},
"home": {
"intro": "Senden Sie ganz einfach <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien mit einem Klick. Erstellen Sie einfach eine Notiz und teilen Sie den Link.",
@@ -30,6 +32,10 @@
},
"messages": {
"note_created": "notiz erstellt."
},
"advanced": {
"explanation": "Standardmäßig wird für jede Notiz ein sicher generiertes Passwort verwendet. Sie können jedoch auch ein eigenes Kennwort wählen, das nicht in dem Link enthalten ist.",
"custom_password": "benutzerdefiniertes Passwort"
}
},
"show": {

View File

@@ -15,7 +15,9 @@
"encrypting": "encrypting",
"decrypting": "decrypting",
"uploading": "uploading",
"downloading": "downloading"
"downloading": "downloading",
"qr_code": "qr code",
"password": "password"
},
"home": {
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
@@ -30,6 +32,10 @@
},
"messages": {
"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": {

View File

@@ -15,7 +15,9 @@
"encrypting": "encriptando",
"decrypting": "descifrando",
"uploading": "cargando",
"downloading": "descargando"
"downloading": "descargando",
"qr_code": "código qr",
"password": "contraseña"
},
"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.",
@@ -30,6 +32,10 @@
},
"messages": {
"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, que no se incluye en el enlace.",
"custom_password": "contraseña personalizada"
}
},
"show": {

View File

@@ -15,7 +15,9 @@
"encrypting": "cryptage",
"decrypting": "déchiffrer",
"uploading": "téléchargement",
"downloading": "téléchargement"
"downloading": "téléchargement",
"qr_code": "code qr",
"password": "mot de passe"
},
"home": {
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement cryptés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.",
@@ -30,6 +32,10 @@
},
"messages": {
"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": {

View File

@@ -15,7 +15,9 @@
"encrypting": "criptando",
"decrypting": "decifrando",
"uploading": "caricamento",
"downloading": "scaricando"
"downloading": "scaricando",
"qr_code": "codice qr",
"password": "password"
},
"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.",
@@ -30,6 +32,10 @@
},
"messages": {
"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": {

View File

@@ -0,0 +1,56 @@
{
"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": "すべてダウンロード"
},
"file_upload": {
"selected_files": "選択したファイル",
"no_files_selected": "ファイルが選択されていません"
}
}

View File

@@ -0,0 +1,56 @@
{
"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>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
"errors": {
"note_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": "скачать всё"
},
"file_upload": {
"selected_files": "Выбранные файлы",
"no_files_selected": "Файлы не выбраны"
}
}

View File

@@ -0,0 +1,56 @@
{
"common": {
"note": "密信",
"file": "上传文件",
"advanced": "高级设置",
"create": "创建",
"loading": "加载中",
"mode": "模式",
"views": "{n, plural, =0 {可读次数} =1 {1 次查看} other {# 次查看}}",
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
"max": "最大值",
"share_link": "分享链接",
"copy_clipboard": "复制到粘贴板",
"copied_to_clipboard": "已成功复制到粘贴板",
"encrypting": "加密",
"decrypting": "解密",
"uploading": "上传",
"downloading": "下载",
"qr_code": "二维码",
"password": "密码"
},
"home": {
"intro": "飞鸽传书,一键传输完全加密的密信或文件,阅后即焚。",
"explanation": "该密信会在{type}后失效。",
"new_note": "新建密信",
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
"errors": {
"note_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": "下载全部"
},
"file_upload": {
"selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中"
}
}

View File

@@ -1,50 +0,0 @@
{
"common": {
"note": "密信",
"file": "上传文件",
"advanced": "高级设置",
"create": "创建",
"loading": "加载中",
"mode": "模式",
"views": "{n, plural, =0 {可查看次数} =1 {1 次查看} other {# 次查看}}",
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
"max": "最大值",
"share_link": "分享链接",
"copy_clipboard": "复制到剪切版",
"copied_to_clipboard": "已复制到剪切板",
"encrypting": "加密",
"decrypting": "解密",
"uploading": "上传",
"downloading": "下载"
},
"home": {
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
"explanation": "该密信会在{type}后失效。",
"new_note": "新建密信",
"new_note_notice": "<b>可用性警示:</b><br />由于加密鸽的所有数据是全部保存在内存中的,所以如果加密鸽的可用内存被用光了那么它将会删除最早的密信以释放内存,因此不保证该密信的可用性。<br />(一般情况下是您应该是不会遇到这个问题,只是警示一下。)",
"errors": {
"note_to_big": "无法创建密信,这个密信太大了!",
"note_error": "无法创建密信,请再试一遍。",
"max": "最大文件大小: {n}",
"empty_content": "密信为空!"
},
"messages": {
"note_created": "注释创建。"
}
},
"show": {
"errors": {
"not_found": "该密信无法被找到或者它已经被删除了!",
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!",
"unsupported_type": "不支持的票据类型。"
},
"explanation": "点击下方的按钮可以查看密信,如果它到达了限制将会被删除",
"show_note": "查看密信",
"warning_will_not_see_again": "您将<b>无法</b>再次查看该密信",
"download_all": "下载全部"
},
"file_upload": {
"selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中"
}
}

View File

@@ -1,9 +1,11 @@
{
"private": true,
"name": "@cryptgeon/web",
"scripts": {
"postinstall": "svelte-kit sync",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview --port 3000",
"preview": "vite preview",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker --summary > licenses.csv",
"locale:download": "node scripts/locale.js",
@@ -11,27 +13,29 @@
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^9.3.0",
"@sveltejs/adapter-static": "^1.0.0",
"@sveltejs/kit": "^1.0.1",
"@types/dompurify": "^2.4.0",
"@lokalise/node-api": "^9.8.0",
"@sveltejs/adapter-static": "^2.0.2",
"@sveltejs/kit": "^1.16.3",
"@types/dompurify": "^3.0.2",
"@types/file-saver": "^2.0.5",
"@zerodevx/svelte-toast": "^0.7.2",
"@zerodevx/svelte-toast": "^0.9.3",
"adm-zip": "^0.5.10",
"dotenv": "^16.0.3",
"svelte": "^3.55.0",
"svelte-check": "^2.10.3",
"svelte-intl-precompile": "^0.10.1",
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.1",
"typescript": "^4.9.4",
"vite": "^4.0.3"
"svelte": "^3.59.1",
"svelte-check": "^3.3.2",
"svelte-intl-precompile": "^0.12.1",
"tslib": "^2.5.0",
"typescript": "^5.0.4",
"vite": "^4.3.5"
},
"dependencies": {
"@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^4.5.10",
"copy-to-clipboard": "^3.3.3",
"dompurify": "^2.4.1",
"dompurify": "^3.0.3",
"file-saver": "^2.0.5",
"pretty-bytes": "^6.0.0"
"occulto": "^2.0.1",
"pretty-bytes": "^6.1.0",
"qrious": "^4.0.2"
}
}

View File

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

View File

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

9
packages/frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}

View File

@@ -1,3 +0,0 @@
/// <reference types="@sveltejs/kit" />
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@@ -1,61 +0,0 @@
import type { EncryptedFileDTO, FileDTO } from './api'
import { Crypto } from './crypto'
abstract class CryptAdapter<T> {
abstract encrypt(plaintext: T, key: CryptoKey): Promise<string>
abstract decrypt(ciphertext: string, key: CryptoKey): Promise<T>
}
class CryptTextAdapter implements CryptAdapter<string> {
async encrypt(plaintext: string, key: CryptoKey) {
return await Crypto.encrypt(new TextEncoder().encode(plaintext), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new TextDecoder().decode(plaintext)
}
}
class CryptBlobAdapter implements CryptAdapter<Blob> {
async encrypt(plaintext: Blob, key: CryptoKey) {
return await Crypto.encrypt(await plaintext.arrayBuffer(), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new Blob([plaintext], { type: 'application/octet-stream' })
}
}
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
async encrypt(plaintext: FileDTO[], key: CryptoKey) {
const adapter = new CryptBlobAdapter()
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.encrypt(file.contents, key),
}))
return JSON.stringify(await Promise.all(data))
}
async decrypt(ciphertext: string, key: CryptoKey) {
const adapter = new CryptBlobAdapter()
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
const files: FileDTO[] = await Promise.all(
data.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.decrypt(file.contents, key),
}))
)
return files
}
}
export const Adapters = {
Text: new CryptTextAdapter(),
Blob: new CryptBlobAdapter(),
Files: new CryptFilesAdapter(),
}

View File

@@ -1,78 +0,0 @@
export type NoteMeta = { type: 'text' | 'file' }
export type Note = {
contents: string
meta: NoteMeta
views?: number
expiration?: number
}
export type NoteInfo = {}
export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: Blob
}
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string
}
type CallOptions = {
url: string
method: string
body?: any
}
export class PayloadToLargeError extends Error {}
export async function call(options: CallOptions) {
const response = await fetch('/api/' + options.url, {
method: options.method,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
if (response.status === 413) throw new PayloadToLargeError()
else throw new Error('API call failed')
}
return response.json()
}
export async function create(note: Note) {
const { meta, ...rest } = note
const body: NoteCreate = {
...rest,
meta: JSON.stringify(meta),
}
const data = await call({
url: 'notes/',
method: 'post',
body,
})
return data as { id: string }
}
export async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
})
const { contents, meta } = data
return {
contents,
meta: JSON.parse(meta) as NoteMeta,
}
}
export async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
})
return data
}

View File

@@ -1,89 +0,0 @@
export class Hex {
static encode(buffer: ArrayBuffer): string {
let s = ''
for (const i of new Uint8Array(buffer)) {
s += i.toString(16).padStart(2, '0')
}
return s
}
static decode(s: string): ArrayBuffer {
const size = s.length / 2
const buffer = new Uint8Array(size)
for (let i = 0; i < size; i++) {
const idx = i * 2
const segment = s.slice(idx, idx + 2)
buffer[i] = parseInt(segment, 16)
}
return buffer
}
}
export class ArrayBufferUtils {
static async toString(buffer: ArrayBuffer): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(new Blob([buffer]))
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
static async fromString(s: string): Promise<ArrayBuffer> {
return fetch(s)
.then((r) => r.blob())
.then((b) => b.arrayBuffer())
}
}
export class Keys {
public static async generateKey(size: 128 | 192 | 256 = 256): Promise<CryptoKey> {
const key = await window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: size,
},
true,
['encrypt', 'decrypt']
)
return key
}
public static async export(key: CryptoKey): Promise<string> {
return Hex.encode(await window.crypto.subtle.exportKey('raw', key))
}
public static async import(key: string): Promise<CryptoKey> {
return window.crypto.subtle.importKey('raw', Hex.decode(key), { name: 'AES-GCM' }, true, [
'encrypt',
'decrypt',
])
}
}
export class Crypto {
private static ALG = 'AES-GCM'
private static DELIMITER = ':::'
public static getRandomBytes(size: number): Uint8Array {
return window.crypto.getRandomValues(new Uint8Array(size))
}
public static async encrypt(plaintext: ArrayBuffer, key: CryptoKey): Promise<string> {
const iv = this.getRandomBytes(12) // AES-GCM needs a 96bit IV
const encrypted: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: this.ALG, iv },
key,
plaintext
)
const data = [Hex.encode(iv), await ArrayBufferUtils.toString(encrypted)].join(this.DELIMITER)
return data
}
public static async decrypt(ciphertext: string, key: CryptoKey): Promise<ArrayBuffer> {
const splitted = ciphertext.split(this.DELIMITER)
const iv = Hex.decode(splitted[0])
const encrypted = await ArrayBufferUtils.fromString(splitted[1])
const plaintext = await window.crypto.subtle.decrypt({ name: this.ALG, iv }, key, encrypted)
return plaintext
}
}

View File

@@ -1,24 +1,8 @@
import { call } from '$lib/api'
import { status as getStatus, type Status } from '@cryptgeon/shared'
import { writable } from 'svelte/store'
export type Status = {
version: string
max_size: number
max_views: number
max_expiration: number
allow_advanced: boolean
theme_image: string
theme_text: string
theme_favicon: string
theme_page_title: string
}
export const status = writable<null | Status>(null)
export async function init() {
const data = await call({
url: 'status/',
method: 'get',
})
status.set(data)
status.set(await getStatus())
}

View File

@@ -1,16 +1,21 @@
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import type { Note } from '$lib/api'
import { status } from '$lib/stores/status'
import Switch from '$lib/ui/Switch.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import type { Note } from '@cryptgeon/shared'
export let note: Note
export let timeExpiration = false
let customPassword = false
$: if (!customPassword) note.password = undefined
</script>
<div class="fields">
<div class="flex col">
<div class="flex">
<TextInput
data-testid="field-views"
type="number"
@@ -23,14 +28,12 @@
($status && v <= $status?.max_views && v > 0) ||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
/>
<div class="middle-switch">
<Switch
data-testid="switch-advanced-toggle"
label={$t('common.mode')}
bind:value={timeExpiration}
color={false}
/>
</div>
<TextInput
data-testid="field-expiration"
type="number"
@@ -43,13 +46,31 @@
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
/>
</div>
<div class="flex">
<Switch bind:value={customPassword} label={$t('home.advanced.custom_password')} />
<TextInput
type="password"
bind:value={note.password}
label={$t('common.password')}
disabled={!customPassword}
random
/>
</div>
<div>
{$t('home.advanced.explanation')}
</div>
</div>
<style>
.middle-switch {
margin: 0 1rem;
.flex {
display: flex;
align-items: flex-end;
gap: 1rem;
width: 100%;
}
.fields {
display: flex;
.col {
gap: 1.5rem;
flex-direction: column;
}
</style>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
// @ts-ignore
import QR from 'qrious'
import { t } from 'svelte-intl-precompile'
import { getCSSVariable } from '$lib/utils'
export let value: string
let canvas: HTMLCanvasElement
$: {
new QR({
value,
level: 'Q',
size: 800,
background: getCSSVariable('--ui-bg-0'),
foreground: getCSSVariable('--ui-text-0'),
element: canvas,
})
}
</script>
<small>{$t('common.qr_code')}</small>
<div>
<canvas bind:this={canvas} />
</div>
<style>
div {
padding: 0.5rem;
width: fit-content;
border: 2px solid var(--ui-bg-1);
background-color: var(--ui-bg-0);
margin-top: 0.125rem;
}
canvas {
width: 100%;
height: auto;
}
</style>

View File

@@ -1,26 +1,27 @@
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import type { FileDTO } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from '@cryptgeon/shared'
export let label: string = ''
export let files: FileDTO[] = []
function fileToDTO(file: File): FileDTO {
async function fileToDTO(file: File): Promise<FileDTO> {
return {
name: file.name,
size: file.size,
type: file.type,
contents: file,
contents: new Uint8Array(await file.arrayBuffer()),
}
}
async function onInput(e: Event) {
const input = e.target as HTMLInputElement
if (input?.files?.length) {
files = [...files, ...Array.from(input.files).map(fileToDTO)]
const toAdd = await Promise.all(Array.from(input.files).map(fileToDTO))
files = [...files, ...toAdd]
}
}

View File

@@ -1,7 +1,7 @@
<script lang="ts" context="module">
export type NoteResult = {
password: string
id: string
password?: string
}
</script>
@@ -10,9 +10,13 @@
import Button from '$lib/ui/Button.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import Canvas from './Canvas.svelte'
export let result: NoteResult
let url = `${window.location.origin}/note/${result.id}`
if (result.password) url += `#${result.password}`
function reset() {
window.location.reload()
}
@@ -22,11 +26,15 @@
type="text"
readonly
label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}"
value={url}
copy
data-testid="share-link"
/>
<br />
<div>
<Canvas value={url} />
</div>
<p>
{@html $t('home.new_note_notice')}
</p>
@@ -34,4 +42,9 @@
<Button on:click={reset}>{$t('home.new_note')}</Button>
<style>
div {
width: min(12rem, 100%);
margin-top: 1rem;
margin-bottom: 1rem;
}
</style>

View File

@@ -8,9 +8,9 @@
import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile'
import type { FileDTO, NotePublic } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils'
import type { FileDTO, NotePublic } from '@cryptgeon/shared'
export let note: DecryptedNote

View File

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

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Crypto, Hex } from '$lib/crypto'
import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils'
import { getRandomBytes, Hex } from 'occulto'
export let label: string = ''
export let value: any
@@ -23,13 +23,14 @@
function toggle() {
hidden = !hidden
}
function randomFN() {
value = Hex.encode(Crypto.getRandomBytes(32))
async function randomFN() {
value = Hex.encode(await getRandomBytes(32))
}
</script>
<label>
<small disabled={$$restProps.disabled}>
<small class:disabled={$$restProps.disabled}>
{label}
{#if valid !== true}
<span class="error-text">{valid}</span>
@@ -53,6 +54,7 @@
label {
position: relative;
display: block;
width: 100%;
}
label > small {

View File

@@ -9,3 +9,8 @@ export function copy(value: string) {
const msg = get(t)('common.copied_to_clipboard')
notify.success(msg)
}
export function getCSSVariable(variable: string): string {
if (typeof window === 'undefined') return ''
return window.getComputedStyle(window.document.body).getPropertyValue(variable)
}

View File

@@ -1,11 +1,8 @@
<script lang="ts">
import { AES, Hex, Bytes } from 'occulto'
import { t } from 'svelte-intl-precompile'
import { blur } from 'svelte/transition'
import { Adapters } from '$lib/adapters'
import type { FileDTO, Note } from '$lib/api'
import { create, PayloadToLargeError } from '$lib/api'
import { Keys } from '$lib/crypto'
import { status } from '$lib/stores/status'
import { notify } from '$lib/toast'
import AdvancedParameters from '$lib/ui/AdvancedParameters.svelte'
@@ -16,6 +13,8 @@
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte'
import type { FileDTO, Note } from '@cryptgeon/shared'
import { Adapters, create, PayloadToLargeError } from '@cryptgeon/shared'
let note: Note = {
contents: '',
@@ -58,13 +57,14 @@
try {
loading = $t('common.encrypting')
const key = await Keys.generateKey()
const password = await Keys.export(key)
const derived = note.password && (await AES.derive(note.password))
const key = derived ? derived[0] : await AES.generateKey()
const data: Note = {
contents: '',
meta: note.meta,
}
if (derived) data.meta.derivation = derived[1]
if (isFile) {
if (files.length === 0) throw new EmptyContentError()
data.contents = await Adapters.Files.encrypt(files, key)
@@ -78,8 +78,8 @@
loading = $t('common.uploading')
const response = await create(data)
result = {
password: password,
id: response.id,
password: note.password ? undefined : Hex.encode(key),
}
notify.success($t('home.messages.note_created'))
} catch (e) {
@@ -149,7 +149,7 @@
{#if advanced}
<div transition:blur={{ duration: 250 }}>
<br />
<hr />
<AdvancedParameters bind:note bind:timeExpiration />
</div>
{/if}

View File

@@ -23,6 +23,7 @@
right: 0;
width: 100%;
background-color: var(--ui-bg-0-85);
backdrop-filter: blur(2px);
}
a {

View File

@@ -1,31 +1,35 @@
<script lang="ts">
import { AES, Hex } from 'occulto'
import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile'
import { Adapters } from '$lib/adapters'
import { get, info } from '$lib/api'
import { Keys } from '$lib/crypto'
import Button from '$lib/ui/Button.svelte'
import Loader from '$lib/ui/Loader.svelte'
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import { Adapters, get, info, type NoteMeta } from '@cryptgeon/shared'
import type { PageData } from './$types'
export let data: PageData
let id = data.id
let password: string
let password: string | null = null
let note: DecryptedNote | null = null
let exists = false
let meta: NoteMeta | null = null
let loading: string | null = null
let error: string | null = null
$: valid = !!password?.length
onMount(async () => {
// Check if note exists
try {
loading = $t('common.loading')
password = window.location.hash.slice(1)
await info(id)
const note = await info(id)
meta = note.meta
exists = true
} catch {
exists = false
@@ -39,11 +43,18 @@
*/
async function show() {
try {
if (!valid) {
error = $t('show.errors.no_password')
return
}
// Load note
error = null
loading = $t('common.downloading')
const data = await get(id)
loading = $t('common.decrypting')
const key = await Keys.import(password)
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
const key = derived ? derived[0] : Hex.decode(password!)
switch (data.meta.type) {
case 'text':
note = {
@@ -78,9 +89,18 @@
<form on:submit|preventDefault={show}>
<fieldset>
<p>{$t('show.explanation')}</p>
<Button data-testid="show-note-button" type="submit">{$t('show.show_note')}</Button>
{#if meta?.derivation}
<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}
<br />
<p class="error-text">
{error}
<br />
@@ -98,4 +118,10 @@
.loader {
text-align: center;
}
fieldset {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>

View File

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

View File

@@ -1,12 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
const port = 8001
/** @type {import('vite').UserConfig} */
const config = {
clearScreen: false,
server: {
port: 3000,
},
server: { port },
preview: { port },
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"
}
}

View File

@@ -3,13 +3,14 @@ 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://localhost:5000' : 'http://localhost:3000'
proxy.web(req, res, { target })
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
proxy.web(req, res, { target, proxyTimeout: 250, timeout: 250 })
})
server.listen(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": "^5.0.4"
},
"dependencies": {
"occulto": "^2.0.1"
}
}

View File

@@ -0,0 +1,61 @@
import { AES, Bytes, type TypedArray } from 'occulto'
import type { EncryptedFileDTO, FileDTO } from './api'
abstract class CryptAdapter<T> {
abstract encrypt(plaintext: T, key: TypedArray): Promise<string>
abstract decrypt(ciphertext: string, key: TypedArray): Promise<T>
}
class CryptTextAdapter implements CryptAdapter<string> {
async encrypt(plaintext: string, key: TypedArray) {
return await AES.encrypt(Bytes.encode(plaintext), key)
}
async decrypt(ciphertext: string, key: TypedArray) {
return Bytes.decode(await AES.decrypt(ciphertext, key))
}
}
class CryptBlobAdapter implements CryptAdapter<TypedArray> {
async encrypt(plaintext: TypedArray, key: TypedArray) {
return await AES.encrypt(plaintext, key)
}
async decrypt(ciphertext: string, key: TypedArray) {
return await AES.decrypt(ciphertext, key)
// const plaintext = await AES.decrypt(ciphertext, key)
// return new Blob([plaintext], { type: 'application/octet-stream' })
}
}
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
async encrypt(plaintext: FileDTO[], key: TypedArray) {
const adapter = new CryptBlobAdapter()
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.encrypt(file.contents, key),
}))
return JSON.stringify(await Promise.all(data))
}
async decrypt(ciphertext: string, key: TypedArray) {
const adapter = new CryptBlobAdapter()
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
const files: FileDTO[] = await Promise.all(
data.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.decrypt(file.contents, key),
}))
)
return files
}
}
export const Adapters = {
Text: new CryptTextAdapter(),
Blob: new CryptBlobAdapter(),
Files: new CryptFilesAdapter(),
}

116
packages/shared/src/api.ts Normal file
View File

@@ -0,0 +1,116 @@
import type { KeyData, TypedArray } from 'occulto'
export type NoteMeta = {
type: 'text' | 'file'
derivation?: KeyData
}
export type Note = {
contents: string
meta: NoteMeta
views?: number
expiration?: number
}
export type NoteInfo = Pick<Note, 'meta'>
export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: TypedArray
}
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string
}
type CallOptions = {
url: string
method: string
body?: any
}
export class PayloadToLargeError extends Error {}
export let BASE = ''
export function setBase(url: string) {
BASE = url
}
export async function call(options: CallOptions) {
const response = await fetch(BASE + '/api/' + options.url, {
method: options.method,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
if (response.status === 413) throw new PayloadToLargeError()
else throw new Error('API call failed')
}
return response.json()
}
export async function create(note: Note) {
const { meta, ...rest } = note
const body: NoteCreate = {
...rest,
meta: JSON.stringify(meta),
}
const data = await call({
url: 'notes/',
method: 'post',
body,
})
return data as { id: string }
}
export async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
})
const { contents, meta } = data
const note = {
contents,
meta: JSON.parse(meta),
} satisfies NotePublic
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
}
export async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
})
const { meta } = 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 = {
version: string
max_size: number
max_views: number
max_expiration: number
allow_advanced: boolean
theme_image: string
theme_text: string
theme_favicon: string
theme_page_title: string
}
export async function status() {
const data = await call({
url: 'status/',
method: 'get',
})
return data as Status
}

View File

@@ -0,0 +1,2 @@
export * from './adapters.js'
export * from './api.js'

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

@@ -10,7 +10,6 @@ const config: PlaywrightTestConfig = {
outputDir: './test-results',
testDir: './test',
timeout: 60_000,
testIgnore: ['file/too-big.spec.ts'],
webServer: {
command: 'docker compose -f docker-compose.dev.yaml up',
@@ -22,11 +21,10 @@ const config: PlaywrightTestConfig = {
{ name: 'chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'safari', use: { ...devices['Desktop Safari'] } },
{
name: 'local',
use: { ...devices['Desktop Chrome'] },
// testMatch: 'file/too-big.spec.ts',
},
{ name: 'cli', use: { ...devices['Desktop Chrome'] }, grep: [/@cli/] },
{ name: 'web', use: { ...devices['Desktop Chrome'] }, grep: [/@web/] },
{ name: 'cross', use: { ...devices['Desktop Chrome'] }, grep: [/@cross/] },
],
}

2338
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
import { test } from '@playwright/test'
import { CLI } from '../../utils'
test.describe('text @cli', () => {
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const note = await CLI('send', 'text', text)
const link = note.stdout.trim().replace(/(.|\s)*http/g, 'http')
const retrieved = await CLI('open', link)
test.expect(retrieved.stdout.trim()).toBe(text)
})
})

View File

@@ -0,0 +1,19 @@
import { test } from '@playwright/test'
import { CLI, checkLinkForText, createNote } from '../../utils'
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
test.describe('text @cross', () => {
test('cli to web', async ({ page }) => {
const note = await CLI('send', 'text', text)
const link = note.stdout.trim().replace(/(.|\s)*http/g, 'http')
await checkLinkForText(page, link, text)
})
test('web to cli', async ({ page }) => {
const link = await createNote(page, { text })
const retrieved = await CLI('open', link)
test.expect(retrieved.stdout.trim()).toBe(text)
})
})

View File

@@ -1,11 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkForDownload, createNote, getFileChecksum } from '../utils'
import Files from './files'
test('multiple', async ({ page }) => {
const files = [Files.PDF, Files.Image]
const checksums = await Promise.all(files.map(getFileChecksum))
const link = await createNote(page, { files, views: 2 })
await checkLinkForDownload(page, link, 'image.jpg', checksums[1])
await checkLinkForDownload(page, link, 'AES.pdf', checksums[0])
})

View File

@@ -1,24 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForDownload, checkLinkForText, createNote, getFileChecksum } from '../utils'
import Files from './files'
test('simple pdf', async ({ page }) => {
const files = [Files.PDF]
const link = await createNote(page, { files })
await checkLinkForText(page, link, 'AES.pdf')
await checkLinkDoesNotExist(page, link)
})
test('pdf content', async ({ page }) => {
const files = [Files.PDF]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, link, 'AES.pdf', checksum)
})
test('image content', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, link, 'image.jpg', checksum)
})

View File

@@ -1,8 +0,0 @@
import { test } from '@playwright/test'
import { createNote } from '../utils'
import Files from './files'
test('to big zip', async ({ page }) => {
const files = [Files.Zip]
const link = await createNote(page, { files, error: 'note is to big' })
})

View File

@@ -1,37 +0,0 @@
import { expect, test, type Page } from '@playwright/test'
async function createNote(page: Page, text: string): Promise<string> {
await page.goto('/')
await page.locator('textarea').click()
await page.locator('textarea').fill(text)
await page.locator('button:has-text("create")').click()
await page.locator('[data-testid="share-link"]').click()
const shareLink = await page.locator('[data-testid="share-link"]').inputValue()
return shareLink
}
async function checkLinkForText(page: Page, link: string, text: string) {
await page.goto(link)
await page.locator('[data-testid="show-note-button"]').click()
expect(await page.locator('[data-testid="result"] >> .note').innerText()).toBe(text)
}
async function checkLinkDoesNotExist(page: Page, link: string) {
await page.goto('/') // Required due to firefox: https://github.com/microsoft/playwright/issues/15781
await page.goto(link)
await expect(page.locator('main')).toContainText('note was not found or was already deleted')
}
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const shareLink = await createNote(page, text)
await checkLinkForText(page, shareLink, text)
})
test('only shown once', async ({ page }) => {
const text = `Christian victorious reason suicide dead. Right ultimate gains god hope truth burying selfish society joy. Ultimate.`
const shareLink = await createNote(page, text)
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})

View File

@@ -1,14 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../utils'
test('1 minute', async ({ page }) => {
const text = `Virtues value ascetic revaluation sea dead strong burying.`
const minutes = 1
const timeout = minutes * 60_000
test.setTimeout(timeout * 2)
const shareLink = await createNote(page, { text, expiration: minutes })
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await page.waitForTimeout(timeout)
await checkLinkDoesNotExist(page, shareLink)
})

View File

@@ -1,8 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkForText, createNote } from '../utils'
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const shareLink = await createNote(page, { text })
await checkLinkForText(page, shareLink, text)
})

View File

@@ -1,18 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../utils'
test('only shown once', async ({ page }) => {
const text = `Christian victorious reason suicide dead. Right ultimate gains god hope truth burying selfish society joy. Ultimate.`
const shareLink = await createNote(page, { text })
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})
test('view 3 times', async ({ page }) => {
const text = `Justice holiest overcome fearful strong ultimate holiest christianity.`
const shareLink = await createNote(page, { text, views: 3 })
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})

View File

@@ -1,6 +1,10 @@
import { expect, type Page } from '@playwright/test'
import { createHash } from 'crypto'
import { readFile } from 'fs/promises'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
const exec = promisify(execFile)
type CreatePage = { text?: string; files?: string[]; views?: number; expiration?: number; error?: string }
export async function createNote(page: Page, options: CreatePage): Promise<string> {
@@ -69,3 +73,12 @@ export async function getFileChecksum(file: string) {
const hash = createHash('sha3-256').update(buffer).digest('hex')
return hash
}
export async function CLI(...args: string[]) {
return await exec('./packages/cli/dist/index.cjs', args, {
env: {
...process.env,
CRYPTGEON_SERVER: 'http://localhost:1234',
},
})
}

View File

@@ -0,0 +1,13 @@
import { test } from '@playwright/test'
import { checkLinkForDownload, createNote, getFileChecksum } from '../../utils'
import Files from './files'
test.describe('@web', () => {
test('multiple', async ({ page }) => {
const files = [Files.PDF, Files.Image]
const checksums = await Promise.all(files.map(getFileChecksum))
const link = await createNote(page, { files, views: 2 })
await checkLinkForDownload(page, link, 'image.jpg', checksums[1])
await checkLinkForDownload(page, link, 'AES.pdf', checksums[0])
})
})

View File

@@ -0,0 +1,26 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForDownload, checkLinkForText, createNote, getFileChecksum } from '../../utils'
import Files from './files'
test.describe('@web', () => {
test('simple pdf', async ({ page }) => {
const files = [Files.PDF]
const link = await createNote(page, { files })
await checkLinkForText(page, link, 'AES.pdf')
await checkLinkDoesNotExist(page, link)
})
test('pdf content', async ({ page }) => {
const files = [Files.PDF]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, link, 'AES.pdf', checksum)
})
test('image content', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, link, 'image.jpg', checksum)
})
})

View File

@@ -0,0 +1,10 @@
import { test } from '@playwright/test'
import { createNote } from '../../utils'
import Files from './files'
test.describe('@web', () => {
test.skip('to big zip', async ({ page }) => {
const files = [Files.Zip]
const link = await createNote(page, { files, error: 'note is to big' })
})
})

View File

@@ -0,0 +1,16 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../../utils'
test.describe('@web', () => {
test('1 minute', async ({ page }) => {
const text = `Virtues value ascetic revaluation sea dead strong burying.`
const minutes = 1
const timeout = minutes * 60_000
test.setTimeout(timeout * 2)
const shareLink = await createNote(page, { text, expiration: minutes })
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await page.waitForTimeout(timeout)
await checkLinkDoesNotExist(page, shareLink)
})
})

View File

@@ -0,0 +1,10 @@
import { test } from '@playwright/test'
import { checkLinkForText, createNote } from '../../utils'
test.describe('@web', () => {
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const shareLink = await createNote(page, { text })
await checkLinkForText(page, shareLink, text)
})
})

View File

@@ -0,0 +1,20 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../../utils'
test.describe('@web', () => {
test('only shown once', async ({ page }) => {
const text = `Christian victorious reason suicide dead. Right ultimate gains god hope truth burying selfish society joy. Ultimate.`
const shareLink = await createNote(page, { text })
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})
test('view 3 times', async ({ page }) => {
const text = `Justice holiest overcome fearful strong ultimate holiest christianity.`
const shareLink = await createNote(page, { text, views: 3 })
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})
})

20
version.mjs Executable file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
import shelljs from 'shelljs'
import { execSync } from 'node:child_process'
const VERSION = process.argv[2]
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
const semver =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm
if (!semver.test(VERSION)) {
console.error('Invalid version number')
process.exit(1)
}
// CLI
shelljs.sed('-i', /"version": ".*"/, `"version": "${process.argv[2]}"`, './packages/cli/package.json')
// Backend
shelljs.sed('-i', /^version = ".*"$/m, `version = "${process.argv[2]}"`, './packages/backend/Cargo.toml')
execSync('cargo check -p cryptgeon', { cwd: './packages/backend' })