Compare commits

...

10 Commits

Author SHA1 Message Date
cacb808117 restructuring (#56)
* restructuring

* pin svelte kit version & parallel execution

* update svelte kit

* correct test result assets

* add timeout

* correct locale path

* simplify crypto

* fix for #58

* add verbosity flag

* disable flaky test
2022-10-07 21:28:25 +02:00
2d573edcac change link 2022-09-12 14:24:05 +02:00
4287cd429d security reporting 2022-09-10 13:13:09 +02:00
024dfeeeb7 add url spec 2022-07-26 23:48:53 +02:00
f24bcba20b remove ununsed 2022-07-26 15:49:12 +02:00
1d95edc455 readme 2022-07-26 15:49:06 +02:00
hash070
ec24ab3edd Update CN README translate (#47) 2022-07-21 11:32:38 +02:00
a552e4d766 Toasts (#45)
* locales

* add toasts and update deps

* changelog

* lock file
2022-07-20 10:41:37 +02:00
c3b1772728 Testing (#44)
* testing

* try playwrigth

* testing

* add pr support

* not on each commit

* add test ids

* make backend more configuratble

* 2.0.2

* spec
2022-07-19 21:55:05 +02:00
786878a3e4 Testing (#41)
* testing

* try playwrigth

* testing

* add pr support

* not on each commit
2022-07-19 14:12:51 +02:00
99 changed files with 2294 additions and 1822 deletions

1
.gitattributes vendored
View File

@@ -1 +1,2 @@
*.afdesign filter=lfs diff=lfs merge=lfs -text
test/assets/** filter=lfs diff=lfs merge=lfs -text

36
.github/workflows/test.yaml vendored Normal file
View File

@@ -0,0 +1,36 @@
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "16"
- uses: pnpm/action-setup@v2
with:
version: 7
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.61
- name: Prepare
run: |
pnpm install
pnpm run test:prepare
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run your tests
run: pnpm run test:run
- name: Upload test results
if: always()
uses: actions/upload-artifact@v2
with:
name: test-results
path: test-results

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ node_modules
/build
/functions
.env
General
test-results

View File

@@ -1,6 +1,6 @@
{
"cSpell.words": ["ciphertext", "cryptgeon"],
"i18n-ally.localesPaths": ["frontend/locales"],
"i18n-ally.localesPaths": ["packages/frontend/locales"],
"i18n-ally.enabledFrameworks": ["svelte"],
"i18n-ally.keystyle": "nested"
}

View File

@@ -5,6 +5,24 @@ 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.0.3] - 2022-10-07
### Added
- Flag for verbosity.
### Fixed
- #58 Fixed bug in the max views frontend form.
## [2.0.2] - 2022-07-20
### Added
- Toasts for events.
- E2E Tests.
- Make backend more configurable
## [2.0.1] - 2022-07-18
### Added

View File

@@ -2,7 +2,7 @@
FROM node:16-alpine as client
WORKDIR /tmp
RUN npm install -g pnpm@7
COPY ./frontend ./
COPY ./packages/frontend ./
RUN pnpm install
RUN pnpm exec svelte-kit sync
RUN pnpm run build
@@ -12,7 +12,9 @@ RUN pnpm run build
FROM rust:1.61-alpine as backend
WORKDIR /tmp
RUN apk add libc-dev openssl-dev alpine-sdk
COPY ./backend ./
COPY ./packages/backend/Cargo.* ./
RUN cargo fetch
COPY ./packages/backend ./
RUN cargo build --release
@@ -20,7 +22,8 @@ RUN cargo build --release
FROM alpine
WORKDIR /app
COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/build ./frontend/build
ENV REDIS=redis://redis/
COPY --from=client /tmp/build ./frontend
ENV FRONTEND_PATH="./frontend"
ENV REDIS="redis://redis/"
EXPOSE 5000
ENTRYPOINT [ "/app/cryptgeon" ]

View File

@@ -26,7 +26,7 @@ _cryptgeon_ is a secure, open source sharing note or file service inspired by [_
## Demo
Check out the demo and see for yourself https://cryptgeon.nicco.io.
Check out the demo and see for yourself [cryptgeon.org](https://cryptgeon.org)
## Features
@@ -52,13 +52,14 @@ of the notes even if it tried to.
| Variable | Default | Description |
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. |
| `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. |
| `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| `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` |
## Deployment
@@ -93,39 +94,15 @@ See the [examples/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/exam
### Traefik 2
Assumptions:
See the [examples/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik) folder.
- External proxy docker network `proxy`
- A certificate resolver `le`
- A https entrypoint `secure`
- Domain name `example.org`
### Scratch
```yaml
version: '3.8'
See the [examples/scratch](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/scratch) folder. There you'll find a guide how to setup a server and install cryptgeon from scratch.
networks:
proxy:
external: true
### Synology
services:
redis:
image: redis:7-alpine
restart: unless-stopped
app:
image: cupcakearmy/cryptgeon:latest
restart: unless-stopped
depends_on:
- redis
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.cryptgeon.rule=Host(`example.org`)
- traefik.http.routers.cryptgeon.entrypoints=secure
- traefik.http.routers.cryptgeon.tls.certresolver=le
```
There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) you can follow.
## Development
@@ -165,6 +142,29 @@ Running `pnpm run dev` in the root folder will start the following things:
You can see the app under [localhost:1234](http://localhost:1234).
## Tests
Tests are end to end tests written with Playwright.
```sh
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.
pnpm run test:local
```
## Security
Please refer to the security section [here](./SECURITY.md).
###### Attributions
Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
- Test data:
- Text for 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 Pictures](https://unsplash.com/)
- Loading animation by [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa)
- Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>

View File

@@ -26,7 +26,7 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
## 演示示例
查看加密鸽的在线演示 demo https://cryptgeon.nicco.io.
查看加密鸽的在线演示 demo [cryptgeon.org](https://cryptgeon.org)
## 功能
@@ -49,11 +49,13 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
| 变量名称 | 默认值 | 描述 |
| ----------------- | ---------------- | --------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. |
| `REDIS` | `redis://redis/` | Redis 连接 URL。 |
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
| ` MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
## 部署
@@ -137,7 +139,7 @@ services:
pnpm install
pnpm --prefix frontend install
# Also you need cargo watch if you don't already have it installed.
# 你还需要安装CargoWatch.
# https://lib.rs/crates/cargo-watch
cargo install cargo-watch
```
@@ -146,7 +148,7 @@ cargo install cargo-watch
确保你的 Docker 正在运行
> If you are on `macOS` you might need to disable AirPlay Receiver as it uses port 5000 (So stupid...)
> 如果你用的是 `macOS` 的话你可能需要关闭 AirPlay 接收功能因为该功能需要占用 5000 端口...)
> https://developer.apple.com/forums/thread/682332
```bash
@@ -161,6 +163,25 @@ pnpm run dev
你可以通过 1234 端口进入该应用,即 [localhost:1234](http://localhost:1234).
## 测试
这些测试是用 Playwright 实现的一些端到端测试用例。
```sh
pnpm run test:prepare
docker compose up redis -d
pnpm run test:server
# 在另一个终端中:
# 使用test或者test:local script。为了更快的开发本地版本只会在一个浏览器中运行。
pnpm run test:local
```
###### Attributions
本项目所使用的图标由<a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com 的<a href="https://www.freepik.com" title="Freepik">freepik</a>制作</a>
- 测试数据:
- 测试文本 [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
- [AES Paper](https://www.cs.miami.edu/home/burt/learning/Csc688.012/rijndael/rijndael_doc_V2.pdf)
- [Unsplash Pictures](https://unsplash.com/)
- 加载动画由 [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa) 提供
- 图标由来自 <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a><a href="https://www.freepik.com" title="Freepik">freepik</a> 提供

18
SECURITY.md Normal file
View File

@@ -0,0 +1,18 @@
# Security Policy
## Supported Versions
Please ensure that you are using the latest major version available.
| Version | Supported |
| ------- | --------- |
| 2.x | ✅ |
| < 1.x | |
## Reporting a vulnerability
_cryptgeon_ has a full disclosure vulnerability policy.
Report any bug / vulnerability directly to the [issue tracker](https://github.com/cupcakearmy/cryptgeon/issues).
Please do NOT attempt to report any security vulnerability in this code privately to anybody.
> Shamefully copied of the [ring security section](https://github.com/briansmith/ring#bug-reporting).

View File

@@ -10,10 +10,11 @@ services:
- 6379:6379
app:
build: .
# build: .
image: cupcakearmy/cryptgeon
depends_on:
- redis
environment:
SIZE_LIMIT: 128 MiB
SIZE_LIMIT: 10 MiB
ports:
- 80:5000
- 1234:5000

View File

@@ -0,0 +1,36 @@
# Install Cryptgeon with Traefik
Assumptions:
- Traefik 2 installed.
- External proxy docker network `proxy`.
- A certificate resolver `le`.
- A https entrypoint `secure`.
- Domain name `example.org`.
```yaml
version: '3.8'
networks:
proxy:
external: true
services:
redis:
image: redis:7-alpine
restart: unless-stopped
app:
image: cupcakearmy/cryptgeon:latest
restart: unless-stopped
depends_on:
- redis
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.cryptgeon.rule=Host(`example.org`)
- traefik.http.routers.cryptgeon.entrypoints=secure
- traefik.http.routers.cryptgeon.tls.certresolver=le
```

1609
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,17 @@
{
"scripts": {
"dev:docker": "docker-compose up redis",
"dev:backend": "cd backend && cargo watch -x 'run --bin cryptgeon'",
"dev:front": "pnpm --prefix frontend run dev",
"dev:packages": "pnpm --parallel run dev",
"dev:proxy": "node proxy.mjs",
"dev": "run-p dev:*"
"dev": "run-p dev:*",
"test:run": "playwright test --project chrome firefox safari",
"test:local": "playwright test --project local",
"test:server": "pnpm --parallel run test:server",
"test:prepare": "pnpm --parallel run test:prepare"
},
"devDependencies": {
"@playwright/test": "^1.25.1",
"@types/node": "^16.11.57",
"http-proxy": "^1.18.1",
"npm-run-all": "^4.1.5"
}

View File

@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "cryptgeon"
version = "2.0.1"
version = "2.0.3"
dependencies = [
"actix-files",
"actix-web",

View File

@@ -1,6 +1,6 @@
[package]
name = "cryptgeon"
version = "2.0.1"
version = "2.0.3"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021"

View File

@@ -0,0 +1,10 @@
{
"name": "backend",
"private": true,
"scripts": {
"dev": "cargo watch -x 'run --bin cryptgeon'",
"build": "cargo build --release",
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:1234 cargo run",
"test:prepare": "cargo build"
}
}

View File

@@ -1,14 +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("/", "./frontend/build")
Files::new("/", config::FRONTEND_PATH.to_string())
.index_file("index.html")
.use_etag(true),
);
}
pub async fn index() -> Result<NamedFile> {
Ok(NamedFile::open("./frontend/build/index.html")?)
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
Ok(NamedFile::open(index)?)
}

View File

@@ -1,10 +1,15 @@
use byte_unit::Byte;
// General
// Internal
lazy_static! {
pub static ref VERSION: String = option_env!("CARGO_PKG_VERSION")
.unwrap_or("Unknown")
.to_string();
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());
pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string());
}
// CONFIG

View File

@@ -18,10 +18,11 @@ mod store;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or("warning"));
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
return HttpServer::new(|| {
App::new()
.wrap(Logger::new("%a \"%r\" %s %b %T"))
.wrap(Logger::new("\"%r\" %s %b %T"))
.wrap(middleware::Compress::default())
.wrap(middleware::DefaultHeaders::default())
.configure(size::init)
@@ -29,7 +30,7 @@ async fn main() -> std::io::Result<()> {
.configure(client::init)
.default_service(web::to(client::index))
})
.bind("0.0.0.0:5000")?
.bind(config::LISTEN_ADDR.to_string())?
.run()
.await;
}

View File

@@ -7,5 +7,6 @@ pub fn init(cfg: &mut web::ServiceConfig) {
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,8 +1,8 @@
├─ MIT: 12
├─ MIT: 13
├─ ISC: 2
├─ BSD-3-Clause: 1
├─ (MPL-2.0 OR Apache-2.0): 1
├─ BSD-2-Clause: 1
├─ ISC: 1
├─ 0BSD: 1
└─ Apache-2.0: 1
1 ├─ MIT: 12 ├─ MIT: 13
2 ├─ ISC: 2
3 ├─ BSD-3-Clause: 1 ├─ BSD-3-Clause: 1
4 ├─ (MPL-2.0 OR Apache-2.0): 1 ├─ (MPL-2.0 OR Apache-2.0): 1
5 ├─ BSD-2-Clause: 1 ├─ BSD-2-Clause: 1
├─ ISC: 1
6 ├─ 0BSD: 1 ├─ 0BSD: 1
7 └─ Apache-2.0: 1 └─ Apache-2.0: 1
8

View File

@@ -11,6 +11,7 @@
"max": "max",
"share_link": "Link teilen",
"copy_clipboard": "in die Zwischenablage kopieren",
"copied_to_clipboard": "in die Zwischenablage kopiert",
"encrypting": "verschlüsseln",
"decrypting": "entschlüsselt",
"uploading": "hochladen",
@@ -27,7 +28,9 @@
"max": "max: {n}",
"empty_content": "Notiz ist leer."
},
"copied_to_clipboard": "in die Zwischenablage kopiert 🔗"
"messages": {
"note_created": "notiz erstellt."
}
},
"show": {
"errors": {

View File

@@ -11,6 +11,7 @@
"max": "max",
"share_link": "share link",
"copy_clipboard": "copy to clipboard",
"copied_to_clipboard": "copied to clipboard",
"encrypting": "encrypting",
"decrypting": "decrypting",
"uploading": "uploading",
@@ -27,7 +28,9 @@
"max": "max: {n}",
"empty_content": "note is empty."
},
"copied_to_clipboard": "copied to clipboard 🔗"
"messages": {
"note_created": "note created."
}
},
"show": {
"errors": {

View File

@@ -11,6 +11,7 @@
"max": "max",
"share_link": "compartir enlace",
"copy_clipboard": "copiar al portapapeles",
"copied_to_clipboard": "copiado al portapapeles",
"encrypting": "encriptando",
"decrypting": "descifrando",
"uploading": "cargando",
@@ -27,7 +28,9 @@
"max": "max: {n}",
"empty_content": "la nota está vacía."
},
"copied_to_clipboard": "copiado al portapapeles 🔗"
"messages": {
"note_created": "nota creada."
}
},
"show": {
"errors": {

View File

@@ -11,6 +11,7 @@
"max": "max",
"share_link": "partager le lien",
"copy_clipboard": "copier dans le presse-papiers",
"copied_to_clipboard": "copié dans le presse-papiers",
"encrypting": "cryptage",
"decrypting": "déchiffrer",
"uploading": "téléchargement",
@@ -27,7 +28,9 @@
"max": "max: {n}",
"empty_content": "La note est vide."
},
"copied_to_clipboard": "copié dans le presse-papiers 🔗"
"messages": {
"note_created": "note créée."
}
},
"show": {
"errors": {

View File

@@ -11,6 +11,7 @@
"max": "max",
"share_link": "condividi link",
"copy_clipboard": "copia negli appunti",
"copied_to_clipboard": "copiato negli appunti",
"encrypting": "criptando",
"decrypting": "decifrando",
"uploading": "caricamento",
@@ -27,7 +28,9 @@
"max": "max: {n}",
"empty_content": "la nota è vuota."
},
"copied_to_clipboard": "copiato negli appunti 🔗"
"messages": {
"note_created": "nota creata."
}
},
"show": {
"errors": {

View File

@@ -11,6 +11,7 @@
"max": "最大值",
"share_link": "分享链接",
"copy_clipboard": "复制到剪切版",
"copied_to_clipboard": "已复制到剪切板",
"encrypting": "加密",
"decrypting": "解密",
"uploading": "上传",
@@ -27,7 +28,9 @@
"max": "最大文件大小: {n}",
"empty_content": "密信为空!"
},
"copied_to_clipboard": "已复制到剪切板 🔗"
"messages": {
"note_created": "注释创建。"
}
},
"show": {
"errors": {

View File

@@ -3,18 +3,20 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "svelte-kit preview",
"preview": "vite preview --port 3000",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker --summary > licenses.csv",
"locale:download": "node scripts/locale.js"
"locale:download": "node scripts/locale.js",
"test:prepare": "pnpm run build"
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^7.3.1",
"@sveltejs/adapter-static": "^1.0.0-next.37",
"@sveltejs/kit": "^1.0.0-next.377",
"@sveltejs/adapter-static": "1.0.0-next.42",
"@sveltejs/kit": "1.0.0-next.480",
"@types/dompurify": "^2.3.3",
"@types/file-saver": "^2.0.5",
"@zerodevx/svelte-toast": "^0.7.2",
"adm-zip": "^0.5.9",
"dotenv": "^16.0.1",
"svelte": "^3.49.0",
@@ -23,12 +25,12 @@
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.0",
"typescript": "^4.7.4",
"vite": "^3.0.1"
"vite": "^3.0.2"
},
"dependencies": {
"@fontsource/fira-mono": "^4.5.8",
"copy-to-clipboard": "^3.3.1",
"dompurify": "^2.3.9",
"dompurify": "^2.3.10",
"file-saver": "^2.0.5",
"pretty-bytes": "^5.6.0"
}

View File

@@ -14,7 +14,9 @@
--ui-text-0: #fefefe;
--ui-text-1: #eee;
--ui-clr-primary: hsl(186, 65%, 55%);
--ui-clr-primary-alt: hsl(186, 85%, 35%);
--ui-clr-error: hsl(357, 77%, 51%);
--ui-clr-error-alt: hsl(357, 87%, 41%);
--ui-anim: all 150ms ease;
}

View File

@@ -35,6 +35,31 @@ export class ArrayBufferUtils {
}
}
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 = ':::'
@@ -43,55 +68,22 @@ export class Crypto {
return window.crypto.getRandomValues(new Uint8Array(size))
}
public static getKeyFromString(password: string) {
return window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
}
public static async getDerivedForKey(key: CryptoKey, salt: ArrayBuffer) {
const iterations = 100_000
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-512',
},
key,
{ name: this.ALG, length: 256 },
true,
['encrypt', 'decrypt']
)
}
public static async encrypt(plaintext: ArrayBuffer, key: CryptoKey): Promise<string> {
const salt = this.getRandomBytes(16)
const derived = await this.getDerivedForKey(key, salt)
const iv = this.getRandomBytes(16)
const iv = this.getRandomBytes(12) // AES-GCM needs a 96bit IV
const encrypted: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: this.ALG, iv },
derived,
key,
plaintext
)
const data = [
Hex.encode(salt),
Hex.encode(iv),
await ArrayBufferUtils.toString(encrypted),
].join(this.DELIMITER)
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 salt = Hex.decode(splitted[0])
const iv = Hex.decode(splitted[1])
const encrypted = await ArrayBufferUtils.fromString(splitted[2])
const derived = await this.getDerivedForKey(key, salt)
const plaintext = await window.crypto.subtle.decrypt({ name: this.ALG, iv }, derived, encrypted)
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

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 325 B

View File

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 736 B

View File

Before

Width:  |  Height:  |  Size: 483 B

After

Width:  |  Height:  |  Size: 483 B

View File

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 732 B

View File

@@ -0,0 +1,37 @@
import { toast, type SvelteToastOptions } from '@zerodevx/svelte-toast'
export enum NotifyType {
Success = 'success',
Error = 'error',
}
const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
[NotifyType.Success]: {
'--toastBackground': 'var(--ui-clr-primary)',
'--toastBarBackground': 'var(--ui-clr-primary-alt)',
},
[NotifyType.Error]: {
'--toastBackground': 'var(--ui-clr-error)',
'--toastBarBackground': 'var(--ui-clr-error-alt)',
},
}
function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
const options: SvelteToastOptions = {
duration: 5_000,
theme: {
...themeMapping[type],
'--toastBarHeight': '0.25rem',
'--toastMinHeight': 'auto',
'--toastMsgPadding': '0.5rem',
'--toastBorderRadius': '0',
},
}
toast.push(message, options)
}
export const notify = {
success: (message: string) => notifyFN(message, NotifyType.Success),
error: (message: string) => notifyFN(message, NotifyType.Error),
}

View File

@@ -12,19 +12,26 @@
<div class="fields">
<TextInput
data-testid="field-views"
type="number"
label={$t('common.views', { values: { n: 0 } })}
bind:value={note.views}
disabled={timeExpiration}
max={$status?.max_views}
validate={(v) =>
($status && v < $status?.max_views) ||
($status && v <= $status?.max_views) ||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
/>
<div class="middle-switch">
<Switch label={$t('common.mode')} bind:value={timeExpiration} color={false} />
<Switch
data-testid="switch-advanced-toggle"
label={$t('common.mode')}
bind:value={timeExpiration}
color={false}
/>
</div>
<TextInput
data-testid="field-expiration"
type="number"
label={$t('common.minutes', { values: { n: 0 } })}
bind:value={note.expiration}

View File

@@ -34,7 +34,7 @@
<small>
{label}
</small>
<input type="file" on:change={onInput} multiple />
<input {...$$restProps} type="file" on:change={onInput} multiple />
<div class="box">
{#if files.length}
<div>

View File

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 784 B

View File

@@ -24,6 +24,7 @@
label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}"
copy
data-testid="share-link"
/>
<br />
<p>

View File

@@ -3,7 +3,6 @@
</script>
<script lang="ts">
import copy from 'copy-to-clipboard'
import DOMPurify from 'dompurify'
import { saveAs } from 'file-saver'
import prettyBytes from 'pretty-bytes'
@@ -11,6 +10,7 @@
import type { FileDTO, NotePublic } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils'
export let note: DecryptedNote
@@ -44,20 +44,22 @@
</script>
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
{#if note.meta.type === 'text'}
<div class="note">
{@html contentWithLinks(note.contents)}
</div>
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
{:else}
{#each files as file}
<div class="note file">
<b on:click={() => downloadFile(file)}> {file.name}</b>
<small> {file.type} {prettyBytes(file.size)}</small>
<div data-testid="result">
{#if note.meta.type === 'text'}
<div class="note">
{@html contentWithLinks(note.contents)}
</div>
{/each}
<Button on:click={download}>{$t('show.download_all')}</Button>
{/if}
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
{:else}
{#each files as file}
<div class="note file">
<b on:click={() => downloadFile(file)}> {file.name}</b>
<small> {file.type} {prettyBytes(file.size)}</small>
</div>
{/each}
<Button on:click={download}>{$t('show.download_all')}</Button>
{/if}
</div>
<style>
.note {

View File

@@ -1,10 +1,7 @@
<script lang="ts">
import copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile'
import { fade } from 'svelte/transition'
import { Crypto, Hex } from '$lib/crypto'
import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils'
export let label: string = ''
export let value: any
@@ -15,8 +12,6 @@
const initialType = $$restProps.type
const isPassword = initialType === 'password'
let hidden = true
let notification: string | null = null
let notificationTimeout: NodeJS.Timeout | null = null
$: valid = validate(value)
@@ -28,22 +23,8 @@
function toggle() {
hidden = !hidden
}
function copyFN() {
copyToClipboard(value.toString())
notify($t('home.copied_to_clipboard'))
}
function randomFN() {
value = Hex.encode(Crypto.getRandomBytes(20))
}
function notify(msg: string, delay: number = 2000) {
if (notificationTimeout) {
clearTimeout(notificationTimeout)
}
notificationTimeout = setTimeout(() => {
notification = null
}, delay)
notification = msg
value = Hex.encode(Crypto.getRandomBytes(32))
}
</script>
@@ -63,12 +44,9 @@
<Icon class="icon" icon="dice" on:click={randomFN} />
{/if}
{#if copy}
<Icon class="icon" icon="copy" on:click={copyFN} />
<Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} />
{/if}
</div>
{#if notification}
<div class="notification" transition:fade><small>{notification}</small></div>
{/if}
</label>
<style>
@@ -118,11 +96,4 @@
.icons > :global(.icon:hover) {
border-color: var(--ui-clr-primary);
}
.notification {
text-align: right;
position: absolute;
right: 0;
bottom: -1.5em;
}
</style>

View File

@@ -0,0 +1,11 @@
import copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile'
import { get } from 'svelte/store'
import { notify } from './toast'
export function copy(value: string) {
copyToClipboard(value)
const msg = get(t)('common.copied_to_clipboard')
notify.success(msg)
}

View File

@@ -5,8 +5,9 @@
import { Adapters } from '$lib/adapters'
import type { FileDTO, Note } from '$lib/api'
import { create, PayloadToLargeError } from '$lib/api'
import { Crypto, Hex } from '$lib/crypto'
import { Keys } from '$lib/crypto'
import { status } from '$lib/stores/status'
import { notify } from '$lib/toast'
import AdvancedParameters from '$lib/ui/AdvancedParameters.svelte'
import Button from '$lib/ui/Button.svelte'
import FileUpload from '$lib/ui/FileUpload.svelte'
@@ -29,7 +30,6 @@
let timeExpiration = false
let description = ''
let loading: string | null = null
let error: string | null = null
$: if (!advanced) {
note.views = 1
@@ -56,11 +56,10 @@
async function submit() {
try {
error = null
loading = $t('common.encrypting')
const password = Hex.encode(Crypto.getRandomBytes(32))
const key = await Crypto.getKeyFromString(password)
const key = await Keys.generateKey()
const password = await Keys.export(key)
const data: Note = {
contents: '',
@@ -82,14 +81,15 @@
password: password,
id: response.id,
}
notify.success($t('home.messages.note_created'))
} catch (e) {
if (e instanceof PayloadToLargeError) {
error = $t('home.errors.note_to_big')
notify.error($t('home.errors.note_to_big'))
} else if (e instanceof EmptyContentError) {
error = $t('home.errors.empty_content')
notify.error($t('home.errors.empty_content'))
} else {
console.error(e)
error = $t('home.errors.note_error')
notify.error($t('home.errors.note_error'))
}
} finally {
loading = null
@@ -106,15 +106,29 @@
<form on:submit|preventDefault={submit}>
<fieldset disabled={loading !== null}>
{#if isFile}
<FileUpload label={$t('common.file')} bind:files />
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
{:else}
<TextArea label={$t('common.note')} bind:value={note.contents} placeholder="..." />
<TextArea
data-testid="text-field"
label={$t('common.note')}
bind:value={note.contents}
placeholder="..."
/>
{/if}
<div class="bottom">
<Switch class="file" label={$t('common.file')} bind:value={isFile} />
<Switch
data-testid="switch-file"
class="file"
label={$t('common.file')}
bind:value={isFile}
/>
{#if $status?.allow_advanced}
<Switch label={$t('common.advanced')} bind:value={advanced} />
<Switch
data-testid="switch-advanced"
label={$t('common.advanced')}
bind:value={advanced}
/>
{/if}
<div class="grow" />
<div class="tr">
@@ -124,10 +138,6 @@
</div>
</div>
{#if error}
<div class="error-text">{error}</div>
{/if}
<p>
<br />
{#if loading}
@@ -161,8 +171,4 @@
.grow {
flex: 1;
}
.error-text {
margin-top: 0.5rem;
}
</style>

View File

@@ -1,17 +1,13 @@
<script lang="ts" context="module">
import { getLocaleFromNavigator, init, waitLocale } from 'svelte-intl-precompile'
// @ts-ignore
import { registerAll } from '$locales'
registerAll()
init({ initialLocale: getLocaleFromNavigator() ?? undefined, fallbackLocale: 'en' })
</script>
<script lang="ts">
import { SvelteToast } from '@zerodevx/svelte-toast'
import { onMount } from 'svelte'
import { waitLocale } from 'svelte-intl-precompile'
import '../app.css'
import { init as initStores } from '$lib/stores/status'
import Footer from '$lib/views/Footer.svelte'
import Header from '$lib/views/Header.svelte'
import { onMount } from 'svelte'
import '../app.css'
onMount(() => {
initStores()
@@ -28,6 +24,8 @@
<slot />
</main>
<SvelteToast />
<Footer />
{/await}

View File

@@ -0,0 +1,5 @@
import { getLocaleFromNavigator, init } from 'svelte-intl-precompile'
// @ts-ignore
import { registerAll } from '$locales'
registerAll()
init({ initialLocale: getLocaleFromNavigator() ?? undefined, fallbackLocale: 'en' })

View File

@@ -1,10 +1,6 @@
<script context="module">
import { browser, dev } from '$app/env'
<script lang="ts">
import { status } from '$lib/stores/status'
import AboutParagraph from '$lib/ui/AboutParagraph.svelte'
export const hydrate = dev
export const router = browser
</script>
<svelte:head>

View File

@@ -1,26 +1,18 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit'
export const load: Load = async ({ params }) => {
return {
props: params,
}
}
</script>
<script lang="ts">
import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile'
import { Adapters } from '$lib/adapters'
import { get, info } from '$lib/api'
import { Crypto } from '$lib/crypto'
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 type { PageData } from './$types'
export let id: string
export let data: PageData
let id = data.id
let password: string
let note: DecryptedNote | null = null
let exists = false
@@ -51,7 +43,7 @@
loading = $t('common.downloading')
const data = await get(id)
loading = $t('common.decrypting')
const key = await Crypto.getKeyFromString(password)
const key = await Keys.import(password)
switch (data.meta.type) {
case 'text':
note = {
@@ -86,7 +78,7 @@
<form on:submit|preventDefault={show}>
<fieldset>
<p>{$t('show.explanation')}</p>
<Button type="submit">{$t('show.show_note')}</Button>
<Button data-testid="show-note-button" type="submit">{$t('show.show_note')}</Button>
{#if error}
<br />
<p class="error-text">

View File

@@ -0,0 +1,5 @@
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params }) => {
return params
}

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -3,6 +3,7 @@ import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
/** @type {import('vite').UserConfig} */
const config = {
clearScreen: false,
server: {
port: 3000,
},

33
playwright.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { devices, type PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
use: {
video: 'retain-on-failure',
baseURL: 'http://localhost:1234',
actionTimeout: 60_000,
},
outputDir: './test-results',
testDir: './test',
timeout: 60_000,
testIgnore: ['file/too-big.spec.ts'],
webServer: {
command: 'pnpm run test:server',
port: 1234,
reuseExistingServer: true,
},
projects: [
{ 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',
},
],
}
export default config

1645
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- "packages/**"

BIN
test/assets/AES.pdf (Stored with Git LFS) Normal file

Binary file not shown.

BIN
test/assets/Pigeons.zip (Stored with Git LFS) Normal file

Binary file not shown.

BIN
test/assets/alfred-kenneally-UIu4RmMxnHU-unsplash.jpg (Stored with Git LFS) Normal file

Binary file not shown.

5
test/file/files.ts Normal file
View File

@@ -0,0 +1,5 @@
export default {
PDF: 'test/assets/AES.pdf',
Image: 'test/assets/alfred-kenneally-UIu4RmMxnHU-unsplash.jpg',
Zip: 'test/assets/Pigeons.zip',
}

View File

@@ -0,0 +1,11 @@
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, 'alfred-kenneally', checksums[1])
await checkLinkForDownload(page, link, 'AES.pdf', checksums[0])
})

24
test/file/simple.spec.ts Normal file
View File

@@ -0,0 +1,24 @@
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, 'alfred-kenneally', checksum)
})

