Compare commits
12 Commits
v2.0.0-rc.
...
v2.0.3
Author | SHA1 | Date | |
---|---|---|---|
cacb808117 | |||
2d573edcac | |||
4287cd429d | |||
024dfeeeb7 | |||
f24bcba20b | |||
1d95edc455 | |||
|
ec24ab3edd | ||
a552e4d766 | |||
c3b1772728 | |||
786878a3e4 | |||
a5d98b76bd | |||
9590c9b567 |
1
.gitattributes
vendored
@@ -1 +1,2 @@
|
|||||||
*.afdesign filter=lfs diff=lfs merge=lfs -text
|
*.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
@@ -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
@@ -9,3 +9,6 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/functions
|
/functions
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
General
|
||||||
|
test-results
|
||||||
|
2
.vscode/settings.json
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"cSpell.words": ["ciphertext", "cryptgeon"],
|
"cSpell.words": ["ciphertext", "cryptgeon"],
|
||||||
"i18n-ally.localesPaths": ["frontend/locales"],
|
"i18n-ally.localesPaths": ["packages/frontend/locales"],
|
||||||
"i18n-ally.enabledFrameworks": ["svelte"],
|
"i18n-ally.enabledFrameworks": ["svelte"],
|
||||||
"i18n-ally.keystyle": "nested"
|
"i18n-ally.keystyle": "nested"
|
||||||
}
|
}
|
||||||
|
48
CHANGELOG.md
@@ -5,16 +5,58 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.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
|
||||||
|
|
||||||
|
- Max file size on the client now.
|
||||||
|
- Loading information.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed encoding from hex to base64.
|
||||||
|
- Chinese language code.
|
||||||
|
- Notable speed improvements for big files.
|
||||||
|
|
||||||
|
## [2.0.0] - 2022-07-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Theming for logo and description text.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved to redis.
|
||||||
|
- New html sanitizing library.
|
||||||
|
|
||||||
## [2.0.0-rc.0] - 2022-07-15
|
## [2.0.0-rc.0] - 2022-07-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Theming for logo and description text
|
- Theming for logo and description text.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Moved to redis
|
- Moved to redis.
|
||||||
- New html sanitizing library
|
- New html sanitizing library.
|
||||||
|
|
||||||
## [1.5.3] - 2022-06-07
|
## [1.5.3] - 2022-06-07
|
||||||
|
|
||||||
|
12
Dockerfile
@@ -2,8 +2,9 @@
|
|||||||
FROM node:16-alpine as client
|
FROM node:16-alpine as client
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
RUN npm install -g pnpm@7
|
RUN npm install -g pnpm@7
|
||||||
COPY ./frontend ./
|
COPY ./packages/frontend ./
|
||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
|
RUN pnpm exec svelte-kit sync
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
|
|
||||||
@@ -11,7 +12,9 @@ RUN pnpm run build
|
|||||||
FROM rust:1.61-alpine as backend
|
FROM rust:1.61-alpine as backend
|
||||||
WORKDIR /tmp
|
WORKDIR /tmp
|
||||||
RUN apk add libc-dev openssl-dev alpine-sdk
|
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
|
RUN cargo build --release
|
||||||
|
|
||||||
|
|
||||||
@@ -19,7 +22,8 @@ RUN cargo build --release
|
|||||||
FROM alpine
|
FROM alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend /tmp/target/release/cryptgeon .
|
COPY --from=backend /tmp/target/release/cryptgeon .
|
||||||
COPY --from=client /tmp/build ./frontend/build
|
COPY --from=client /tmp/build ./frontend
|
||||||
ENV REDIS=redis://redis/
|
ENV FRONTEND_PATH="./frontend"
|
||||||
|
ENV REDIS="redis://redis/"
|
||||||
EXPOSE 5000
|
EXPOSE 5000
|
||||||
ENTRYPOINT [ "/app/cryptgeon" ]
|
ENTRYPOINT [ "/app/cryptgeon" ]
|
||||||
|
82
README.md
@@ -26,7 +26,7 @@ _cryptgeon_ is a secure, open source sharing note or file service inspired by [_
|
|||||||
|
|
||||||
## Demo
|
## 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
|
## Features
|
||||||
|
|
||||||
@@ -50,15 +50,16 @@ of the notes even if it tried to.
|
|||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| 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/). `512 MiB` is the maximum allowed |
|
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
|
||||||
| `MAX_VIEWS` | `100` | Maximal number of views. |
|
| `MAX_VIEWS` | `100` | Maximal number of views. |
|
||||||
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
|
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
|
||||||
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
|
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
|
||||||
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
||||||
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
||||||
|
| `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
|
## Deployment
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
environment:
|
environment:
|
||||||
SIZE_LIMIT: 4M
|
SIZE_LIMIT: 4 MiB
|
||||||
ports:
|
ports:
|
||||||
- 80:5000
|
- 80:5000
|
||||||
```
|
```
|
||||||
@@ -93,39 +94,15 @@ See the [examples/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/exam
|
|||||||
|
|
||||||
### Traefik 2
|
### Traefik 2
|
||||||
|
|
||||||
Assumptions:
|
See the [examples/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik) folder.
|
||||||
|
|
||||||
- External proxy docker network `proxy`
|
### Scratch
|
||||||
- A certificate resolver `le`
|
|
||||||
- A https entrypoint `secure`
|
|
||||||
- Domain name `example.org`
|
|
||||||
|
|
||||||
```yaml
|
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.
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
networks:
|
### Synology
|
||||||
proxy:
|
|
||||||
external: true
|
|
||||||
|
|
||||||
services:
|
There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) you can follow.
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development
|
## 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).
|
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
|
###### 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>
|
||||||
|
@@ -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/) |
|
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
|
||||||
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
|
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
|
||||||
| ` MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
|
| ` MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
|
||||||
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
|
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
|
||||||
|
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
|
||||||
|
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
environment:
|
environment:
|
||||||
SIZE_LIMIT: 4M
|
SIZE_LIMIT: 4 MiB
|
||||||
ports:
|
ports:
|
||||||
- 80:5000
|
- 80:5000
|
||||||
```
|
```
|
||||||
@@ -137,7 +139,7 @@ services:
|
|||||||
pnpm install
|
pnpm install
|
||||||
pnpm --prefix frontend 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
|
# https://lib.rs/crates/cargo-watch
|
||||||
cargo install cargo-watch
|
cargo install cargo-watch
|
||||||
```
|
```
|
||||||
@@ -146,7 +148,7 @@ cargo install cargo-watch
|
|||||||
|
|
||||||
确保你的 Docker 正在运行
|
确保你的 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
|
> https://developer.apple.com/forums/thread/682332
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -161,6 +163,25 @@ pnpm run dev
|
|||||||
|
|
||||||
你可以通过 1234 端口进入该应用,即 [localhost:1234](http://localhost:1234).
|
你可以通过 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
|
###### 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
@@ -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).
|
@@ -1,18 +0,0 @@
|
|||||||
use actix_web::web;
|
|
||||||
use byte_unit::Byte;
|
|
||||||
use mime;
|
|
||||||
|
|
||||||
lazy_static! {
|
|
||||||
pub static ref LIMIT: usize =
|
|
||||||
Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string()))
|
|
||||||
.unwrap()
|
|
||||||
.get_bytes() as usize;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn init(cfg: &mut web::ServiceConfig) {
|
|
||||||
let json = web::JsonConfig::default().limit(*LIMIT);
|
|
||||||
let plain = web::PayloadConfig::default()
|
|
||||||
.limit(*LIMIT)
|
|
||||||
.mimetype(mime::STAR_STAR);
|
|
||||||
cfg.app_data(json).app_data(plain);
|
|
||||||
}
|
|
@@ -10,10 +10,11 @@ services:
|
|||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
app:
|
app:
|
||||||
build: .
|
# build: .
|
||||||
|
image: cupcakearmy/cryptgeon
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
environment:
|
environment:
|
||||||
SIZE_LIMIT: 128M
|
SIZE_LIMIT: 10 MiB
|
||||||
ports:
|
ports:
|
||||||
- 80:5000
|
- 1234:5000
|
||||||
|
36
examples/traefik/README.md
Normal 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
@@ -1,71 +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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALG = 'AES-GCM'
|
|
||||||
|
|
||||||
export function getRandomBytes(size = 16): Uint8Array {
|
|
||||||
return window.crypto.getRandomValues(new Uint8Array(size))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getKeyFromString(password: string) {
|
|
||||||
return window.crypto.subtle.importKey(
|
|
||||||
'raw',
|
|
||||||
new TextEncoder().encode(password),
|
|
||||||
'PBKDF2',
|
|
||||||
false,
|
|
||||||
['deriveBits', 'deriveKey']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDerivedForKey(key: CryptoKey, salt: ArrayBuffer) {
|
|
||||||
const iterations = 100_000
|
|
||||||
return window.crypto.subtle.deriveKey(
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt,
|
|
||||||
iterations,
|
|
||||||
hash: 'SHA-512',
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
{ name: ALG, length: 256 },
|
|
||||||
true,
|
|
||||||
['encrypt', 'decrypt']
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function encrypt(plaintext: string, key: CryptoKey) {
|
|
||||||
const salt = getRandomBytes(16)
|
|
||||||
const derived = await getDerivedForKey(key, salt)
|
|
||||||
const iv = getRandomBytes(16)
|
|
||||||
const encrypted = await window.crypto.subtle.encrypt(
|
|
||||||
{ name: ALG, iv },
|
|
||||||
derived,
|
|
||||||
new TextEncoder().encode(plaintext)
|
|
||||||
)
|
|
||||||
return [salt, iv, encrypted].map(Hex.encode).join(':')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decrypt(ciphertext: string, key: CryptoKey) {
|
|
||||||
const [salt, iv, encrypted] = ciphertext.split(':').map(Hex.decode)
|
|
||||||
const derived = await getDerivedForKey(key, salt)
|
|
||||||
const plaintext = await window.crypto.subtle.decrypt({ name: ALG, iv }, derived, encrypted)
|
|
||||||
return new TextDecoder().decode(plaintext)
|
|
||||||
}
|
|
@@ -1,13 +0,0 @@
|
|||||||
export class Files {
|
|
||||||
static toString(f: File | Blob): Promise<string> {
|
|
||||||
const reader = new window.FileReader()
|
|
||||||
reader.readAsDataURL(f)
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
reader.onloadend = () => resolve(reader.result as string)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
static async fromString(s: string): Promise<Blob> {
|
|
||||||
return fetch(s).then((r) => r.blob())
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,201 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Note } from '$lib/api'
|
|
||||||
import { create,PayloadToLargeError } from '$lib/api'
|
|
||||||
import { encrypt,getKeyFromString,getRandomBytes,Hex } from '$lib/crypto'
|
|
||||||
import { status } from '$lib/stores/status'
|
|
||||||
import Button from '$lib/ui/Button.svelte'
|
|
||||||
import FileUpload from '$lib/ui/FileUpload.svelte'
|
|
||||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
|
||||||
import Switch from '$lib/ui/Switch.svelte'
|
|
||||||
import TextArea from '$lib/ui/TextArea.svelte'
|
|
||||||
import TextInput from '$lib/ui/TextInput.svelte'
|
|
||||||
import { t } from 'svelte-intl-precompile'
|
|
||||||
import { blur } from 'svelte/transition'
|
|
||||||
|
|
||||||
let note: Note = {
|
|
||||||
contents: '',
|
|
||||||
meta: { type: 'text' },
|
|
||||||
views: 1,
|
|
||||||
expiration: 60,
|
|
||||||
}
|
|
||||||
let result: { password: string; id: string } | null = null
|
|
||||||
let advanced = false
|
|
||||||
let file = false
|
|
||||||
let timeExpiration = false
|
|
||||||
let message = ''
|
|
||||||
let loading = false
|
|
||||||
let error: string | null = null
|
|
||||||
|
|
||||||
$: if (!advanced) {
|
|
||||||
note.views = 1
|
|
||||||
timeExpiration = false
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
|
||||||
message = $t('home.explanation', {
|
|
||||||
values: {
|
|
||||||
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
|
|
||||||
values: { n: (timeExpiration ? note.expiration : note.views) ?? '?' },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$: note.meta.type = file ? 'file' : 'text'
|
|
||||||
|
|
||||||
$: if (!file) {
|
|
||||||
note.contents = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
class EmptyContentError extends Error {}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
try {
|
|
||||||
error = null
|
|
||||||
loading = true
|
|
||||||
const password = Hex.encode(getRandomBytes(32))
|
|
||||||
const key = await getKeyFromString(password)
|
|
||||||
if (note.contents === '') throw new EmptyContentError()
|
|
||||||
const data: Note = {
|
|
||||||
contents: await encrypt(note.contents, key),
|
|
||||||
meta: note.meta,
|
|
||||||
}
|
|
||||||
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
|
|
||||||
else data.views = parseInt(note.views as any)
|
|
||||||
|
|
||||||
const response = await create(data)
|
|
||||||
result = {
|
|
||||||
password: password,
|
|
||||||
id: response.id,
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof PayloadToLargeError) {
|
|
||||||
error = $t('home.errors.note_to_big')
|
|
||||||
} else if (e instanceof EmptyContentError) {
|
|
||||||
error = $t('home.errors.empty_content')
|
|
||||||
} else {
|
|
||||||
error = $t('home.errors.note_error')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if result}
|
|
||||||
<TextInput
|
|
||||||
type="text"
|
|
||||||
readonly
|
|
||||||
label={$t('common.share_link')}
|
|
||||||
value="{window.location.origin}/note/{result.id}#{result.password}"
|
|
||||||
copy
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
{@html $t('home.new_note_notice')}
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<Button on:click={reset}>{$t('home.new_note')}</Button>
|
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
{@html $status?.theme_text || $t('home.intro')}
|
|
||||||
</p>
|
|
||||||
<form on:submit|preventDefault={submit}>
|
|
||||||
<fieldset disabled={loading}>
|
|
||||||
{#if file}
|
|
||||||
<FileUpload label={$t('common.file')} on:file={(f) => (note.contents = f.detail)} />
|
|
||||||
{:else}
|
|
||||||
<TextArea label={$t('common.note')} bind:value={note.contents} placeholder="..." />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="bottom">
|
|
||||||
<Switch class="file" label={$t('common.file')} bind:value={file} />
|
|
||||||
{#if $status?.allow_advanced}
|
|
||||||
<Switch label={$t('common.advanced')} bind:value={advanced} />
|
|
||||||
{/if}
|
|
||||||
<div class="grow" />
|
|
||||||
<div class="tr">
|
|
||||||
<small>{$t('common.max')}: <MaxSize /> </small>
|
|
||||||
<br />
|
|
||||||
<Button type="submit">{$t('common.create')}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="error-text">{error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<br />
|
|
||||||
{#if loading}
|
|
||||||
{$t('common.loading')}
|
|
||||||
{:else}
|
|
||||||
{message}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if advanced}
|
|
||||||
<div transition:blur={{ duration: 250 }}>
|
|
||||||
<br />
|
|
||||||
<div class="fields">
|
|
||||||
<TextInput
|
|
||||||
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) ||
|
|
||||||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
|
|
||||||
/>
|
|
||||||
<div class="middle-switch">
|
|
||||||
<Switch label={$t('common.mode')} bind:value={timeExpiration} color={false} />
|
|
||||||
</div>
|
|
||||||
<TextInput
|
|
||||||
type="number"
|
|
||||||
label={$t('common.minutes', { values: { n: 0 } })}
|
|
||||||
bind:value={note.expiration}
|
|
||||||
disabled={!timeExpiration}
|
|
||||||
max={$status?.max_expiration}
|
|
||||||
validate={(v) =>
|
|
||||||
($status && v < $status?.max_expiration) ||
|
|
||||||
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.bottom {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom :global(.file) {
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grow {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.middle-switch {
|
|
||||||
margin: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fields {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
</style>
|
|
@@ -1,83 +0,0 @@
|
|||||||
<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 type { NotePublic } from '$lib/api'
|
|
||||||
import { get, info } from '$lib/api'
|
|
||||||
import { decrypt, getKeyFromString } from '$lib/crypto'
|
|
||||||
import Button from '$lib/ui/Button.svelte'
|
|
||||||
import ShowNote from '$lib/ui/ShowNote.svelte'
|
|
||||||
|
|
||||||
export let id: string
|
|
||||||
|
|
||||||
let password: string
|
|
||||||
let note: NotePublic | null = null
|
|
||||||
let exists = false
|
|
||||||
|
|
||||||
let loading = true
|
|
||||||
let error = false
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
loading = true
|
|
||||||
error = false
|
|
||||||
password = window.location.hash.slice(1)
|
|
||||||
await info(id)
|
|
||||||
exists = true
|
|
||||||
} catch {
|
|
||||||
exists = false
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function show() {
|
|
||||||
try {
|
|
||||||
error = false
|
|
||||||
loading = true
|
|
||||||
const data = note || (await get(id)) // Don't get the content twice on wrong password.
|
|
||||||
const key = await getKeyFromString(password)
|
|
||||||
data.contents = await decrypt(data.contents, key)
|
|
||||||
note = data
|
|
||||||
} catch {
|
|
||||||
error = true
|
|
||||||
} finally {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !loading}
|
|
||||||
{#if !exists}
|
|
||||||
<p class="error-text">{$t('show.errors.not_found')}</p>
|
|
||||||
{:else if note && !error}
|
|
||||||
<ShowNote {note} />
|
|
||||||
{:else}
|
|
||||||
<form on:submit|preventDefault={show}>
|
|
||||||
<fieldset>
|
|
||||||
<p>{$t('show.explanation')}</p>
|
|
||||||
<Button type="submit">{$t('show.show_note')}</Button>
|
|
||||||
{#if error}
|
|
||||||
<br />
|
|
||||||
<p class="error-text">
|
|
||||||
{$t('show.errors.decryption_failed')}
|
|
||||||
<br />
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</fieldset>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if loading}
|
|
||||||
<p>{$t('common.loading')}</p>
|
|
||||||
{/if}
|
|
11
package.json
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:docker": "docker-compose up redis",
|
"dev:docker": "docker-compose up redis",
|
||||||
"dev:backend": "cd backend && cargo watch -x 'run --bin cryptgeon'",
|
"dev:packages": "pnpm --parallel run dev",
|
||||||
"dev:front": "pnpm --prefix frontend run dev",
|
|
||||||
"dev:proxy": "node proxy.mjs",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.25.1",
|
||||||
|
"@types/node": "^16.11.57",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
"npm-run-all": "^4.1.5"
|
"npm-run-all": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
2
backend/Cargo.lock → packages/backend/Cargo.lock
generated
@@ -424,7 +424,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
version = "2.0.0-rc.0"
|
version = "2.0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-files",
|
"actix-files",
|
||||||
"actix-web",
|
"actix-web",
|
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
version = "2.0.0-rc.0"
|
version = "2.0.3"
|
||||||
authors = ["cupcakearmy <hi@nicco.io>"]
|
authors = ["cupcakearmy <hi@nicco.io>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
10
packages/backend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
@@ -1,14 +1,17 @@
|
|||||||
use actix_files::{Files, NamedFile};
|
use actix_files::{Files, NamedFile};
|
||||||
use actix_web::{web, Result};
|
use actix_web::{web, Result};
|
||||||
|
|
||||||
|
use crate::config;
|
||||||
|
|
||||||
pub fn init(cfg: &mut web::ServiceConfig) {
|
pub fn init(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
Files::new("/", "./frontend/build")
|
Files::new("/", config::FRONTEND_PATH.to_string())
|
||||||
.index_file("index.html")
|
.index_file("index.html")
|
||||||
.use_etag(true),
|
.use_etag(true),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn index() -> Result<NamedFile> {
|
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)?)
|
||||||
}
|
}
|
@@ -1,18 +1,23 @@
|
|||||||
use byte_unit::Byte;
|
use byte_unit::Byte;
|
||||||
|
|
||||||
// General
|
// Internal
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref VERSION: String = option_env!("CARGO_PKG_VERSION")
|
pub static ref VERSION: String = option_env!("CARGO_PKG_VERSION")
|
||||||
.unwrap_or("Unknown")
|
.unwrap_or("Unknown")
|
||||||
.to_string();
|
.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
|
// CONFIG
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
pub static ref LIMIT: u32 =
|
pub static ref LIMIT: usize =
|
||||||
Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string()))
|
Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string()))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.get_bytes() as u32;
|
.get_bytes() as usize;
|
||||||
pub static ref MAX_VIEWS: u32 = std::env::var("MAX_VIEWS")
|
pub static ref MAX_VIEWS: u32 = std::env::var("MAX_VIEWS")
|
||||||
.unwrap_or("100".to_string())
|
.unwrap_or("100".to_string())
|
||||||
.parse()
|
.parse()
|
@@ -18,10 +18,11 @@ mod store;
|
|||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
dotenv().ok();
|
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(|| {
|
return HttpServer::new(|| {
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(Logger::new("%a \"%r\" %s %b %T"))
|
.wrap(Logger::new("\"%r\" %s %b %T"))
|
||||||
.wrap(middleware::Compress::default())
|
.wrap(middleware::Compress::default())
|
||||||
.wrap(middleware::DefaultHeaders::default())
|
.wrap(middleware::DefaultHeaders::default())
|
||||||
.configure(size::init)
|
.configure(size::init)
|
||||||
@@ -29,7 +30,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
.configure(client::init)
|
.configure(client::init)
|
||||||
.default_service(web::to(client::index))
|
.default_service(web::to(client::index))
|
||||||
})
|
})
|
||||||
.bind("0.0.0.0:5000")?
|
.bind(config::LISTEN_ADDR.to_string())?
|
||||||
.run()
|
.run()
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
12
packages/backend/src/size.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use crate::config;
|
||||||
|
use actix_web::web;
|
||||||
|
use mime;
|
||||||
|
|
||||||
|
pub fn init(cfg: &mut web::ServiceConfig) {
|
||||||
|
let json = web::JsonConfig::default().limit(*config::LIMIT);
|
||||||
|
let plain = web::PayloadConfig::default()
|
||||||
|
.limit(*config::LIMIT)
|
||||||
|
.mimetype(mime::STAR_STAR);
|
||||||
|
// cfg.app_data(plain);
|
||||||
|
cfg.app_data(json).app_data(plain);
|
||||||
|
}
|
@@ -7,7 +7,7 @@ use crate::status::Status;
|
|||||||
async fn get_status() -> impl Responder {
|
async fn get_status() -> impl Responder {
|
||||||
return HttpResponse::Ok().json(Status {
|
return HttpResponse::Ok().json(Status {
|
||||||
version: config::VERSION.to_string(),
|
version: config::VERSION.to_string(),
|
||||||
max_size: *config::LIMIT,
|
max_size: *config::LIMIT as u32,
|
||||||
max_views: *config::MAX_VIEWS,
|
max_views: *config::MAX_VIEWS,
|
||||||
max_expiration: *config::MAX_EXPIRATION,
|
max_expiration: *config::MAX_EXPIRATION,
|
||||||
allow_advanced: *config::ALLOW_ADVANCED,
|
allow_advanced: *config::ALLOW_ADVANCED,
|
@@ -1,8 +1,8 @@
|
|||||||
├─ MIT: 12
|
├─ MIT: 13
|
||||||
|
├─ ISC: 2
|
||||||
├─ BSD-3-Clause: 1
|
├─ BSD-3-Clause: 1
|
||||||
├─ (MPL-2.0 OR Apache-2.0): 1
|
├─ (MPL-2.0 OR Apache-2.0): 1
|
||||||
├─ BSD-2-Clause: 1
|
├─ BSD-2-Clause: 1
|
||||||
├─ ISC: 1
|
|
||||||
├─ 0BSD: 1
|
├─ 0BSD: 1
|
||||||
└─ Apache-2.0: 1
|
└─ Apache-2.0: 1
|
||||||
|
|
|
@@ -4,13 +4,18 @@
|
|||||||
"file": "Datei",
|
"file": "Datei",
|
||||||
"advanced": "erweitert",
|
"advanced": "erweitert",
|
||||||
"create": "erstellen",
|
"create": "erstellen",
|
||||||
"loading": "Läd...",
|
"loading": "läd",
|
||||||
"mode": "Modus",
|
"mode": "Modus",
|
||||||
"views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}",
|
"views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}",
|
||||||
"minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}",
|
"minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}",
|
||||||
"max": "max",
|
"max": "max",
|
||||||
"share_link": "Link teilen",
|
"share_link": "Link teilen",
|
||||||
"copy_clipboard": "in die Zwischenablage kopieren"
|
"copy_clipboard": "in die Zwischenablage kopieren",
|
||||||
|
"copied_to_clipboard": "in die Zwischenablage kopiert",
|
||||||
|
"encrypting": "verschlüsseln",
|
||||||
|
"decrypting": "entschlüsselt",
|
||||||
|
"uploading": "hochladen",
|
||||||
|
"downloading": "wird heruntergeladen"
|
||||||
},
|
},
|
||||||
"home": {
|
"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.",
|
"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.",
|
||||||
@@ -23,12 +28,15 @@
|
|||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "Notiz ist leer."
|
"empty_content": "Notiz ist leer."
|
||||||
},
|
},
|
||||||
"copied_to_clipboard": "in die Zwischenablage kopiert 🔗"
|
"messages": {
|
||||||
|
"note_created": "notiz erstellt."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "wurde nicht gefunden oder wurde bereits gelöscht.",
|
"not_found": "wurde nicht gefunden oder wurde bereits gelöscht.",
|
||||||
"decryption_failed": "falsches Passwort. konnte nicht entziffert werden. wahrscheinlich ein defekter Link. Notiz wurde zerstört."
|
"decryption_failed": "falsches Passwort. konnte nicht entziffert werden. wahrscheinlich ein defekter Link. Notiz wurde zerstört.",
|
||||||
|
"unsupported_type": "nicht unterstützter Notiztyp."
|
||||||
},
|
},
|
||||||
"explanation": "Klicken Sie unten, um die Notiz anzuzeigen und zu löschen, wenn der Zähler sein Limit erreicht hat",
|
"explanation": "Klicken Sie unten, um die Notiz anzuzeigen und zu löschen, wenn der Zähler sein Limit erreicht hat",
|
||||||
"show_note": "Notiz anzeigen",
|
"show_note": "Notiz anzeigen",
|
@@ -4,13 +4,18 @@
|
|||||||
"file": "file",
|
"file": "file",
|
||||||
"advanced": "advanced",
|
"advanced": "advanced",
|
||||||
"create": "create",
|
"create": "create",
|
||||||
"loading": "loading...",
|
"loading": "loading",
|
||||||
"mode": "mode",
|
"mode": "mode",
|
||||||
"views": "{n, plural, =0 {views} =1 {1 view} other {# views}}",
|
"views": "{n, plural, =0 {views} =1 {1 view} other {# views}}",
|
||||||
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
|
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
|
||||||
"max": "max",
|
"max": "max",
|
||||||
"share_link": "share link",
|
"share_link": "share link",
|
||||||
"copy_clipboard": "copy to clipboard"
|
"copy_clipboard": "copy to clipboard",
|
||||||
|
"copied_to_clipboard": "copied to clipboard",
|
||||||
|
"encrypting": "encrypting",
|
||||||
|
"decrypting": "decrypting",
|
||||||
|
"uploading": "uploading",
|
||||||
|
"downloading": "downloading"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
|
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
|
||||||
@@ -23,12 +28,15 @@
|
|||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "note is empty."
|
"empty_content": "note is empty."
|
||||||
},
|
},
|
||||||
"copied_to_clipboard": "copied to clipboard 🔗"
|
"messages": {
|
||||||
|
"note_created": "note created."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "note was not found or was already deleted.",
|
"not_found": "note was not found or was already deleted.",
|
||||||
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed."
|
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.",
|
||||||
|
"unsupported_type": "unsupported note type."
|
||||||
},
|
},
|
||||||
"explanation": "click below to show and delete the note if the counter has reached it's limit",
|
"explanation": "click below to show and delete the note if the counter has reached it's limit",
|
||||||
"show_note": "show note",
|
"show_note": "show note",
|
@@ -4,13 +4,18 @@
|
|||||||
"file": "archivo",
|
"file": "archivo",
|
||||||
"advanced": "avanzado",
|
"advanced": "avanzado",
|
||||||
"create": "crear",
|
"create": "crear",
|
||||||
"loading": "cargando...",
|
"loading": "cargando",
|
||||||
"mode": "modo",
|
"mode": "modo",
|
||||||
"views": "{n, plural, =0 {vistas} =1 {1 vista} other {# vistas}}",
|
"views": "{n, plural, =0 {vistas} =1 {1 vista} other {# vistas}}",
|
||||||
"minutes": "{n, plural, =0 {minutos} =1 {1 minuto} other {# minutos}}",
|
"minutes": "{n, plural, =0 {minutos} =1 {1 minuto} other {# minutos}}",
|
||||||
"max": "max",
|
"max": "max",
|
||||||
"share_link": "compartir enlace",
|
"share_link": "compartir enlace",
|
||||||
"copy_clipboard": "copiar al portapapeles"
|
"copy_clipboard": "copiar al portapapeles",
|
||||||
|
"copied_to_clipboard": "copiado al portapapeles",
|
||||||
|
"encrypting": "encriptando",
|
||||||
|
"decrypting": "descifrando",
|
||||||
|
"uploading": "cargando",
|
||||||
|
"downloading": "descargando"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.",
|
"intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.",
|
||||||
@@ -23,12 +28,15 @@
|
|||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "la nota está vacía."
|
"empty_content": "la nota está vacía."
|
||||||
},
|
},
|
||||||
"copied_to_clipboard": "copiado al portapapeles 🔗"
|
"messages": {
|
||||||
|
"note_created": "nota creada."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "la nota no se encontró o ya fue borrada.",
|
"not_found": "la nota no se encontró o ya fue borrada.",
|
||||||
"decryption_failed": "contraseña incorrecta. no se pudo descifrar. probablemente un enlace roto. la nota fue destruida."
|
"decryption_failed": "contraseña incorrecta. no se pudo descifrar. probablemente un enlace roto. la nota fue destruida.",
|
||||||
|
"unsupported_type": "tipo de nota no compatible."
|
||||||
},
|
},
|
||||||
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
|
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
|
||||||
"show_note": "mostrar nota",
|
"show_note": "mostrar nota",
|
@@ -4,13 +4,18 @@
|
|||||||
"file": "fichier",
|
"file": "fichier",
|
||||||
"advanced": "avancé",
|
"advanced": "avancé",
|
||||||
"create": "créer",
|
"create": "créer",
|
||||||
"loading": "chargement...",
|
"loading": "chargement",
|
||||||
"mode": "mode",
|
"mode": "mode",
|
||||||
"views": "{n, plural, =0 {vues} =1 {1 vue} other {# vues}}",
|
"views": "{n, plural, =0 {vues} =1 {1 vue} other {# vues}}",
|
||||||
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
|
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
|
||||||
"max": "max",
|
"max": "max",
|
||||||
"share_link": "partager le lien",
|
"share_link": "partager le lien",
|
||||||
"copy_clipboard": "copier dans le presse-papiers"
|
"copy_clipboard": "copier dans le presse-papiers",
|
||||||
|
"copied_to_clipboard": "copié dans le presse-papiers",
|
||||||
|
"encrypting": "cryptage",
|
||||||
|
"decrypting": "déchiffrer",
|
||||||
|
"uploading": "téléchargement",
|
||||||
|
"downloading": "téléchargement"
|
||||||
},
|
},
|
||||||
"home": {
|
"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.",
|
"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.",
|
||||||
@@ -23,12 +28,15 @@
|
|||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "La note est vide."
|
"empty_content": "La note est vide."
|
||||||
},
|
},
|
||||||
"copied_to_clipboard": "copié dans le presse-papiers 🔗"
|
"messages": {
|
||||||
|
"note_created": "note créée."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "La note n'a pas été trouvée ou a déjà été supprimée.",
|
"not_found": "La note n'a pas été trouvée ou a déjà été supprimée.",
|
||||||
"decryption_failed": "mauvais mot de passe. impossible à déchiffrer. probablement un lien brisé. la note a été détruite."
|
"decryption_failed": "mauvais mot de passe. impossible à déchiffrer. probablement un lien brisé. la note a été détruite.",
|
||||||
|
"unsupported_type": "type de note non supporté."
|
||||||
},
|
},
|
||||||
"explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.",
|
"explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.",
|
||||||
"show_note": "note de présentation",
|
"show_note": "note de présentation",
|
@@ -4,13 +4,18 @@
|
|||||||
"file": "file",
|
"file": "file",
|
||||||
"advanced": "avanzato",
|
"advanced": "avanzato",
|
||||||
"create": "crea",
|
"create": "crea",
|
||||||
"loading": "carica...",
|
"loading": "carica",
|
||||||
"mode": "modalita",
|
"mode": "modalita",
|
||||||
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
|
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
|
||||||
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
|
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
|
||||||
"max": "max",
|
"max": "max",
|
||||||
"share_link": "condividi link",
|
"share_link": "condividi link",
|
||||||
"copy_clipboard": "copia negli appunti"
|
"copy_clipboard": "copia negli appunti",
|
||||||
|
"copied_to_clipboard": "copiato negli appunti",
|
||||||
|
"encrypting": "criptando",
|
||||||
|
"decrypting": "decifrando",
|
||||||
|
"uploading": "caricamento",
|
||||||
|
"downloading": "scaricando"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.",
|
"intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.",
|
||||||
@@ -23,12 +28,15 @@
|
|||||||
"max": "max: {n}",
|
"max": "max: {n}",
|
||||||
"empty_content": "la nota è vuota."
|
"empty_content": "la nota è vuota."
|
||||||
},
|
},
|
||||||
"copied_to_clipboard": "copiato negli appunti 🔗"
|
"messages": {
|
||||||
|
"note_created": "nota creata."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "non è stata trovata o è stata già cancellata.",
|
"not_found": "non è stata trovata o è stata già cancellata.",
|
||||||
"decryption_failed": "password sbagliata. non ha potuto decifrare. probabilmente un link rotto. la nota è stata distrutta."
|
"decryption_failed": "password sbagliata. non ha potuto decifrare. probabilmente un link rotto. la nota è stata distrutta.",
|
||||||
|
"unsupported_type": "tipo di nota non supportato."
|
||||||
},
|
},
|
||||||
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
|
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
|
||||||
"show_note": "mostra la nota",
|
"show_note": "mostra la nota",
|
@@ -4,13 +4,18 @@
|
|||||||
"file": "上传文件",
|
"file": "上传文件",
|
||||||
"advanced": "高级设置",
|
"advanced": "高级设置",
|
||||||
"create": "创建",
|
"create": "创建",
|
||||||
"loading": "加载中...",
|
"loading": "加载中",
|
||||||
"mode": "模式",
|
"mode": "模式",
|
||||||
"views": "{n, plural, =0 {可查看次数} =1 {1 次查看} other {# 次查看}}",
|
"views": "{n, plural, =0 {可查看次数} =1 {1 次查看} other {# 次查看}}",
|
||||||
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
|
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
|
||||||
"max": "最大值",
|
"max": "最大值",
|
||||||
"share_link": "分享链接",
|
"share_link": "分享链接",
|
||||||
"copy_clipboard": "复制到剪切版"
|
"copy_clipboard": "复制到剪切版",
|
||||||
|
"copied_to_clipboard": "已复制到剪切板",
|
||||||
|
"encrypting": "加密",
|
||||||
|
"decrypting": "解密",
|
||||||
|
"uploading": "上传",
|
||||||
|
"downloading": "下载"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
|
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
|
||||||
@@ -23,12 +28,15 @@
|
|||||||
"max": "最大文件大小: {n}",
|
"max": "最大文件大小: {n}",
|
||||||
"empty_content": "密信为空!"
|
"empty_content": "密信为空!"
|
||||||
},
|
},
|
||||||
"copied_to_clipboard": "已复制到剪切板 🔗"
|
"messages": {
|
||||||
|
"note_created": "注释创建。"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"errors": {
|
"errors": {
|
||||||
"not_found": "该密信无法被找到或者它已经被删除了!",
|
"not_found": "该密信无法被找到或者它已经被删除了!",
|
||||||
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!"
|
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!",
|
||||||
|
"unsupported_type": "不支持的票据类型。"
|
||||||
},
|
},
|
||||||
"explanation": "点击下方的按钮可以查看密信,如果它到达了限制将会被删除",
|
"explanation": "点击下方的按钮可以查看密信,如果它到达了限制将会被删除",
|
||||||
"show_note": "查看密信",
|
"show_note": "查看密信",
|
@@ -3,18 +3,20 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "svelte-kit preview",
|
"preview": "vite preview --port 3000",
|
||||||
"check": "svelte-check --tsconfig tsconfig.json",
|
"check": "svelte-check --tsconfig tsconfig.json",
|
||||||
"licenses": "license-checker --summary > licenses.csv",
|
"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",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@lokalise/node-api": "^7.3.1",
|
"@lokalise/node-api": "^7.3.1",
|
||||||
"@sveltejs/adapter-static": "^1.0.0-next.34",
|
"@sveltejs/adapter-static": "1.0.0-next.42",
|
||||||
"@sveltejs/kit": "^1.0.0-next.361",
|
"@sveltejs/kit": "1.0.0-next.480",
|
||||||
"@types/dompurify": "^2.3.3",
|
"@types/dompurify": "^2.3.3",
|
||||||
"@types/file-saver": "^2.0.5",
|
"@types/file-saver": "^2.0.5",
|
||||||
|
"@zerodevx/svelte-toast": "^0.7.2",
|
||||||
"adm-zip": "^0.5.9",
|
"adm-zip": "^0.5.9",
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"svelte": "^3.49.0",
|
"svelte": "^3.49.0",
|
||||||
@@ -23,12 +25,12 @@
|
|||||||
"svelte-preprocess": "^4.10.7",
|
"svelte-preprocess": "^4.10.7",
|
||||||
"tslib": "^2.4.0",
|
"tslib": "^2.4.0",
|
||||||
"typescript": "^4.7.4",
|
"typescript": "^4.7.4",
|
||||||
"vite": "^3.0.0"
|
"vite": "^3.0.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/fira-mono": "^4.5.8",
|
"@fontsource/fira-mono": "^4.5.8",
|
||||||
"copy-to-clipboard": "^3.3.1",
|
"copy-to-clipboard": "^3.3.1",
|
||||||
"dompurify": "^2.3.9",
|
"dompurify": "^2.3.10",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"pretty-bytes": "^5.6.0"
|
"pretty-bytes": "^5.6.0"
|
||||||
}
|
}
|
@@ -14,7 +14,9 @@
|
|||||||
--ui-text-0: #fefefe;
|
--ui-text-0: #fefefe;
|
||||||
--ui-text-1: #eee;
|
--ui-text-1: #eee;
|
||||||
--ui-clr-primary: hsl(186, 65%, 55%);
|
--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: hsl(357, 77%, 51%);
|
||||||
|
--ui-clr-error-alt: hsl(357, 87%, 41%);
|
||||||
|
|
||||||
--ui-anim: all 150ms ease;
|
--ui-anim: all 150ms ease;
|
||||||
}
|
}
|
61
packages/frontend/src/lib/adapters.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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(),
|
||||||
|
}
|
@@ -11,6 +11,10 @@ export type NotePublic = Pick<Note, 'contents' | 'meta'>
|
|||||||
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
|
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
|
||||||
|
|
||||||
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
|
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
|
||||||
|
contents: Blob
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
|
||||||
contents: string
|
contents: string
|
||||||
}
|
}
|
||||||
|
|
89
packages/frontend/src/lib/crypto.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
Before Width: | Height: | Size: 325 B After Width: | Height: | Size: 325 B |
Before Width: | Height: | Size: 736 B After Width: | Height: | Size: 736 B |
Before Width: | Height: | Size: 483 B After Width: | Height: | Size: 483 B |
Before Width: | Height: | Size: 732 B After Width: | Height: | Size: 732 B |
37
packages/frontend/src/lib/toast.ts
Normal 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),
|
||||||
|
}
|
54
packages/frontend/src/lib/ui/AdvancedParameters.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
export let note: Note
|
||||||
|
export let timeExpiration = false
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<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) ||
|
||||||
|
$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"
|
||||||
|
label={$t('common.minutes', { values: { n: 0 } })}
|
||||||
|
bind:value={note.expiration}
|
||||||
|
disabled={!timeExpiration}
|
||||||
|
max={$status?.max_expiration}
|
||||||
|
validate={(v) =>
|
||||||
|
($status && v < $status?.max_expiration) ||
|
||||||
|
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.middle-switch {
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -1,38 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FileDTO } from '$lib/api'
|
|
||||||
import { Files } from '$lib/files'
|
|
||||||
import { createEventDispatcher } from 'svelte'
|
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
import Button from './Button.svelte'
|
|
||||||
import MaxSize from './MaxSize.svelte'
|
import type { FileDTO } from '$lib/api'
|
||||||
|
import Button from '$lib/ui/Button.svelte'
|
||||||
|
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||||
|
|
||||||
export let label: string = ''
|
export let label: string = ''
|
||||||
let files: File[] = []
|
export let files: FileDTO[] = []
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ file: string }>()
|
function fileToDTO(file: File): FileDTO {
|
||||||
|
return {
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
contents: file,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onInput(e: Event) {
|
async function onInput(e: Event) {
|
||||||
const input = e.target as HTMLInputElement
|
const input = e.target as HTMLInputElement
|
||||||
if (input?.files?.length) {
|
if (input?.files?.length) {
|
||||||
files = [...files, ...Array.from(input.files)]
|
files = [...files, ...Array.from(input.files).map(fileToDTO)]
|
||||||
const data: FileDTO[] = await Promise.all(
|
|
||||||
files.map(async (file) => ({
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
contents: await Files.toString(file),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
dispatch('file', JSON.stringify(data))
|
|
||||||
} else {
|
|
||||||
dispatch('file', '')
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear(e: Event) {
|
function clear(e: Event) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
files = []
|
files = []
|
||||||
dispatch('file', '')
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -40,7 +34,7 @@
|
|||||||
<small>
|
<small>
|
||||||
{label}
|
{label}
|
||||||
</small>
|
</small>
|
||||||
<input type="file" on:change={onInput} multiple />
|
<input {...$$restProps} type="file" on:change={onInput} multiple />
|
||||||
<div class="box">
|
<div class="box">
|
||||||
{#if files.length}
|
{#if files.length}
|
||||||
<div>
|
<div>
|
||||||
@@ -57,7 +51,9 @@
|
|||||||
<div>
|
<div>
|
||||||
<b>{$t('file_upload.no_files_selected')}</b>
|
<b>{$t('file_upload.no_files_selected')}</b>
|
||||||
<br />
|
<br />
|
||||||
<small>{$t('common.max')}: <MaxSize /></small>
|
<small>
|
||||||
|
{$t('common.max')}: <MaxSize />
|
||||||
|
</small>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
41
packages/frontend/src/lib/ui/Loader.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
xml:space="preserve"
|
||||||
|
>
|
||||||
|
<rect fill="none" stroke="currentColor" stroke-width="4" x="25" y="25" width="50" height="50">
|
||||||
|
<animateTransform
|
||||||
|
attributeName="transform"
|
||||||
|
dur="0.5s"
|
||||||
|
from="0 50 50"
|
||||||
|
to="180 50 50"
|
||||||
|
type="rotate"
|
||||||
|
id="strokeBox"
|
||||||
|
attributeType="XML"
|
||||||
|
begin="rectBox.end"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
<rect x="27" y="27" fill="currentColor" width="46" height="50">
|
||||||
|
<animate
|
||||||
|
attributeName="height"
|
||||||
|
dur="1.3s"
|
||||||
|
attributeType="XML"
|
||||||
|
from="50"
|
||||||
|
to="0"
|
||||||
|
id="rectBox"
|
||||||
|
fill="freeze"
|
||||||
|
begin="0s;strokeBox.end"
|
||||||
|
/>
|
||||||
|
</rect>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
svg {
|
||||||
|
height: 2em;
|
||||||
|
position: relative;
|
||||||
|
top: 0.6em;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
After Width: | Height: | Size: 784 B |
@@ -1,12 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { status } from '$lib/stores/status'
|
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
import { _ } from 'svelte-intl-precompile'
|
import { _ } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
|
import { status } from '$lib/stores/status'
|
||||||
|
|
||||||
|
// Due to encoding overhead (~35%) with base64
|
||||||
|
// https://en.wikipedia.org/wiki/Base64
|
||||||
|
const overhead = 1 / 1.35
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
{#if $status !== null}
|
{#if $status !== null}
|
||||||
{prettyBytes($status.max_size, { binary: true })}
|
{prettyBytes($status.max_size * overhead, { binary: true })}
|
||||||
{:else}
|
{:else}
|
||||||
{$_('common.loading')}
|
{$_('common.loading')}
|
||||||
{/if}
|
{/if}
|
37
packages/frontend/src/lib/ui/NoteResult.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export type NoteResult = {
|
||||||
|
password: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
|
import Button from '$lib/ui/Button.svelte'
|
||||||
|
import TextInput from '$lib/ui/TextInput.svelte'
|
||||||
|
|
||||||
|
export let result: NoteResult
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
label={$t('common.share_link')}
|
||||||
|
value="{window.location.origin}/note/{result.id}#{result.password}"
|
||||||
|
copy
|
||||||
|
data-testid="share-link"
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
{@html $t('home.new_note_notice')}
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<Button on:click={reset}>{$t('home.new_note')}</Button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
</style>
|
@@ -1,20 +1,24 @@
|
|||||||
|
<script lang="ts" context="module">
|
||||||
|
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FileDTO, NotePublic } from '$lib/api'
|
|
||||||
import { Files } from '$lib/files'
|
|
||||||
import copy from 'copy-to-clipboard'
|
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
import prettyBytes from 'pretty-bytes'
|
import prettyBytes from 'pretty-bytes'
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { t } from 'svelte-intl-precompile'
|
||||||
import Button from './Button.svelte'
|
|
||||||
|
|
||||||
export let note: NotePublic
|
import type { FileDTO, NotePublic } from '$lib/api'
|
||||||
|
import Button from '$lib/ui/Button.svelte'
|
||||||
|
import { copy } from '$lib/utils'
|
||||||
|
|
||||||
|
export let note: DecryptedNote
|
||||||
|
|
||||||
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
|
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
|
||||||
let files: FileDTO[] = []
|
let files: FileDTO[] = []
|
||||||
|
|
||||||
$: if (note.meta.type === 'file') {
|
$: if (note.meta.type === 'file') {
|
||||||
files = JSON.parse(note.contents) as FileDTO[]
|
files = note.contents
|
||||||
}
|
}
|
||||||
|
|
||||||
$: download = () => {
|
$: download = () => {
|
||||||
@@ -24,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadFile(file: FileDTO) {
|
async function downloadFile(file: FileDTO) {
|
||||||
const f = new File([await Files.fromString(file.contents)], file.name, {
|
const f = new File([file.contents], file.name, {
|
||||||
type: file.type,
|
type: file.type,
|
||||||
})
|
})
|
||||||
saveAs(f)
|
saveAs(f)
|
||||||
@@ -40,20 +44,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
|
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
|
||||||
{#if note.meta.type === 'text'}
|
<div data-testid="result">
|
||||||
<div class="note">
|
{#if note.meta.type === 'text'}
|
||||||
{@html contentWithLinks(note.contents)}
|
<div class="note">
|
||||||
</div>
|
{@html contentWithLinks(note.contents)}
|
||||||
<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>
|
</div>
|
||||||
{/each}
|
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
|
||||||
<Button on:click={download}>{$t('show.download_all')}</Button>
|
{:else}
|
||||||
{/if}
|
{#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>
|
<style>
|
||||||
.note {
|
.note {
|
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getRandomBytes, Hex } from '$lib/crypto'
|
import { Crypto, Hex } from '$lib/crypto'
|
||||||
import copyToClipboard from 'copy-to-clipboard'
|
import Icon from '$lib/ui/Icon.svelte'
|
||||||
import { t } from 'svelte-intl-precompile'
|
import { copy as copyFN } from '$lib/utils'
|
||||||
import { fade } from 'svelte/transition'
|
|
||||||
import Icon from './Icon.svelte'
|
|
||||||
|
|
||||||
export let label: string = ''
|
export let label: string = ''
|
||||||
export let value: any
|
export let value: any
|
||||||
@@ -14,8 +12,6 @@
|
|||||||
const initialType = $$restProps.type
|
const initialType = $$restProps.type
|
||||||
const isPassword = initialType === 'password'
|
const isPassword = initialType === 'password'
|
||||||
let hidden = true
|
let hidden = true
|
||||||
let notification: string | null = null
|
|
||||||
let notificationTimeout: NodeJS.Timeout | null = null
|
|
||||||
|
|
||||||
$: valid = validate(value)
|
$: valid = validate(value)
|
||||||
|
|
||||||
@@ -27,22 +23,8 @@
|
|||||||
function toggle() {
|
function toggle() {
|
||||||
hidden = !hidden
|
hidden = !hidden
|
||||||
}
|
}
|
||||||
function copyFN() {
|
|
||||||
copyToClipboard(value.toString())
|
|
||||||
notify($t('home.copied_to_clipboard'))
|
|
||||||
}
|
|
||||||
function randomFN() {
|
function randomFN() {
|
||||||
value = Hex.encode(getRandomBytes(20))
|
value = Hex.encode(Crypto.getRandomBytes(32))
|
||||||
}
|
|
||||||
|
|
||||||
function notify(msg: string, delay: number = 2000) {
|
|
||||||
if (notificationTimeout) {
|
|
||||||
clearTimeout(notificationTimeout)
|
|
||||||
}
|
|
||||||
notificationTimeout = setTimeout(() => {
|
|
||||||
notification = null
|
|
||||||
}, delay)
|
|
||||||
notification = msg
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -62,12 +44,9 @@
|
|||||||
<Icon class="icon" icon="dice" on:click={randomFN} />
|
<Icon class="icon" icon="dice" on:click={randomFN} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if copy}
|
{#if copy}
|
||||||
<Icon class="icon" icon="copy" on:click={copyFN} />
|
<Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if notification}
|
|
||||||
<div class="notification" transition:fade><small>{notification}</small></div>
|
|
||||||
{/if}
|
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -117,11 +96,4 @@
|
|||||||
.icons > :global(.icon:hover) {
|
.icons > :global(.icon:hover) {
|
||||||
border-color: var(--ui-clr-primary);
|
border-color: var(--ui-clr-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification {
|
|
||||||
text-align: right;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: -1.5em;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
11
packages/frontend/src/lib/utils.ts
Normal 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)
|
||||||
|
}
|
174
packages/frontend/src/lib/views/Create.svelte
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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'
|
||||||
|
import Button from '$lib/ui/Button.svelte'
|
||||||
|
import FileUpload from '$lib/ui/FileUpload.svelte'
|
||||||
|
import Loader from '$lib/ui/Loader.svelte'
|
||||||
|
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||||
|
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
|
||||||
|
import Switch from '$lib/ui/Switch.svelte'
|
||||||
|
import TextArea from '$lib/ui/TextArea.svelte'
|
||||||
|
|
||||||
|
let note: Note = {
|
||||||
|
contents: '',
|
||||||
|
meta: { type: 'text' },
|
||||||
|
views: 1,
|
||||||
|
expiration: 60,
|
||||||
|
}
|
||||||
|
let files: FileDTO[]
|
||||||
|
let result: NoteResult | null = null
|
||||||
|
let advanced = false
|
||||||
|
let isFile = false
|
||||||
|
let timeExpiration = false
|
||||||
|
let description = ''
|
||||||
|
let loading: string | null = null
|
||||||
|
|
||||||
|
$: if (!advanced) {
|
||||||
|
note.views = 1
|
||||||
|
timeExpiration = false
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
description = $t('home.explanation', {
|
||||||
|
values: {
|
||||||
|
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
|
||||||
|
values: { n: (timeExpiration ? note.expiration : note.views) ?? '?' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$: note.meta.type = isFile ? 'file' : 'text'
|
||||||
|
|
||||||
|
$: if (!isFile) {
|
||||||
|
note.contents = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyContentError extends Error {}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
loading = $t('common.encrypting')
|
||||||
|
|
||||||
|
const key = await Keys.generateKey()
|
||||||
|
const password = await Keys.export(key)
|
||||||
|
|
||||||
|
const data: Note = {
|
||||||
|
contents: '',
|
||||||
|
meta: note.meta,
|
||||||
|
}
|
||||||
|
if (isFile) {
|
||||||
|
if (files.length === 0) throw new EmptyContentError()
|
||||||
|
data.contents = await Adapters.Files.encrypt(files, key)
|
||||||
|
} else {
|
||||||
|
if (note.contents === '') throw new EmptyContentError()
|
||||||
|
data.contents = await Adapters.Text.encrypt(note.contents, key)
|
||||||
|
}
|
||||||
|
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
|
||||||
|
else data.views = parseInt(note.views as any)
|
||||||
|
|
||||||
|
loading = $t('common.uploading')
|
||||||
|
const response = await create(data)
|
||||||
|
result = {
|
||||||
|
password: password,
|
||||||
|
id: response.id,
|
||||||
|
}
|
||||||
|
notify.success($t('home.messages.note_created'))
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PayloadToLargeError) {
|
||||||
|
notify.error($t('home.errors.note_to_big'))
|
||||||
|
} else if (e instanceof EmptyContentError) {
|
||||||
|
notify.error($t('home.errors.empty_content'))
|
||||||
|
} else {
|
||||||
|
console.error(e)
|
||||||
|
notify.error($t('home.errors.note_error'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if result}
|
||||||
|
<Result {result} />
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
{@html $status?.theme_text || $t('home.intro')}
|
||||||
|
</p>
|
||||||
|
<form on:submit|preventDefault={submit}>
|
||||||
|
<fieldset disabled={loading !== null}>
|
||||||
|
{#if isFile}
|
||||||
|
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||||
|
{:else}
|
||||||
|
<TextArea
|
||||||
|
data-testid="text-field"
|
||||||
|
label={$t('common.note')}
|
||||||
|
bind:value={note.contents}
|
||||||
|
placeholder="..."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="bottom">
|
||||||
|
<Switch
|
||||||
|
data-testid="switch-file"
|
||||||
|
class="file"
|
||||||
|
label={$t('common.file')}
|
||||||
|
bind:value={isFile}
|
||||||
|
/>
|
||||||
|
{#if $status?.allow_advanced}
|
||||||
|
<Switch
|
||||||
|
data-testid="switch-advanced"
|
||||||
|
label={$t('common.advanced')}
|
||||||
|
bind:value={advanced}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<div class="grow" />
|
||||||
|
<div class="tr">
|
||||||
|
<small>{$t('common.max')}: <MaxSize /> </small>
|
||||||
|
<br />
|
||||||
|
<Button type="submit">{$t('common.create')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<br />
|
||||||
|
{#if loading}
|
||||||
|
{loading} <Loader />
|
||||||
|
{:else}
|
||||||
|
{description}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if advanced}
|
||||||
|
<div transition:blur={{ duration: 250 }}>
|
||||||
|
<br />
|
||||||
|
<AdvancedParameters bind:note bind:timeExpiration />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom :global(.file) {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -1,17 +1,13 @@
|
|||||||
<script lang="ts" context="module">
|
|
||||||
import { init, waitLocale, getLocaleFromNavigator } from 'svelte-intl-precompile'
|
|
||||||
// @ts-ignore
|
|
||||||
import { registerAll } from '$locales'
|
|
||||||
registerAll()
|
|
||||||
init({ initialLocale: getLocaleFromNavigator() ?? undefined, fallbackLocale: 'en' })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<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 { init as initStores } from '$lib/stores/status'
|
||||||
import Footer from '$lib/views/Footer.svelte'
|
import Footer from '$lib/views/Footer.svelte'
|
||||||
import Header from '$lib/views/Header.svelte'
|
import Header from '$lib/views/Header.svelte'
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import '../app.css'
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initStores()
|
initStores()
|
||||||
@@ -28,6 +24,8 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<SvelteToast />
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
{/await}
|
{/await}
|
||||||
|
|
5
packages/frontend/src/routes/+layout.ts
Normal 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' })
|
@@ -1,10 +1,6 @@
|
|||||||
<script context="module">
|
<script lang="ts">
|
||||||
import { browser, dev } from '$app/env'
|
|
||||||
import { status } from '$lib/stores/status'
|
import { status } from '$lib/stores/status'
|
||||||
import AboutParagraph from '$lib/ui/AboutParagraph.svelte'
|
import AboutParagraph from '$lib/ui/AboutParagraph.svelte'
|
||||||
|
|
||||||
export const hydrate = dev
|
|
||||||
export const router = browser
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
101
packages/frontend/src/routes/note/[id]/+page.svelte
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<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 { 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 data: PageData
|
||||||
|
|
||||||
|
let id = data.id
|
||||||
|
let password: string
|
||||||
|
let note: DecryptedNote | null = null
|
||||||
|
let exists = false
|
||||||
|
|
||||||
|
let loading: string | null = null
|
||||||
|
let error: string | null = null
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Check if note exists
|
||||||
|
try {
|
||||||
|
loading = $t('common.loading')
|
||||||
|
password = window.location.hash.slice(1)
|
||||||
|
await info(id)
|
||||||
|
exists = true
|
||||||
|
} catch {
|
||||||
|
exists = false
|
||||||
|
} finally {
|
||||||
|
loading = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the actual contents of the note and decrypt it.
|
||||||
|
*/
|
||||||
|
async function show() {
|
||||||
|
try {
|
||||||
|
error = null
|
||||||
|
loading = $t('common.downloading')
|
||||||
|
const data = await get(id)
|
||||||
|
loading = $t('common.decrypting')
|
||||||
|
const key = await Keys.import(password)
|
||||||
|
switch (data.meta.type) {
|
||||||
|
case 'text':
|
||||||
|
note = {
|
||||||
|
meta: { type: 'text' },
|
||||||
|
contents: await Adapters.Text.decrypt(data.contents, key),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'file':
|
||||||
|
note = {
|
||||||
|
meta: { type: 'file' },
|
||||||
|
contents: await Adapters.Files.decrypt(data.contents, key),
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
error = $t('show.errors.unsupported_type')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = $t('show.errors.decryption_failed')
|
||||||
|
} finally {
|
||||||
|
loading = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !loading}
|
||||||
|
{#if !exists}
|
||||||
|
<p class="error-text">{$t('show.errors.not_found')}</p>
|
||||||
|
{:else if note && !error}
|
||||||
|
<ShowNote {note} />
|
||||||
|
{:else}
|
||||||
|
<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 error}
|
||||||
|
<br />
|
||||||
|
<p class="error-text">
|
||||||
|
{error}
|
||||||
|
<br />
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if loading}
|
||||||
|
<p class="loader">{loading} <Loader /></p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loader {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
5
packages/frontend/src/routes/note/[id]/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import type { PageLoad } from './$types'
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => {
|
||||||
|
return params
|
||||||
|
}
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -3,6 +3,7 @@ import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
|
|||||||
|
|
||||||
/** @type {import('vite').UserConfig} */
|
/** @type {import('vite').UserConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
},
|
},
|
33
playwright.config.ts
Normal 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
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/**"
|
BIN
test/assets/AES.pdf
(Stored with Git LFS)
Normal file
BIN
test/assets/Pigeons.zip
(Stored with Git LFS)
Normal file
BIN
test/assets/alfred-kenneally-UIu4RmMxnHU-unsplash.jpg
(Stored with Git LFS)
Normal file
5
test/file/files.ts
Normal 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',
|
||||||
|
}
|
11
test/file/multiple.spec.ts
Normal 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
@@ -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)
|
||||||
|
})
|
8
test/file/too-big.spec.ts
Normal 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
@@ -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)
|
||||||
|
})
|