Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
fe5ce580ab | |||
0f882da5d1 | |||
ad6f136dd0 | |||
da527a0857 | |||
a95931ae77 | |||
d6c2236673 | |||
42a8ab5d0f | |||
0934808a59 | |||
88ea828b66 | |||
41ed5c0e23 | |||
0a98481991 | |||
5d62c48a35 | |||
0ab39023b0 | |||
7b202962e8 | |||
7a045b3f34 | |||
cb80c8bfe4 | |||
74c3197e47 | |||
6ae927ce71 | |||
9d13e607f5 | |||
0db3ef4a1f | |||
03e9fb431f | |||
b84df2866b | |||
3d4fef7c23 | |||
9d787008a4 | |||
687f26bb40 | |||
371a869800 | |||
321c303a8a | |||
2f176d84e9 | |||
67d4f09bd7 | |||
c40f009523 | |||
026f8c69d7 | |||
cacb808117 | |||
2d573edcac | |||
4287cd429d | |||
024dfeeeb7 | |||
f24bcba20b | |||
1d95edc455 | |||
|
ec24ab3edd | ||
a552e4d766 | |||
c3b1772728 | |||
786878a3e4 | |||
a5d98b76bd |
@@ -1,15 +1,15 @@
|
|||||||
*
|
*
|
||||||
|
|
||||||
!/backend/src
|
!/packages/backend/src
|
||||||
!/backend/Cargo.lock
|
!/packages/backend/Cargo.lock
|
||||||
!/backend/Cargo.toml
|
!/packages/backend/Cargo.toml
|
||||||
|
|
||||||
!/frontend/locales
|
!/packages/frontend/locales
|
||||||
!/frontend/src
|
!/packages/frontend/src
|
||||||
!/frontend/static
|
!/packages/frontend/static
|
||||||
!/frontend/.npmrc
|
!/packages/frontend/.npmrc
|
||||||
!/frontend/package.json
|
!/packages/frontend/package.json
|
||||||
!/frontend/pnpm-lock.yaml
|
!/packages/frontend/pnpm-lock.yaml
|
||||||
!/frontend/svelte.config.js
|
!/packages/frontend/svelte.config.js
|
||||||
!/frontend/tsconfig.json
|
!/packages/frontend/tsconfig.json
|
||||||
!/frontend/vite.config.js
|
!/packages/frontend/vite.config.js
|
||||||
|
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
|
||||||
|
5
.github/workflows/docker.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: ci
|
name: Publish
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -31,11 +31,8 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: docker_build
|
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
- name: Image digest
|
|
||||||
run: echo ${{ steps.docker_build.outputs.digest }}
|
|
||||||
|
36
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: "16"
|
||||||
|
|
||||||
|
- uses: docker/setup-qemu-action@v1
|
||||||
|
- uses: docker/setup-buildx-action@v1
|
||||||
|
with:
|
||||||
|
install: true
|
||||||
|
- name: Build docker image
|
||||||
|
run: npm run test:prepare
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: |
|
||||||
|
npm install playwright
|
||||||
|
npx playwright install --with-deps
|
||||||
|
|
||||||
|
- name: Run your tests
|
||||||
|
run: npm test
|
||||||
|
- uses: actions/upload-artifact@v2
|
||||||
|
if: always()
|
||||||
|
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
|
||||||
|
6
.vscode/settings.json
vendored
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"cSpell.words": ["ciphertext", "cryptgeon"],
|
|
||||||
"i18n-ally.localesPaths": ["frontend/locales"],
|
|
||||||
"i18n-ally.enabledFrameworks": ["svelte"],
|
|
||||||
"i18n-ally.keystyle": "nested"
|
|
||||||
}
|
|
78
CHANGELOG.md
@@ -5,27 +5,93 @@ 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.1.0] - 2023-01-04
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- QR Code to more easily copy and share links.
|
||||||
|
|
||||||
|
## [2.0.7] - 2022-12-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Svelte Kit now stable 🎉
|
||||||
|
|
||||||
|
## [2.0.6] - 2022-11-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- #66 Set minimum a view.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Updated dependencies.
|
||||||
|
|
||||||
|
## [2.0.5] - 2022-11-04
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Docker build pipeline.
|
||||||
|
|
||||||
|
## [2.0.4] - 2022-10-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `THEME_PAGE_TITLE`.
|
||||||
|
- `THEME_FAVICON`.
|
||||||
|
|
||||||
|
## [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
|
## [2.0.0] - 2022-07-16
|
||||||
|
|
||||||
### 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.
|
||||||
|
|
||||||
## [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
|
||||||
|
|
||||||
|
19
Dockerfile
@@ -2,24 +2,31 @@
|
|||||||
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
|
||||||
|
|
||||||
|
|
||||||
# BACKEND
|
# BACKEND
|
||||||
FROM rust:1.61-alpine as backend
|
FROM rust:1.64-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 build --release
|
# https://blog.rust-lang.org/2022/06/22/sparse-registry-testing.html
|
||||||
|
RUN rustup update nightly
|
||||||
|
ENV CARGO_UNSTABLE_SPARSE_REGISTRY=true
|
||||||
|
RUN cargo +nightly fetch
|
||||||
|
COPY ./packages/backend ./
|
||||||
|
RUN cargo +nightly build --release
|
||||||
|
|
||||||
|
|
||||||
# RUNNER
|
# RUNNER
|
||||||
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" ]
|
||||||
|
79
README.md
@@ -24,9 +24,9 @@ _cryptgeon_ is a secure, open source sharing note or file service inspired by [_
|
|||||||
>
|
>
|
||||||
> Thanks to [Lokalise](https://lokalise.com/) for providing free access to their platform.
|
> Thanks to [Lokalise](https://lokalise.com/) for providing free access to their platform.
|
||||||
|
|
||||||
## Demo
|
## Live Service / Demo
|
||||||
|
|
||||||
Check out the demo and see for yourself https://cryptgeon.nicco.io.
|
Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -51,14 +51,17 @@ 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. |
|
||||||
|
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
|
||||||
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
||||||
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
||||||
|
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
|
||||||
|
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
@@ -76,12 +79,16 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://redis.io/docs/manual/eviction/
|
||||||
|
command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: cupcakearmy/cryptgeon:latest
|
image: cupcakearmy/cryptgeon:latest
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
environment:
|
environment:
|
||||||
|
# Size limit for a single note.
|
||||||
SIZE_LIMIT: 4 MiB
|
SIZE_LIMIT: 4 MiB
|
||||||
ports:
|
ports:
|
||||||
- 80:5000
|
- 80:5000
|
||||||
@@ -93,39 +100,20 @@ 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:
|
### YouTube Guides
|
||||||
image: cupcakearmy/cryptgeon:latest
|
|
||||||
restart: unless-stopped
|
- English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
|
||||||
depends_on:
|
- German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
|
||||||
- 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 +153,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 下方的文本。 |
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
@@ -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).
|
16
cryptgeon.code-workspace
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "packages/backend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"cSpell.words": ["ciphertext", "cryptgeon"],
|
||||||
|
"i18n-ally.enabledFrameworks": ["svelte"],
|
||||||
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"i18n-ally.localesPaths": ["packages/frontend/locales"]
|
||||||
|
}
|
||||||
|
}
|
@@ -10,10 +10,10 @@ services:
|
|||||||
- 6379:6379
|
- 6379:6379
|
||||||
|
|
||||||
app:
|
app:
|
||||||
|
image: cupcakearmy/cryptgeon:test
|
||||||
build: .
|
build: .
|
||||||
|
env_file: .dev.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
environment:
|
|
||||||
SIZE_LIMIT: 128 MiB
|
|
||||||
ports:
|
ports:
|
||||||
- 80:5000
|
- 1234:5000
|
18
docker-compose.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
# Set a size limit. See link below on how to customise.
|
||||||
|
# https://redis.io/docs/manual/eviction/
|
||||||
|
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
||||||
|
|
||||||
|
app:
|
||||||
|
image: cupcakearmy/cryptgeon:latest
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
# Size limit for a single note.
|
||||||
|
SIZE_LIMIT: 4 MiB
|
||||||
|
ports:
|
||||||
|
- 80: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
|
||||||
|
```
|
@@ -1,5 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
/.svelte
|
|
||||||
/build
|
|
||||||
/functions
|
|
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "svelte-kit preview",
|
|
||||||
"check": "svelte-check --tsconfig tsconfig.json",
|
|
||||||
"licenses": "license-checker --summary > licenses.csv",
|
|
||||||
"locale:download": "node scripts/locale.js"
|
|
||||||
},
|
|
||||||
"type": "module",
|
|
||||||
"devDependencies": {
|
|
||||||
"@lokalise/node-api": "^7.3.1",
|
|
||||||
"@sveltejs/adapter-static": "^1.0.0-next.34",
|
|
||||||
"@sveltejs/kit": "^1.0.0-next.361",
|
|
||||||
"@types/dompurify": "^2.3.3",
|
|
||||||
"@types/file-saver": "^2.0.5",
|
|
||||||
"adm-zip": "^0.5.9",
|
|
||||||
"dotenv": "^16.0.1",
|
|
||||||
"svelte": "^3.49.0",
|
|
||||||
"svelte-check": "^2.8.0",
|
|
||||||
"svelte-intl-precompile": "^0.10.1",
|
|
||||||
"svelte-preprocess": "^4.10.7",
|
|
||||||
"tslib": "^2.4.0",
|
|
||||||
"typescript": "^4.7.4",
|
|
||||||
"vite": "^3.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@fontsource/fira-mono": "^4.5.8",
|
|
||||||
"copy-to-clipboard": "^3.3.1",
|
|
||||||
"dompurify": "^2.3.9",
|
|
||||||
"file-saver": "^2.0.5",
|
|
||||||
"pretty-bytes": "^5.6.0"
|
|
||||||
}
|
|
||||||
}
|
|
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}
|
|
13
package.json
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:docker": "docker-compose up redis",
|
"dev:docker": "docker-compose -f docker-compose.dev.yaml 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": "playwright test --project chrome firefox safari",
|
||||||
|
"test:local": "playwright test --project local",
|
||||||
|
"test:server": "docker compose -f docker-compose.dev.yaml up",
|
||||||
|
"test:prepare": "docker compose -f docker-compose.dev.yaml build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.29.1",
|
||||||
|
"@types/node": "^16.18.10",
|
||||||
"http-proxy": "^1.18.1",
|
"http-proxy": "^1.18.1",
|
||||||
"npm-run-all": "^4.1.5"
|
"npm-run-all": "^4.1.5"
|
||||||
}
|
}
|
||||||
|
439
backend/Cargo.lock → packages/backend/Cargo.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cryptgeon"
|
name = "cryptgeon"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
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,10 +1,15 @@
|
|||||||
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
|
||||||
@@ -37,4 +42,12 @@ lazy_static! {
|
|||||||
.unwrap_or("".to_string())
|
.unwrap_or("".to_string())
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
pub static ref THEME_PAGE_TITLE: String = std::env::var("THEME_PAGE_TITLE")
|
||||||
|
.unwrap_or("".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
|
pub static ref THEME_FAVICON: String = std::env::var("THEME_FAVICON")
|
||||||
|
.unwrap_or("".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
@@ -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;
|
||||||
}
|
}
|
@@ -49,7 +49,7 @@ async fn create(note: web::Json<Note>) -> impl Responder {
|
|||||||
}
|
}
|
||||||
match n.views {
|
match n.views {
|
||||||
Some(v) => {
|
Some(v) => {
|
||||||
if v > *config::MAX_VIEWS {
|
if v > *config::MAX_VIEWS || v < 1 {
|
||||||
return bad_req;
|
return bad_req;
|
||||||
}
|
}
|
||||||
n.expiration = None; // views overrides expiration
|
n.expiration = None; // views overrides expiration
|
@@ -7,5 +7,6 @@ pub fn init(cfg: &mut web::ServiceConfig) {
|
|||||||
let plain = web::PayloadConfig::default()
|
let plain = web::PayloadConfig::default()
|
||||||
.limit(*config::LIMIT)
|
.limit(*config::LIMIT)
|
||||||
.mimetype(mime::STAR_STAR);
|
.mimetype(mime::STAR_STAR);
|
||||||
|
// cfg.app_data(plain);
|
||||||
cfg.app_data(json).app_data(plain);
|
cfg.app_data(json).app_data(plain);
|
||||||
}
|
}
|
@@ -12,4 +12,6 @@ pub struct Status {
|
|||||||
// Theme
|
// Theme
|
||||||
pub theme_image: String,
|
pub theme_image: String,
|
||||||
pub theme_text: String,
|
pub theme_text: String,
|
||||||
|
pub theme_page_title: String,
|
||||||
|
pub theme_favicon: String,
|
||||||
}
|
}
|
@@ -13,6 +13,8 @@ async fn get_status() -> impl Responder {
|
|||||||
allow_advanced: *config::ALLOW_ADVANCED,
|
allow_advanced: *config::ALLOW_ADVANCED,
|
||||||
theme_image: config::THEME_IMAGE.to_string(),
|
theme_image: config::THEME_IMAGE.to_string(),
|
||||||
theme_text: config::THEME_TEXT.to_string(),
|
theme_text: config::THEME_TEXT.to_string(),
|
||||||
|
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
|
||||||
|
theme_favicon: config::THEME_FAVICON.to_string()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@@ -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,19 @@
|
|||||||
"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",
|
||||||
|
"qr_code": "qr-code"
|
||||||
},
|
},
|
||||||
"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 +29,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,19 @@
|
|||||||
"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",
|
||||||
|
"qr_code": "qr code"
|
||||||
},
|
},
|
||||||
"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 +29,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,19 @@
|
|||||||
"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",
|
||||||
|
"qr_code": "código qr"
|
||||||
},
|
},
|
||||||
"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 +29,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,19 @@
|
|||||||
"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",
|
||||||
|
"qr_code": "code qr"
|
||||||
},
|
},
|
||||||
"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 +29,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,19 @@
|
|||||||
"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",
|
||||||
|
"qr_code": "codice qr"
|
||||||
},
|
},
|
||||||
"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 +29,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,19 @@
|
|||||||
"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": "下载",
|
||||||
|
"qr_code": "二维码"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
|
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
|
||||||
@@ -23,12 +29,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": "查看密信",
|
38
packages/frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview --port 3000",
|
||||||
|
"check": "svelte-check --tsconfig tsconfig.json",
|
||||||
|
"licenses": "license-checker --summary > licenses.csv",
|
||||||
|
"locale:download": "node scripts/locale.js",
|
||||||
|
"test:prepare": "pnpm run build"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"devDependencies": {
|
||||||
|
"@lokalise/node-api": "^9.3.0",
|
||||||
|
"@sveltejs/adapter-static": "^1.0.0",
|
||||||
|
"@sveltejs/kit": "^1.0.1",
|
||||||
|
"@types/dompurify": "^2.4.0",
|
||||||
|
"@types/file-saver": "^2.0.5",
|
||||||
|
"@zerodevx/svelte-toast": "^0.7.2",
|
||||||
|
"adm-zip": "^0.5.10",
|
||||||
|
"dotenv": "^16.0.3",
|
||||||
|
"svelte": "^3.55.0",
|
||||||
|
"svelte-check": "^2.10.3",
|
||||||
|
"svelte-intl-precompile": "^0.10.1",
|
||||||
|
"svelte-preprocess": "^4.10.7",
|
||||||
|
"tslib": "^2.4.1",
|
||||||
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "^4.0.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/fira-mono": "^4.5.10",
|
||||||
|
"copy-to-clipboard": "^3.3.3",
|
||||||
|
"dompurify": "^2.4.1",
|
||||||
|
"file-saver": "^2.0.5",
|
||||||
|
"pretty-bytes": "^6.0.0",
|
||||||
|
"qrious": "^4.0.2"
|
||||||
|
}
|
||||||
|
}
|
@@ -1,7 +1,7 @@
|
|||||||
import dotenv from 'dotenv'
|
|
||||||
import { LokaliseApi } from '@lokalise/node-api'
|
import { LokaliseApi } from '@lokalise/node-api'
|
||||||
import https from 'https'
|
|
||||||
import AdmZip from 'adm-zip'
|
import AdmZip from 'adm-zip'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import https from 'https'
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -85,6 +87,8 @@ button {
|
|||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
background: inherit;
|
background: inherit;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
padding-inline: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
*:disabled,
|
*:disabled,
|
@@ -2,7 +2,6 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
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 |
@@ -9,6 +9,8 @@ export type Status = {
|
|||||||
allow_advanced: boolean
|
allow_advanced: boolean
|
||||||
theme_image: string
|
theme_image: string
|
||||||
theme_text: string
|
theme_text: string
|
||||||
|
theme_favicon: string
|
||||||
|
theme_page_title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const status = writable<null | Status>(null)
|
export const status = writable<null | Status>(null)
|
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),
|
||||||
|
}
|
55
packages/frontend/src/lib/ui/AdvancedParameters.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<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}
|
||||||
|
min={1}
|
||||||
|
validate={(v) =>
|
||||||
|
($status && v <= $status?.max_views && v > 0) ||
|
||||||
|
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
|
||||||
|
/>
|
||||||
|
<div class="middle-switch">
|
||||||
|
<Switch
|
||||||
|
data-testid="switch-advanced-toggle"
|
||||||
|
label={$t('common.mode')}
|
||||||
|
bind:value={timeExpiration}
|
||||||
|
color={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<TextInput
|
||||||
|
data-testid="field-expiration"
|
||||||
|
type="number"
|
||||||
|
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>
|
41
packages/frontend/src/lib/ui/Canvas.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import QR from 'qrious'
|
||||||
|
import { t } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
|
import { getCSSVariable } from '$lib/utils'
|
||||||
|
|
||||||
|
export let value: string
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement
|
||||||
|
|
||||||
|
$: {
|
||||||
|
new QR({
|
||||||
|
value,
|
||||||
|
level: 'Q',
|
||||||
|
size: 800,
|
||||||
|
background: getCSSVariable('--ui-bg-0'),
|
||||||
|
foreground: getCSSVariable('--ui-text-0'),
|
||||||
|
element: canvas,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<small>{$t('common.qr_code')}</small>
|
||||||
|
<div>
|
||||||
|
<canvas bind:this={canvas} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
div {
|
||||||
|
padding: 0.5rem;
|
||||||
|
width: fit-content;
|
||||||
|
border: 2px solid var(--ui-bg-1);
|
||||||
|
background-color: var(--ui-bg-0);
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
</style>
|
@@ -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}
|
49
packages/frontend/src/lib/ui/NoteResult.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<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'
|
||||||
|
import Canvas from './Canvas.svelte'
|
||||||
|
|
||||||
|
export let result: NoteResult
|
||||||
|
|
||||||
|
$: url = `${window.location.origin}/note/${result.id}#${result.password}`
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
label={$t('common.share_link')}
|
||||||
|
value={url}
|
||||||
|
copy
|
||||||
|
data-testid="share-link"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Canvas value={url} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{@html $t('home.new_note_notice')}
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<Button on:click={reset}>{$t('home.new_note')}</Button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
div {
|
||||||
|
width: min(12rem, 100%);
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
</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,6 +44,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
|
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
|
||||||
|
<div data-testid="result">
|
||||||
{#if note.meta.type === 'text'}
|
{#if note.meta.type === 'text'}
|
||||||
<div class="note">
|
<div class="note">
|
||||||
{@html contentWithLinks(note.contents)}
|
{@html contentWithLinks(note.contents)}
|
||||||
@@ -48,12 +53,15 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{#each files as file}
|
{#each files as file}
|
||||||
<div class="note file">
|
<div class="note file">
|
||||||
<b on:click={() => downloadFile(file)}>↓ {file.name}</b>
|
<button on:click={() => downloadFile(file)}>
|
||||||
|
<b>↓ {file.name}</b>
|
||||||
|
</button>
|
||||||
<small> {file.type} - {prettyBytes(file.size)}</small>
|
<small> {file.type} - {prettyBytes(file.size)}</small>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<Button on:click={download}>{$t('show.download_all')}</Button>
|
<Button on:click={download}>{$t('show.download_all')}</Button>
|
||||||
{/if}
|
{/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>
|
@@ -40,19 +40,19 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div on:click={change}>
|
<button on:click={change}>
|
||||||
<Icon class="icon" icon="contrast" />
|
<Icon class="icon" icon="contrast" />
|
||||||
{$theme}
|
{$theme}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
div :global(.icon) {
|
button :global(.icon) {
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
button {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
16
packages/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCSSVariable(variable: string): string {
|
||||||
|
if (typeof window === 'undefined') return ''
|
||||||
|
return window.getComputedStyle(window.document.body).getPropertyValue(variable)
|
||||||
|
}
|
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>
|
@@ -7,7 +7,9 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/">/home</a>
|
<a href="/">/home</a>
|
||||||
<a href="/about">/about</a>
|
<a href="/about">/about</a>
|
||||||
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener">/code</a>
|
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
|
||||||
|
code
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
@@ -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 { init as initStores } from '$lib/stores/status'
|
import { SvelteToast } from '@zerodevx/svelte-toast'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { waitLocale } from 'svelte-intl-precompile'
|
||||||
|
|
||||||
|
import '../app.css'
|
||||||
|
|
||||||
|
import { init as initStores, status } from '$lib/stores/status'
|
||||||
import Footer from '$lib/views/Footer.svelte'
|
import Footer from '$lib/views/Footer.svelte'
|
||||||
import Header from '$lib/views/Header.svelte'
|
import Header from '$lib/views/Header.svelte'
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import '../app.css'
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
initStores()
|
initStores()
|
||||||
@@ -19,7 +15,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>cryptgeon</title>
|
<title>{$status?.theme_page_title || 'cryptgeon'}</title>
|
||||||
|
<link rel="icon" href={$status?.theme_favicon || '/favicon.png'} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#await waitLocale() then _}
|
{#await waitLocale() then _}
|
||||||
@@ -28,6 +25,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>
|
||||||
@@ -43,7 +39,7 @@
|
|||||||
the backend is written in rust and the frontend is svelte and typescript.
|
the backend is written in rust and the frontend is svelte and typescript.
|
||||||
<br />
|
<br />
|
||||||
you are welcomed to check & audit the
|
you are welcomed to check & audit the
|
||||||
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener">
|
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
|
||||||
source code
|
source code
|
||||||
</a>.
|
</a>.
|
||||||
</span>
|
</span>
|
||||||
@@ -51,9 +47,12 @@
|
|||||||
|
|
||||||
<AboutParagraph title="translations">
|
<AboutParagraph title="translations">
|
||||||
<span
|
<span
|
||||||
>translations are managed on <a href="https://lokalise.com/" target="_blank">Lokalise</a>,
|
>translations are managed on <a
|
||||||
which granted an open source license to use the paid version. If you are interested in helping
|
href="https://lokalise.com/"
|
||||||
translating don't hesitate to contact me!
|
target="_blank"
|
||||||
|
rel="noopener noreferrer">Lokalise</a
|
||||||
|
>, which granted an open source license to use the paid version. If you are interested in
|
||||||
|
helping translating don't hesitate to contact me!
|
||||||
</span>
|
</span>
|
||||||
</AboutParagraph>
|
</AboutParagraph>
|
||||||
|
|
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: 'docker compose -f docker-compose.dev.yaml up',
|
||||||
|
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
|
1712
pnpm-lock.yaml
generated
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/**"
|
@@ -3,12 +3,13 @@ import httpProxy from 'http-proxy'
|
|||||||
|
|
||||||
const proxy = httpProxy.createProxyServer()
|
const proxy = httpProxy.createProxyServer()
|
||||||
proxy.on('error', function (err, req, res) {
|
proxy.on('error', function (err, req, res) {
|
||||||
|
console.error(err)
|
||||||
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
||||||
res.end('500 Internal Server Error')
|
res.end('500 Internal Server Error')
|
||||||
})
|
})
|
||||||
|
|
||||||
const server = http.createServer(function (req, res) {
|
const server = http.createServer(function (req, res) {
|
||||||
const target = req.url.startsWith('/api/') ? 'http://localhost:5000' : 'http://localhost:3000'
|
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:5000' : 'http://localhost:3000'
|
||||||
proxy.web(req, res, { target })
|
proxy.web(req, res, { target })
|
||||||
})
|
})
|
||||||
server.listen(1234)
|
server.listen(1234)
|
||||||
|