View File

@@ -0,0 +1,8 @@
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' })
})

37
test/text.ts Normal file
View File

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,14 @@
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)
})

8
test/text/simple.spec.ts Normal file
View File

@@ -0,0 +1,8 @@
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)
})

18
test/text/views.spec.ts Normal file
View File

@@ -0,0 +1,18 @@
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)
})

71
test/utils.ts Normal file
View File

@@ -0,0 +1,71 @@
import { expect, type Page } from '@playwright/test'
import { createHash } from 'crypto'
import { readFile } from 'fs/promises'
type CreatePage = { text?: string; files?: string[]; views?: number; expiration?: number; error?: string }
export async function createNote(page: Page, options: CreatePage): Promise<string> {
await page.goto('/')
if (options.text) {
await page.locator('[data-testid="text-field"]').fill(options.text)
} else if (options.files) {
await page.locator('[data-testid="switch-file"]').click()
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('text=No Files Selected').click(),
])
await fileChooser.setFiles(options.files)
}
if (options.views) {
await page.locator('[data-testid="switch-advanced"]').click()
await page.locator('[data-testid="field-views"]').fill(options.views.toString())
} else if (options.expiration) {
await page.locator('[data-testid="switch-advanced"]').click()
await page.locator('[data-testid="switch-advanced-toggle"]').click()
await page.locator('[data-testid="field-expiration"]').fill(options.expiration.toString())
}
await page.locator('button:has-text("create")').click()
if (options.error) {
await expect(page.locator('.error-text')).toContainText(options.error, { timeout: 60_000 })
}
const shareLink = await page.locator('[data-testid="share-link"]').inputValue()
return shareLink
}
export async function checkLinkForDownload(page: Page, link: string, text: string, checksum: string) {
await page.goto('/')
await page.goto(link)
await page.locator('[data-testid="show-note-button"]').click()
const [download] = await Promise.all([
page.waitForEvent('download'),
page.locator(`[data-testid="result"] >> text=${text}`).click(),
])
const path = await download.path()
if (!path) throw new Error('Download failed')
const cs = await getFileChecksum(path)
await expect(cs).toBe(checksum)
}
export async function checkLinkForText(page: Page, link: string, text: string) {
await page.goto('/')
await page.goto(link)
await page.locator('[data-testid="show-note-button"]').click()
await expect(await page.locator('[data-testid="result"] >> .note').innerText()).toContain(text)
}
export 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')
}
export async function getFileChecksum(file: string) {
const buffer = await readFile(file)
const hash = createHash('sha3-256').update(buffer).digest('hex')
return hash
}