Compare commits
113 Commits
Author | SHA1 | Date | |
---|---|---|---|
cacb808117 | |||
2d573edcac | |||
4287cd429d | |||
024dfeeeb7 | |||
f24bcba20b | |||
1d95edc455 | |||
|
ec24ab3edd | ||
a552e4d766 | |||
c3b1772728 | |||
786878a3e4 | |||
a5d98b76bd | |||
9590c9b567 | |||
|
0913a8ad0c | ||
d13c712e95 | |||
6230d2dbd0 | |||
|
dbfb383c73 | ||
a257d2cefb | |||
35ba25ba9e | |||
724dca0e69 | |||
9029f72a02 | |||
1d55d7f2d2 | |||
d09bb4e0c6 | |||
53c7c9d9e2 | |||
df9c60c29e | |||
f29b6b23f0 | |||
cc88fa6763 | |||
19022e7cb5 | |||
44f43dbc2c | |||
45f6f3af32 | |||
9bd544f0d5 | |||
a315e58284 | |||
d576b71bc5 | |||
e02f7f59c6 | |||
e8c6467faa | |||
43f67c795d | |||
83f0902291 | |||
11a6621bd7 | |||
36fa451249 | |||
d112eba8fe | |||
ef39f9ec0b | |||
8517c20e6c | |||
728ad56b33 | |||
f185ccee03 | |||
284bbcbae2 | |||
7eba454f1b | |||
dcd9efaeba | |||
f13bcbaf3f | |||
8e7e0414a6 | |||
229c8d8368 | |||
1adf87b884 | |||
a061b540b1 | |||
824603ff4a | |||
539d99d35f | |||
716034660c | |||
bab65bcdad | |||
a0732a4593 | |||
835f7df0f6 | |||
2def365cae | |||
c8b2539414 | |||
c8a25eb9bf | |||
15bceb1715 | |||
8acc4108ae | |||
0f708f53c0 | |||
8d03ad8e15 | |||
33829768eb | |||
8cee6579e2 | |||
8eeb2a8de7 | |||
e4ce767444 | |||
00fd514da5 | |||
ba38d2b819 | |||
d0f83e6148 | |||
a040ad469e | |||
0c01866344 | |||
048c5198a2 | |||
f606916d97 | |||
aea85c3b73 | |||
5f904b3971 | |||
ac5d52a010 | |||
8644a937d0 | |||
a0ebb97bc5 | |||
19cd9b8507 | |||
fe653e91c8 | |||
a78ec72687 | |||
a462bed948 | |||
325518ba15 | |||
4b80912727 | |||
|
c78ad636c3 | ||
4fe7833977 | |||
24f9aeb229 | |||
976413e11b | |||
2480d875b4 | |||
5dff12ea70 | |||
e332dc63e8 | |||
a18e9bcc88 | |||
4b43edf54a | |||
e3aa2dd5ff | |||
98a03c25e6 | |||
7f618e7e45 | |||
84a7be4549 | |||
b2bad5f64c | |||
41f55c0920 | |||
edbf8a8ecf | |||
4852804581 | |||
22b1c35b3e | |||
d1e9ffd89b | |||
9c675ba48c | |||
ef3d3d5bde | |||
7e835af3f2 | |||
f153102978 | |||
5944c784ba | |||
2aa186450a | |||
70b53106ea | |||
b958189b0c |
@@ -1 +1,15 @@
|
||||
target
|
||||
*
|
||||
|
||||
!/backend/src
|
||||
!/backend/Cargo.lock
|
||||
!/backend/Cargo.toml
|
||||
|
||||
!/frontend/locales
|
||||
!/frontend/src
|
||||
!/frontend/static
|
||||
!/frontend/.npmrc
|
||||
!/frontend/package.json
|
||||
!/frontend/pnpm-lock.yaml
|
||||
!/frontend/svelte.config.js
|
||||
!/frontend/tsconfig.json
|
||||
!/frontend/vite.config.js
|
||||
|
1
.gitattributes
vendored
@@ -1 +1,2 @@
|
||||
*.afdesign filter=lfs diff=lfs merge=lfs -text
|
||||
test/assets/** filter=lfs diff=lfs merge=lfs -text
|
||||
|
BIN
.github/lokalise.png
vendored
Normal file
After Width: | Height: | Size: 30 KiB |
4
.github/workflows/docker.yml
vendored
@@ -14,6 +14,8 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v1
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
with:
|
||||
install: true
|
||||
- name: Docker Labels
|
||||
id: meta
|
||||
uses: crazy-max/ghaction-docker-meta@v2
|
||||
@@ -32,7 +34,7 @@ jobs:
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
- name: Image digest
|
||||
|
36
.github/workflows/test.yaml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "16"
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 7
|
||||
- uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: 1.61
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install
|
||||
pnpm run test:prepare
|
||||
- name: Install Playwright
|
||||
run: npx playwright install --with-deps
|
||||
- name: Run your tests
|
||||
run: pnpm run test:run
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results
|
6
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
|
||||
# Backend
|
||||
/target
|
||||
target
|
||||
|
||||
# Client
|
||||
.DS_Store
|
||||
@@ -8,3 +8,7 @@ node_modules
|
||||
/.svelte
|
||||
/build
|
||||
/functions
|
||||
.env
|
||||
|
||||
General
|
||||
test-results
|
||||
|
10
.vscode/settings.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"ciphertext",
|
||||
"cryptgeon"
|
||||
]
|
||||
}
|
||||
"cSpell.words": ["ciphertext", "cryptgeon"],
|
||||
"i18n-ally.localesPaths": ["packages/frontend/locales"],
|
||||
"i18n-ally.enabledFrameworks": ["svelte"],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
|
214
CHANGELOG.md
@@ -5,64 +5,252 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.0.3] - 2022-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- Flag for verbosity.
|
||||
|
||||
### Fixed
|
||||
|
||||
- #58 Fixed bug in the max views frontend form.
|
||||
|
||||
## [2.0.2] - 2022-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Toasts for events.
|
||||
- E2E Tests.
|
||||
- Make backend more configurable
|
||||
|
||||
## [2.0.1] - 2022-07-18
|
||||
|
||||
### Added
|
||||
|
||||
- Max file size on the client now.
|
||||
- Loading information.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed encoding from hex to base64.
|
||||
- Chinese language code.
|
||||
- Notable speed improvements for big files.
|
||||
|
||||
## [2.0.0] - 2022-07-16
|
||||
|
||||
### Added
|
||||
|
||||
- Theming for logo and description text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved to redis.
|
||||
- New html sanitizing library.
|
||||
|
||||
## [2.0.0-rc.0] - 2022-07-15
|
||||
|
||||
### Added
|
||||
|
||||
- Theming for logo and description text.
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved to redis.
|
||||
- New html sanitizing library.
|
||||
|
||||
## [1.5.3] - 2022-06-07
|
||||
|
||||
### Changed
|
||||
|
||||
- Use the value from the `MEMCACHE` env variable in startup script.
|
||||
|
||||
## [1.5.2] - 2022-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Wait for script for memecached.
|
||||
|
||||
### Security
|
||||
|
||||
- Updated dependencies.
|
||||
|
||||
## [1.5.1] - 2022-05-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove double note content.
|
||||
|
||||
## [1.5.0] - 2022-05-14
|
||||
|
||||
### Added
|
||||
|
||||
- Links in notes are not highlighted and can be directly clicked #30.
|
||||
|
||||
## [1.4.1] - 2022-03-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Router in prod build.
|
||||
|
||||
## [1.4.0] - 2022-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- Support for multiple languages.
|
||||
- Select multiple files without removing already selected ones.
|
||||
- Tooltip for copy action.
|
||||
- Configure maximum views, expiration and advanced options for the server.
|
||||
|
||||
### Changed
|
||||
|
||||
- Use native SVGs instead of images.
|
||||
- Update robots.txt file to allow only root.
|
||||
- Stronger frontend types.
|
||||
|
||||
## [1.3.3] - 2022-01-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug fix due to dependency update.
|
||||
|
||||
## [1.3.2] - 2022-01-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Dependencies updates.
|
||||
- Folder structure.
|
||||
|
||||
## [1.3.1] - 2021-12-30
|
||||
|
||||
### Added
|
||||
|
||||
- Short explanation in the home page.
|
||||
|
||||
### Changed
|
||||
|
||||
- Explanation in about & readme.
|
||||
- Shorten server ids from 512 to 256bit.
|
||||
|
||||
## [1.3.0] - 2021-12-22
|
||||
|
||||
### Added
|
||||
|
||||
- Option to set a custom size limit.
|
||||
- Options to share files.
|
||||
|
||||
### Changed
|
||||
|
||||
- Don't delete note if time is not expired yet
|
||||
- Use pnpm instead of npm.
|
||||
|
||||
## [1.2.0] - 2021-11-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Switch to pnpm.
|
||||
|
||||
### Security
|
||||
|
||||
- Dependencies updated.
|
||||
|
||||
## [1.1.1] - 2021-05-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- Height on big displays.
|
||||
- About page.
|
||||
|
||||
## [1.1.0] - 2021-05-16
|
||||
|
||||
### Security
|
||||
|
||||
- Using hash `#` instead of path.
|
||||
|
||||
## [1.0.11] - 2021-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- loading text.
|
||||
- description for created notes about availability.
|
||||
|
||||
### Changed
|
||||
|
||||
- iterations from 100 to 100k.
|
||||
|
||||
### Fixed
|
||||
|
||||
- time based view bug.
|
||||
|
||||
## [1.0.10] - 2021-05-08
|
||||
|
||||
### Fixed
|
||||
|
||||
- API endpoint was not reachable.
|
||||
|
||||
## [1.0.9] - 2021-05-07
|
||||
|
||||
## Changed
|
||||
|
||||
- Removed a dependency.
|
||||
|
||||
## [1.0.8] - 2021-05-05
|
||||
|
||||
### Added
|
||||
|
||||
- Manual theme override option
|
||||
- Manual theme override option.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed Arm builds for now
|
||||
- iOS style bugs
|
||||
- Removed Arm builds for now.
|
||||
- iOS style bugs.
|
||||
|
||||
## [1.0.7] - 2021-05-04
|
||||
|
||||
### Added
|
||||
|
||||
- Arm images
|
||||
- Arm images.
|
||||
|
||||
## [1.0.6] - 2021-05-04
|
||||
|
||||
### Added
|
||||
|
||||
- Always use encryption with random passwords included links
|
||||
- Always use encryption with random passwords included links.
|
||||
|
||||
## [1.0.5] - 2021-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Typos
|
||||
- Typos.
|
||||
|
||||
## [1.0.4] - 2021-05-02
|
||||
|
||||
### Added
|
||||
|
||||
- From scratch docker image
|
||||
- From scratch docker image.
|
||||
|
||||
## [1.0.3] - 2021-05-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- Higher default text area
|
||||
- Mobile touchups
|
||||
- Higher default text area.
|
||||
- Mobile touchups.
|
||||
|
||||
## [1.0.2] - 2021-05-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- SVG Icons
|
||||
- SVG Icons.
|
||||
|
||||
## [1.0.1] - 2021-05-02
|
||||
|
||||
### Added
|
||||
|
||||
- Dark mode support
|
||||
- Dark mode support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Don't reload data on wrong password
|
||||
- Don't reload data on wrong password.
|
||||
|
||||
## [1.0.0] - 2021-05-02
|
||||
|
||||
Initial release
|
||||
Initial release.
|
||||
|
2030
Cargo.lock
generated
37
Dockerfile
@@ -1,26 +1,29 @@
|
||||
FROM node:16-alpine as CLIENT
|
||||
|
||||
# FRONTEND
|
||||
FROM node:16-alpine as client
|
||||
WORKDIR /tmp
|
||||
COPY ./client ./
|
||||
RUN npm install -g pnpm@7
|
||||
COPY ./packages/frontend ./
|
||||
RUN pnpm install
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN pnpm run build
|
||||
|
||||
RUN npm ci
|
||||
RUN npm run build
|
||||
|
||||
FROM rust:1.51-alpine as RUST
|
||||
|
||||
# BACKEND
|
||||
FROM rust:1.61-alpine as backend
|
||||
WORKDIR /tmp
|
||||
RUN apk add libc-dev openssl-dev alpine-sdk
|
||||
COPY ./Cargo* ./
|
||||
COPY ./src ./src
|
||||
|
||||
COPY ./packages/backend/Cargo.* ./
|
||||
RUN cargo fetch
|
||||
COPY ./packages/backend ./
|
||||
RUN cargo build --release
|
||||
|
||||
FROM scratch
|
||||
|
||||
# RUNNER
|
||||
FROM alpine
|
||||
WORKDIR /app
|
||||
COPY --from=RUST /tmp/target/release/cryptgeon .
|
||||
COPY --from=CLIENT /tmp/build ./client/build
|
||||
|
||||
ENV MEMCACHE=memcached:11211
|
||||
|
||||
ENTRYPOINT [ "/app/cryptgeon" ]
|
||||
COPY --from=backend /tmp/target/release/cryptgeon .
|
||||
COPY --from=client /tmp/build ./frontend
|
||||
ENV FRONTEND_PATH="./frontend"
|
||||
ENV REDIS="redis://redis/"
|
||||
EXPOSE 5000
|
||||
ENTRYPOINT [ "/app/cryptgeon" ]
|
||||
|
144
README.md
@@ -1,70 +1,170 @@
|
||||
<p align="center">
|
||||
<img src="./design/Github.png">
|
||||
<img src="./design/Github.png" alt="logo">
|
||||
</p>
|
||||
|
||||

|
||||

|
||||

|
||||
<a href="https://discord.gg/nuby6RnxZt">
|
||||
<img alt="discord" src="https://img.shields.io/discord/252403122348097536?style=for-the-badge" />
|
||||
<img alt="docker pulls" src="https://img.shields.io/docker/pulls/cupcakearmy/cryptgeon?style=for-the-badge" />
|
||||
<img alt="Docker image size badge" src="https://img.shields.io/docker/image-size/cupcakearmy/cryptgeon?style=for-the-badge" />
|
||||
<img alt="Latest version" src="https://img.shields.io/github/v/release/cupcakearmy/cryptgeon?style=for-the-badge" />
|
||||
</a>
|
||||
|
||||
<br/><br/>
|
||||
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
||||
<a href=""><img src="./.github/lokalise.png" height="50">
|
||||
<br/><br/>
|
||||
|
||||
EN | [简体中文](README_zh-CN.md)
|
||||
|
||||
## About?
|
||||
|
||||
_cryptgeon_ is a secure, open source sharing note service inspired by [_PrivNote_](https://privnote.com)
|
||||
_cryptgeon_ is a secure, open source sharing note or file service inspired by [_PrivNote_](https://privnote.com)
|
||||
|
||||
> 🌍 If you want to translate the project feel free to reach out to me.
|
||||
>
|
||||
> Thanks to [Lokalise](https://lokalise.com/) for providing free access to their platform.
|
||||
|
||||
## Demo
|
||||
|
||||
Check out the demo and see for yourself https://cryptgeon.nicco.io.
|
||||
Check out the demo and see for yourself [cryptgeon.org](https://cryptgeon.org)
|
||||
|
||||
## Features
|
||||
|
||||
- server cannot decrypt contents due to client side encryption
|
||||
- view and time constraints
|
||||
- view or time constraints
|
||||
- in memory, no persistence
|
||||
- obligatory dark mode support
|
||||
|
||||
## How does it work?
|
||||
|
||||
each note has a 512bit generated <i>id</i> that is used to retrieve the note. data is stored in memory and never persisted to disk.
|
||||
each note has a generated <code>id (256bit)</code> and <code>key 256(bit)</code>. The
|
||||
<code>id</code>
|
||||
is used to save & retrieve the note. the note is then encrypted with aes in gcm mode on the
|
||||
client side with the <code>key</code> and then sent to the server. data is stored in memory and
|
||||
never persisted to disk. the server never sees the encryption key and cannot decrypt the contents
|
||||
of the notes even if it tried to.
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
||||
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
|
||||
| `MAX_VIEWS` | `100` | Maximal number of views. |
|
||||
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
|
||||
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
|
||||
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
||||
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
||||
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
|
||||
|
||||
## Deployment
|
||||
|
||||
ℹ️ `https` is required otherwise browsers will not support the cryptographic functions.
|
||||
|
||||
### Docker
|
||||
|
||||
Docker is the easiest way. There is the [official image here](https://hub.docker.com/r/cupcakearmy/cryptgeon).
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
version: '3.7'
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
memcached:
|
||||
image: memcached:1-alpine
|
||||
entrypoint: memcached -m 128 # Limit to 128 MB Ram, customize at free will.
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
depends_on:
|
||||
- memcache
|
||||
- redis
|
||||
environment:
|
||||
SIZE_LIMIT: 4 MiB
|
||||
ports:
|
||||
- 80:5000
|
||||
```
|
||||
|
||||
### NGINX Proxy
|
||||
|
||||
See the [examples/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/nginx) folder. There an example with a simple proxy, and one with https. You need to specify the server names and certificates.
|
||||
|
||||
### Traefik 2
|
||||
|
||||
See the [examples/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik) folder.
|
||||
|
||||
### Scratch
|
||||
|
||||
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.
|
||||
|
||||
### Synology
|
||||
|
||||
There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) you can follow.
|
||||
|
||||
## Development
|
||||
|
||||
1. Clone
|
||||
2. run `npm i` in the root and and client `client/` folders.
|
||||
3. Run `npm run dev` to start development.
|
||||
**Requirements**
|
||||
|
||||
Running `npm run dev` in the root folder will start the following things
|
||||
- `pnpm`: `>=6`
|
||||
- `node`: `>=16`
|
||||
- `rust`: edition `2021`
|
||||
|
||||
- a memcache docker container
|
||||
- rust backend with hot reload
|
||||
- client with hot reload
|
||||
**Install**
|
||||
|
||||
You can see the app under [localhost:3000](http://localhost:3000).
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --prefix frontend install
|
||||
|
||||
# Also you need cargo watch if you don't already have it installed.
|
||||
# https://lib.rs/crates/cargo-watch
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
**Run**
|
||||
|
||||
Make sure you have docker running.
|
||||
|
||||
> If you are on `macOS` you might need to disable AirPlay Receiver as it uses port 5000 (So stupid...)
|
||||
> https://developer.apple.com/forums/thread/682332
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Running `pnpm run dev` in the root folder will start the following things:
|
||||
|
||||
- redis docker container
|
||||
- rust backend
|
||||
- client
|
||||
|
||||
You can see the app under [localhost:1234](http://localhost:1234).
|
||||
|
||||
## Tests
|
||||
|
||||
Tests are end to end tests written with Playwright.
|
||||
|
||||
```sh
|
||||
pnpm run test:prepare
|
||||
docker compose up redis -d
|
||||
pnpm run test:server
|
||||
|
||||
# In another terminal.
|
||||
# Use the test or test:local script. The local version only runs in one browser for quicker development.
|
||||
pnpm run test:local
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
Please refer to the security section [here](./SECURITY.md).
|
||||
|
||||
###### Attributions
|
||||
|
||||
Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
|
||||
- Test data:
|
||||
- Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
|
||||
- [AES Paper](https://www.cs.miami.edu/home/burt/learning/Csc688.012/rijndael/rijndael_doc_V2.pdf)
|
||||
- [Unsplash Pictures](https://unsplash.com/)
|
||||
- Loading animation by [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa)
|
||||
- Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
|
||||
|
187
README_zh-CN.md
Normal file
@@ -0,0 +1,187 @@
|
||||
<p align="center">
|
||||
<img src="./design/Github_zh-CN.png" alt="logo">
|
||||
</p>
|
||||
|
||||
<a href="https://discord.gg/nuby6RnxZt">
|
||||
<img alt="discord" src="https://img.shields.io/discord/252403122348097536?style=for-the-badge" />
|
||||
<img alt="docker pulls" src="https://img.shields.io/docker/pulls/cupcakearmy/cryptgeon?style=for-the-badge" />
|
||||
<img alt="Docker image size badge" src="https://img.shields.io/docker/image-size/cupcakearmy/cryptgeon?style=for-the-badge" />
|
||||
<img alt="Latest version" src="https://img.shields.io/github/v/release/cupcakearmy/cryptgeon?style=for-the-badge" />
|
||||
</a>
|
||||
|
||||
<br/>
|
||||
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
|
||||
<a href=""><img src="./.github/lokalise.png" height="50">
|
||||
<br/>
|
||||
|
||||
[EN](README.md) | 简体中文
|
||||
|
||||
## 关于本项目
|
||||
|
||||
_加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器
|
||||
|
||||
> 🌍 如果你想翻译此项目请随时与我联系.
|
||||
>
|
||||
> 感谢 [Lokalise](https://lokalise.com/) 提供免费的平台服务支持
|
||||
|
||||
## 演示示例
|
||||
|
||||
查看加密鸽的在线演示 demo: [cryptgeon.org](https://cryptgeon.org)
|
||||
|
||||
## 功能
|
||||
|
||||
- 服务端无法解密和查看客户端加密的内容
|
||||
- 查看次数或时间限制,阅后即焚
|
||||
- 您发送的数据将存放于内存中,不会写入到磁盘中
|
||||
- 黑暗模式支持
|
||||
|
||||
## 加密鸽是如何工作的?
|
||||
|
||||
加密鸽会为每条笔记都生成一个独立的 <code>id (256bit)</code> 和 <code>key 256(bit)</code>。
|
||||
|
||||
其中<code>id</code>用于保存和提取密信, 在这之后这封密信将会被客户端使用 AES 算法的 GCM 模式和`key`进行加密然后发送至服务器,数据将会保存在服务器的内存中且永远不会被持久化到硬盘上,服务端永远不会得到密钥并且无法解读密信的内容。
|
||||
|
||||
## 屏幕截图
|
||||
|
||||

|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量名称 | 默认值 | 描述 |
|
||||
| ----------------- | ---------------- | --------------------------------------------------------------------------------- |
|
||||
| `REDIS` | `redis://redis/` | Redis 连接 URL。 |
|
||||
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
|
||||
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
|
||||
| ` MAX_EXPIRATION` | `360` | 密信最长过期时间限制(分钟) |
|
||||
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
|
||||
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
|
||||
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
|
||||
|
||||
## 部署
|
||||
|
||||
ℹ️ 加密鸽必须使用`https`,否则浏览器可能将不会支援加密鸽的加密算法。
|
||||
|
||||
### Docker
|
||||
|
||||
Docker 是最简单的部署方式。这里是[官方镜像的地址](https://hub.docker.com/r/cupcakearmy/cryptgeon)。
|
||||
|
||||
附:译者的[部署笔记](https://www.hash070.top/archives/cryptgeon-docker-deploy.html)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
depends_on:
|
||||
- redis
|
||||
environment:
|
||||
SIZE_LIMIT: 4 MiB
|
||||
ports:
|
||||
- 80:5000
|
||||
```
|
||||
|
||||
### NGINX 反向代理
|
||||
|
||||
查看 [examples/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/nginx) 目录。那里有几个示例反代配置文件模板,其中一个是带 https 配置的反代配置模板,你需要指定服务器的名称和证书才能生效。
|
||||
|
||||
### Traefik 2
|
||||
|
||||
假设:
|
||||
|
||||
- 外部 Docker 代理网络 `proxy`
|
||||
- 证书解析器 `le`
|
||||
- 一个 https 入站点 `secure`
|
||||
- 域名 `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
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
**环境要求**
|
||||
|
||||
- `pnpm`: `>=6`
|
||||
- `node`: `>=14`
|
||||
- `rust`: edition `2021`
|
||||
|
||||
**安装**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm --prefix frontend install
|
||||
|
||||
# 你还需要安装CargoWatch.
|
||||
# https://lib.rs/crates/cargo-watch
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
**运行**
|
||||
|
||||
确保你的 Docker 正在运行
|
||||
|
||||
> 如果你用的是 `macOS` 的话你可能需要关闭 AirPlay 接收功能因为该功能需要占用 5000 端口...)
|
||||
> https://developer.apple.com/forums/thread/682332
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
在根目录执行 `pnpm run dev` 会开启下列服务:
|
||||
|
||||
- 一个 redis docker 容器
|
||||
- 无热重载的 rust 后端
|
||||
- 可热重载的客户端
|
||||
|
||||
你可以通过 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
|
||||
|
||||
- 测试数据:
|
||||
- 测试文本 [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
|
||||
- [AES Paper](https://www.cs.miami.edu/home/burt/learning/Csc688.012/rijndael/rijndael_doc_V2.pdf)
|
||||
- [Unsplash Pictures](https://unsplash.com/)
|
||||
- 加载动画由 [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa) 提供
|
||||
- 图标由来自 <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a> 的 <a href="https://www.freepik.com" title="Freepik">freepik</a> 提供
|
18
SECURITY.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Please ensure that you are using the latest major version available.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | --------- |
|
||||
| 2.x | ✅ |
|
||||
| < 1.x | ❌ |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
_cryptgeon_ has a full disclosure vulnerability policy.
|
||||
Report any bug / vulnerability directly to the [issue tracker](https://github.com/cupcakearmy/cryptgeon/issues).
|
||||
Please do NOT attempt to report any security vulnerability in this code privately to anybody.
|
||||
|
||||
> Shamefully copied of the [ring security section](https://github.com/briansmith/ring#bug-reporting).
|
@@ -1,38 +0,0 @@
|
||||
# create-svelte
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte);
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm init svelte@next
|
||||
|
||||
# create a new project in my-app
|
||||
npm init svelte@next my-app
|
||||
```
|
||||
|
||||
> Note: the `@next` is temporary
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Before creating a production version of your app, install an [adapter](https://kit.svelte.dev/docs#adapters) for your target environment. Then:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
> You can preview the built app with `npm run preview`, regardless of whether you installed an adapter. This should _not_ be used to serve your app in production.
|
@@ -1,7 +0,0 @@
|
||||
├─ MIT: 43
|
||||
├─ MIT*: 2
|
||||
├─ BSD-3-Clause: 2
|
||||
├─ ISC: 1
|
||||
├─ 0BSD: 1
|
||||
└─ Apache-2.0: 1
|
||||
|
|
1000
client/package-lock.json
generated
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "svelte-kit dev",
|
||||
"build": "svelte-kit build",
|
||||
"preview": "svelte-kit preview",
|
||||
"licenses": "npx license-checker --summary > licenses.csv"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "next",
|
||||
"@sveltejs/kit": "next",
|
||||
"svelte": "^3.34.0",
|
||||
"svelte-preprocess": "^4.0.0",
|
||||
"tslib": "^2.0.0",
|
||||
"typescript": "^4.0.0",
|
||||
"vite": "^2.1.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/fira-mono": "^4.2.2",
|
||||
"axios": "^0.21.1",
|
||||
"copy-to-clipboard": "^3.3.1"
|
||||
}
|
||||
}
|
@@ -1,37 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import { dev } from '$app/env'
|
||||
|
||||
const base = axios.create({ baseURL: dev ? 'http://localhost:5000' : undefined })
|
||||
|
||||
export type Note = {
|
||||
contents: string
|
||||
views?: number
|
||||
expiration?: number
|
||||
}
|
||||
export type NoteInfo = {}
|
||||
export type NotePublic = Pick<Note, 'contents'>
|
||||
|
||||
export async function create(note: Note) {
|
||||
const { data } = await base({
|
||||
url: '/api/notes',
|
||||
method: 'post',
|
||||
data: note,
|
||||
})
|
||||
return data as { id: string }
|
||||
}
|
||||
|
||||
export async function get(id: string) {
|
||||
const { data } = await base({
|
||||
url: `/api/notes/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
return data as NotePublic
|
||||
}
|
||||
|
||||
export async function info(id: string) {
|
||||
const { data } = await base({
|
||||
url: `/api/notes/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
return data as NoteInfo
|
||||
}
|
@@ -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 = 1_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,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
export let icon: string = ''
|
||||
export let href: string = ''
|
||||
|
||||
$: src = href || `/icons/${icon}.svg`
|
||||
|
||||
let html = null
|
||||
|
||||
onMount(async () => {
|
||||
html = await fetch(src).then((res) => res.text())
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if html === null}
|
||||
<img on:click {...$$restProps} {src} alt={icon} />
|
||||
{:else}
|
||||
<div on:click {...$$restProps}>
|
||||
{@html html}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
img,
|
||||
div {
|
||||
display: inline-block;
|
||||
contain: strict;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
div > :global(svg) {
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
@@ -1,34 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let label: string = ''
|
||||
export let value: string
|
||||
</script>
|
||||
|
||||
<label>
|
||||
<small>
|
||||
{label}
|
||||
</small>
|
||||
<textarea {...$$restProps} bind:value />
|
||||
</label>
|
||||
|
||||
<style>
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: calc(100vh - 30rem);
|
||||
margin: 0;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 30rem) {
|
||||
textarea {
|
||||
min-height: calc(100vh - 25rem);
|
||||
}
|
||||
}
|
||||
|
||||
textarea:hover,
|
||||
textarea:focus {
|
||||
border-color: var(--ui-clr-primary);
|
||||
}
|
||||
</style>
|
@@ -1,146 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Note } from '$lib/api'
|
||||
import { create } from '$lib/api'
|
||||
import { getKeyFromString, encrypt, Hex, getRandomBytes } from '$lib/crypto'
|
||||
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import Switch from '$lib/ui/Switch.svelte'
|
||||
import TextArea from '$lib/ui/TextArea.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
|
||||
let note: Note = {
|
||||
contents: '',
|
||||
views: 1,
|
||||
expiration: 60,
|
||||
}
|
||||
let result: { password: string; id: string } | null = null
|
||||
let advanced = false
|
||||
let type = false
|
||||
let message = ''
|
||||
let loading = false
|
||||
let error: string | null = null
|
||||
|
||||
$: if (!advanced) {
|
||||
note.views = 1
|
||||
type = false
|
||||
}
|
||||
|
||||
$: {
|
||||
let fraction: string
|
||||
fraction = type ? `${note.expiration} minutes` : `${note.views} views`
|
||||
message = 'the note will expire and be destroyed after ' + fraction
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
error = null
|
||||
loading = true
|
||||
const password = Hex.encode(getRandomBytes(32))
|
||||
const key = await getKeyFromString(password)
|
||||
const data: Note = {
|
||||
contents: await encrypt(note.contents, key),
|
||||
}
|
||||
// @ts-ignore
|
||||
if (type) data.expiration = parseInt(note.expiration)
|
||||
// @ts-ignore
|
||||
else data.views = parseInt(note.views)
|
||||
|
||||
const response = await create(data)
|
||||
result = {
|
||||
password: password,
|
||||
id: response.id,
|
||||
}
|
||||
} catch {
|
||||
error = 'could not create note.'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if result}
|
||||
<TextInput
|
||||
type="text"
|
||||
readonly
|
||||
value="{window.location.origin}/note/{result.id}/{result.password}"
|
||||
copy
|
||||
/>
|
||||
<br />
|
||||
<Button on:click={reset}>new</Button>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<fieldset disabled={loading}>
|
||||
<TextArea label="note" bind:value={note.contents} placeholder="..." />
|
||||
|
||||
<div class="bottom">
|
||||
<Switch label="advanced" bind:value={advanced} />
|
||||
<Button type="submit">create</Button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-text">{error}</div>
|
||||
{/if}
|
||||
|
||||
<p><br />{message}</p>
|
||||
|
||||
<div class="advanced" class:hidden={!advanced}>
|
||||
<br />
|
||||
<div class="fields">
|
||||
<TextInput
|
||||
type="number"
|
||||
label="views"
|
||||
bind:value={note.views}
|
||||
disabled={type}
|
||||
max={100}
|
||||
/>
|
||||
<div class="middle-switch">
|
||||
<Switch label="mode" bind:value={type} color={false} />
|
||||
</div>
|
||||
<TextInput
|
||||
type="number"
|
||||
label="minutes"
|
||||
bind:value={note.expiration}
|
||||
disabled={!type}
|
||||
max={360}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fields {
|
||||
display: flex;
|
||||
}
|
||||
.spacer {
|
||||
width: 3rem;
|
||||
}
|
||||
</style>
|
||||
</fieldset>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.middle-switch {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.advanced {
|
||||
max-height: 14em;
|
||||
overflow: hidden;
|
||||
transition: var(--ui-anim);
|
||||
}
|
||||
|
||||
.advanced.hidden {
|
||||
max-height: 0;
|
||||
}
|
||||
</style>
|
@@ -1,82 +0,0 @@
|
||||
<header>
|
||||
<a href="/">
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 450 200"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xml:space="preserve"
|
||||
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"
|
||||
><g
|
||||
><clipPath id="_clip1"><rect x="6.336" y="3.225" width="193.55" height="193.55" /></clipPath
|
||||
><g clip-path="url(#_clip1)"
|
||||
><g
|
||||
><g
|
||||
><path
|
||||
d="M173.425,43.296c-2.087,-0 -3.78,1.693 -3.78,3.78c-0,2.087 1.693,3.78 3.78,3.78c2.087,0 3.78,-1.693 3.78,-3.78c0,-2.087 -1.693,-3.78 -3.78,-3.78Z"
|
||||
style="fill-rule:nonzero;"
|
||||
/></g
|
||||
></g
|
||||
><g
|
||||
><g
|
||||
><path
|
||||
d="M103.112,134.023c-2.087,-0 -3.781,1.693 -3.781,3.78c0,2.087 1.694,3.78 3.781,3.78c2.086,0 3.78,-1.693 3.78,-3.78c-0,-2.087 -1.694,-3.78 -3.78,-3.78Z"
|
||||
style="fill-rule:nonzero;"
|
||||
/></g
|
||||
></g
|
||||
><g
|
||||
><g
|
||||
><path
|
||||
d="M156.036,96.22c-2.088,-0 -3.781,1.692 -3.781,3.78c0,18.76 -15.262,34.023 -34.022,34.023c-2.088,-0 -3.781,1.692 -3.781,3.78c0,2.088 1.693,3.78 3.781,3.78c22.929,0 41.583,-18.654 41.583,-41.583c-0,-2.088 -1.693,-3.78 -3.78,-3.78Z"
|
||||
style="fill-rule:nonzero;"
|
||||
/></g
|
||||
></g
|
||||
><g
|
||||
><g
|
||||
><path
|
||||
d="M199.488,60.507l-9.515,-19.026c-4.102,-8.207 -12.35,-13.306 -21.527,-13.306c-7.479,-0 -14.626,3.547 -19.154,9.498l-10.679,12.016c-2.846,-4.047 -7.021,-7.049 -11.83,-8.421l-19.102,-5.459c-14.623,-4.178 -28.92,-15.441 -39.227,-30.901c-0.924,-1.386 -2.646,-2.003 -4.241,-1.521c-1.594,0.483 -2.684,1.953 -2.684,3.618l-0,20.372c-0,9.468 1.417,18.804 4.219,27.813c-2.936,0.73 -5.896,1.34 -8.843,1.816c-5.772,0.936 -11.653,1.411 -17.48,1.411l-29.308,-0c-1.374,-0 -2.64,0.746 -3.307,1.948c-0.666,1.202 -0.627,2.671 0.101,3.836l22.637,36.219c9.672,15.473 26.329,25.183 44.578,25.983l-36.36,41.158c-5.602,5.728 -3.655,15.315 3.746,18.396l20.017,9.887c0.089,0.044 0.179,0.084 0.271,0.121c5.79,2.313 12.389,-0.496 14.726,-6.279l13.969,-32.982l27.738,0c31.966,0 58.972,-25.967 58.972,-56.704l0,-22.682c0,-6.253 5.088,-11.341 11.341,-11.341l7.56,0c1.311,0 2.528,-0.678 3.216,-1.793c0.689,-1.114 0.752,-2.506 0.166,-3.677Zm-130.399,-42.236c10.418,12.203 23.307,21.034 36.515,24.808l19.103,5.459c3.726,1.063 6.866,3.624 8.666,7.048l-10.048,11.307l-17.652,-10.591c-7.646,-4.588 -16.746,-6.412 -25.776,-4.951c-2.838,0.46 -4.649,1.038 -6.877,1.759c-2.609,-8.332 -3.931,-16.971 -3.931,-25.733l0,-9.106Zm119.457,40.146c-10.422,-0 -18.901,8.479 -18.901,18.901l-0,22.682c-0,26.639 -23.544,49.144 -51.412,49.144l-30.243,-0c-10.77,-0 -20.451,5.983 -25.265,15.615l-0.797,1.596c-0.934,1.867 -0.177,4.137 1.691,5.071c1.867,0.934 4.138,0.176 5.071,-1.691c0.44,-0.586 3.306,-9.102 13.21,-12.125l-12.349,29.159c-0.01,0.024 -0.02,0.047 -0.029,0.071c-0.75,1.877 -2.864,2.851 -4.8,2.148c-21.279,-10.506 -19.997,-9.888 -20.252,-9.99c-2.526,-1.01 -3.191,-4.259 -1.267,-6.182c0.13,-0.131 8.026,-9.078 41.009,-46.411c13.867,-0.617 26.841,-6.319 36.694,-16.172c1.476,-1.476 1.476,-3.87 0,-5.346c-1.476,-1.477 -3.87,-1.476 -5.346,-0c-16.827,16.828 -36.803,13.634 -39.028,14.014c-16.603,0 -31.771,-8.407 -40.573,-22.488l-2.417,-3.868l2.729,1.065c17.307,6.753 38.919,4.347 53.816,-5.586c1.737,-1.158 2.207,-3.505 1.049,-5.242c-1.159,-1.737 -3.505,-2.207 -5.243,-1.048c-13.085,8.724 -31.922,10.666 -46.875,4.832l-12.185,-4.753l-9.896,-15.836l22.488,0c6.231,0 12.519,-0.507 18.689,-1.507c12.711,-2.055 18.051,-4.855 22.992,-5.655c7.182,-1.163 14.516,0.274 20.678,3.97l29.625,17.775c1.789,1.074 4.112,0.494 5.186,-1.296c1.075,-1.79 0.495,-4.113 -1.295,-5.187l-5.378,-3.226c26.56,-29.893 25.14,-28.272 25.32,-28.511c3.101,-4.136 8.038,-6.605 13.204,-6.605c6.294,0 11.951,3.497 14.764,9.127l6.779,13.555l-1.443,-0Z"
|
||||
style="fill-rule:nonzero;"
|
||||
/></g
|
||||
></g
|
||||
></g
|
||||
><text
|
||||
x="197.239px"
|
||||
y="127.131px"
|
||||
style="font-family:'Sofia-Regular', 'Sofia';font-size:60.681px;">cryptgeon</text
|
||||
></g
|
||||
></svg
|
||||
>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
a {
|
||||
border: none;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
margin-top: 4rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 30rem) {
|
||||
header {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
header svg {
|
||||
max-height: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
header svg {
|
||||
width: 100%;
|
||||
max-width: 16rem;
|
||||
transform: translateX(-1rem);
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Footer from '$lib/views/Footer.svelte'
|
||||
import Header from '$lib/views/Header.svelte'
|
||||
|
||||
import '../app.css'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>cryptgeon</title>
|
||||
</svelte:head>
|
||||
|
||||
<main>
|
||||
<Header />
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
main {
|
||||
padding: 1rem;
|
||||
padding-bottom: 4rem;
|
||||
width: 100%;
|
||||
max-width: 35rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
@@ -1,66 +0,0 @@
|
||||
<script context="module">
|
||||
import { browser, dev } from '$app/env'
|
||||
export const hydrate = dev
|
||||
export const router = browser
|
||||
export const prerender = true
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>About</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="content">
|
||||
<h1>About</h1>
|
||||
|
||||
<p>
|
||||
<i>cryptgeon</i> is a secure, open source sharing note service inspired by
|
||||
<a href="https://privnote.com"><i>PrivNote</i></a>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>▶ how does it work?</b>
|
||||
<br />
|
||||
each note has a 512bit generated <i>id</i> that is used to retrieve the note. data is stored in memory
|
||||
and never persisted to disk.
|
||||
</p>
|
||||
|
||||
<b>▶ Features</b>
|
||||
<ul>
|
||||
<li>server cannot decrypt contents due to client side encryption</li>
|
||||
<li>view and time constraints</li>
|
||||
<li>in memory, no persistence</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<b>▶ tech stack</b>
|
||||
<br />
|
||||
the backend is written in rust and the frontend is svelte and typescript.
|
||||
<br />
|
||||
you are welcomed to check & audit the
|
||||
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener">source code</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<br />
|
||||
<b>▶ attributions</b>
|
||||
<br />
|
||||
<small>
|
||||
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>
|
||||
</small>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-left: 1rem;
|
||||
list-style: square;
|
||||
}
|
||||
</style>
|
@@ -1,90 +0,0 @@
|
||||
<script context="module" lang="ts">
|
||||
export async function load({ page }) {
|
||||
return {
|
||||
props: page.params,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { NotePublic } from '$lib/api'
|
||||
import { info, get } from '$lib/api'
|
||||
import { decrypt, getKeyFromString } from '$lib/crypto'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
import copy from 'copy-to-clipboard'
|
||||
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let id: string
|
||||
export let password: string
|
||||
|
||||
let note: NotePublic | null = null
|
||||
let exists = false
|
||||
|
||||
let loading = true
|
||||
let error = false
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
loading = true
|
||||
error = null
|
||||
await info(id)
|
||||
exists = true
|
||||
} catch {
|
||||
exists = false
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
async function show() {
|
||||
try {
|
||||
error = false
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !loading}
|
||||
{#if !exists}
|
||||
<p class="error-text">note was not found or was already deleted.</p>
|
||||
{:else if note && !error}
|
||||
<p class="error-text">you will not get the chance to see the note again.</p>
|
||||
<div class="note">
|
||||
{note.contents}
|
||||
</div>
|
||||
<br />
|
||||
<Button on:click={() => copy(note.contents)}>copy to clipboard</Button>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={show}>
|
||||
<p>click below to show and delete the note if the counter has reached it's limit</p>
|
||||
<Button type="submit">show note</Button>
|
||||
{#if error}
|
||||
<br />
|
||||
<p class="error-text">
|
||||
wrong password. could not decipher. probably a broken link. note was destroyed.
|
||||
<br />
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.note {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
outline: none;
|
||||
padding: 0.5rem;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
@@ -1 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' class='ionicon' viewBox='0 0 512 512'><title>Contrast</title><path d='M256 32C132.29 32 32 132.29 32 256s100.29 224 224 224 224-100.29 224-224S379.71 32 256 32zM128.72 383.28A180 180 0 01256 76v360a178.82 178.82 0 01-127.28-52.72z'/></svg>
|
Before Width: | Height: | Size: 279 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' class='ionicon' viewBox='0 0 512 512'><title>Copy</title><path d='M456 480H136a24 24 0 01-24-24V128a16 16 0 0116-16h328a24 24 0 0124 24v320a24 24 0 01-24 24z'/><path d='M112 80h288V56a24 24 0 00-24-24H60a28 28 0 00-28 28v316a24 24 0 0024 24h24V112a32 32 0 0132-32z'/></svg>
|
Before Width: | Height: | Size: 313 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' class='ionicon' viewBox='0 0 512 512'><title>Dice</title><path d='M48 366.92L240 480V284L48 170zM192 288c8.84 0 16 10.75 16 24s-7.16 24-16 24-16-10.75-16-24 7.16-24 16-24zm-96 32c8.84 0 16 10.75 16 24s-7.16 24-16 24-16-10.75-16-24 7.16-24 16-24zM272 284v196l192-113.08V170zm48 140c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm0-88c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm96 32c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm0-88c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm32 77.64zM256 32L64 144l192 112 192-112zm0 120c-13.25 0-24-7.16-24-16s10.75-16 24-16 24 7.16 24 16-10.75 16-24 16z'/></svg>
|
Before Width: | Height: | Size: 728 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' class='ionicon' viewBox='0 0 512 512'><title>Eye Off</title><path d='M63.998 86.004l21.998-21.998L448 426.01l-21.998 21.998zM259.34 192.09l60.57 60.57a64.07 64.07 0 00-60.57-60.57zM252.66 319.91l-60.57-60.57a64.07 64.07 0 0060.57 60.57z'/><path d='M256 352a96 96 0 01-92.6-121.34l-69.07-69.08C66.12 187.42 39.24 221.14 16 256c26.42 44 62.56 89.24 100.2 115.18C159.38 400.92 206.33 416 255.76 416A233.47 233.47 0 00335 402.2l-53.61-53.6A95.84 95.84 0 01256 352zM256 160a96 96 0 0192.6 121.34L419.26 352c29.15-26.25 56.07-61.56 76.74-96-26.38-43.43-62.9-88.56-101.18-114.82C351.1 111.2 304.31 96 255.76 96a222.92 222.92 0 00-78.21 14.29l53.11 53.11A95.84 95.84 0 01256 160z'/></svg>
|
Before Width: | Height: | Size: 720 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' class='ionicon' viewBox='0 0 512 512'><title>Eye</title><circle cx='256' cy='256' r='64'/><path d='M394.82 141.18C351.1 111.2 304.31 96 255.76 96c-43.69 0-86.28 13-126.59 38.48C88.52 160.23 48.67 207 16 256c26.42 44 62.56 89.24 100.2 115.18C159.38 400.92 206.33 416 255.76 416c49 0 95.85-15.07 139.3-44.79C433.31 345 469.71 299.82 496 256c-26.38-43.43-62.9-88.56-101.18-114.82zM256 352a96 96 0 1196-96 96.11 96.11 0 01-96 96z'/></svg>
|
Before Width: | Height: | Size: 474 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns='http://www.w3.org/2000/svg' class='ionicon' viewBox='0 0 512 512'><title>Lock Closed</title><path d='M420 192h-68v-80a96 96 0 10-192 0v80H92a12 12 0 00-12 12v280a12 12 0 0012 12h328a12 12 0 0012-12V204a12 12 0 00-12-12zm-106 0H198v-80.75a58 58 0 11116 0z'/></svg>
|
Before Width: | Height: | Size: 275 B |
@@ -1,17 +0,0 @@
|
||||
const preprocess = require('svelte-preprocess')
|
||||
const adapter = require('@sveltejs/adapter-static')
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
module.exports = {
|
||||
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||
// for more information about preprocessors
|
||||
preprocess: preprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: 'index.html',
|
||||
}),
|
||||
// hydrate the <div id="svelte"> element in src/app.html
|
||||
target: '#svelte',
|
||||
},
|
||||
}
|
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"module": "es2020",
|
||||
"lib": ["es2020"],
|
||||
"target": "es2019",
|
||||
/**
|
||||
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
|
||||
to enforce using \`import type\` instead of \`import\` for Types.
|
||||
*/
|
||||
"importsNotUsedAsValues": "error",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
/**
|
||||
To have warnings/errors of the Svelte compiler at the correct position,
|
||||
enable source maps by default.
|
||||
*/
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"paths": {
|
||||
"$lib/*": ["src/lib/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
|
||||
}
|
BIN
design/Github_zh-CN.png
Normal file
After Width: | Height: | Size: 69 KiB |
BIN
design/Logo.afdesign
(Stored with Git LFS)
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 12 KiB |
@@ -1,18 +1,20 @@
|
||||
# DEV Compose file.
|
||||
# For a production file see: README.md
|
||||
|
||||
version: '3.7'
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
memcached:
|
||||
image: memcached:1-alpine
|
||||
entrypoint: memcached -m 128
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 11211:11211
|
||||
- 6379:6379
|
||||
|
||||
app:
|
||||
build: .
|
||||
# build: .
|
||||
image: cupcakearmy/cryptgeon
|
||||
depends_on:
|
||||
- memcached
|
||||
- redis
|
||||
environment:
|
||||
SIZE_LIMIT: 10 MiB
|
||||
ports:
|
||||
- 80:5000
|
||||
- 1234:5000
|
||||
|
21
examples/nginx/docker-compose.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
proxy:
|
||||
image: nginx:alpine
|
||||
depends_on:
|
||||
- app
|
||||
volumes:
|
||||
- ./nginx-plain.conf:/etc/nginx/conf.d/default.conf
|
||||
# Or with tls
|
||||
# - ./nginx-tls.conf:/etc/nginx/conf.d/default.conf
|
||||
ports:
|
||||
- 80:80
|
13
examples/nginx/nginx-plain.conf
Normal file
@@ -0,0 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app:5000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
29
examples/nginx/nginx-tls.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
# You should change the server_name to something sensible.
|
||||
# Also you need to specify the path to the ssl certificates.
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name _;
|
||||
|
||||
# Enforce HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name _;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
ssl_trusted_certificate /path/to/fullchain.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app:5000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
160
examples/scratch/README.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Install from scratch.
|
||||
|
||||
This is a tiny guide to install cryptgeon on (probably) any unix system (and maybe windows?) from scratch using traefik as the proxy, which will manage certificates and handle https for us.
|
||||
|
||||
1. Install Docker & Docker Compose.
|
||||
2. Install Traefik.
|
||||
3. Run the cryptgeon.
|
||||
4. [Optional] install watchtower to keep up to date.
|
||||
|
||||
## Install Docker & Docker Compose
|
||||
|
||||
- [Docker](https://docs.docker.com/engine/install/)
|
||||
- [Compose](https://docs.docker.com/compose/install/)
|
||||
|
||||
## Install Traefik 2.0
|
||||
|
||||
[Traefik](https://doc.traefik.io/traefik/) is a router & proxy that makes deployment of containers incredibly easy. It will manage all the https certificates, routing, etc.
|
||||
|
||||
```sh
|
||||
/foo/bar/traefik/
|
||||
├── docker-compose.yaml
|
||||
└── traefik.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yaml
|
||||
|
||||
version: '3.8'
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:2.6
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./traefik.yaml:/etc/traefik/traefik.yaml:ro
|
||||
- ./data:/data
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
|
||||
# HTTP to HTTPS redirection
|
||||
- 'traefik.http.routers.http_catchall.rule=HostRegexp(`{any:.+}`)'
|
||||
- 'traefik.http.routers.http_catchall.entrypoints=insecure'
|
||||
- 'traefik.http.routers.http_catchall.middlewares=https_redirect'
|
||||
- 'traefik.http.middlewares.https_redirect.redirectscheme.scheme=https'
|
||||
- 'traefik.http.middlewares.https_redirect.redirectscheme.permanent=true'
|
||||
|
||||
networks:
|
||||
default:
|
||||
external: true
|
||||
name: proxy
|
||||
```
|
||||
|
||||
```yaml
|
||||
# traefik.yaml
|
||||
|
||||
api:
|
||||
dashboard: true
|
||||
|
||||
# Define HTTP and HTTPS entrypoint
|
||||
entryPoints:
|
||||
insecure:
|
||||
address: ':80'
|
||||
secure:
|
||||
address: ':443'
|
||||
|
||||
# Dynamic configuration will come from docker labels
|
||||
providers:
|
||||
docker:
|
||||
endpoint: 'unix:///var/run/docker.sock'
|
||||
network: 'proxy'
|
||||
exposedByDefault: false
|
||||
|
||||
# Enable acme with http file challenge
|
||||
certificatesResolvers:
|
||||
le:
|
||||
acme:
|
||||
email: me@example.org
|
||||
storage: /data/acme.json
|
||||
httpChallenge:
|
||||
entryPoint: insecure
|
||||
```
|
||||
|
||||
**Run**
|
||||
|
||||
```sh
|
||||
docker network create proxy
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Cryptgeon
|
||||
|
||||
Create another docker-compose.yaml file in another folder. We will assume that the domain is `cryptgeon.example.org`.
|
||||
|
||||
```sh
|
||||
/foo/bar/cryptgeon/
|
||||
└── docker-compose.yaml
|
||||
```
|
||||
|
||||
```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
|
||||
environment:
|
||||
SIZE_LIMIT: 4 MiB
|
||||
networks:
|
||||
- default
|
||||
- proxy
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.example.org`)
|
||||
- traefik.http.routers.cryptgeon.entrypoints=secure
|
||||
- traefik.http.routers.cryptgeon.tls.certresolver=le
|
||||
```
|
||||
|
||||
**Run**
|
||||
|
||||
```sh
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Watchtower
|
||||
|
||||
> A container-based solution for automating Docker container base image updates.
|
||||
|
||||
[Watchtower](https://containrrr.dev/watchtower/) will keep our containers up to date. The interval is set to once a day and also configured to delete old images to prevent cluttering.
|
||||
|
||||
```sh
|
||||
/foo/bar/watchtower/
|
||||
└── docker-compose.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
# docker-compose.yaml
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: --cleanup --interval 86400
|
||||
```
|
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
|
||||
```
|
1375
package-lock.json
generated
15
package.json
@@ -1,11 +1,18 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev:docker": "docker-compose up memcached",
|
||||
"dev:backend": "cargo watch -x 'run --bin cryptgeon'",
|
||||
"dev:front": "npm --prefix client run dev",
|
||||
"dev": "run-p dev:*"
|
||||
"dev:docker": "docker-compose up redis",
|
||||
"dev:packages": "pnpm --parallel run dev",
|
||||
"dev:proxy": "node proxy.mjs",
|
||||
"dev": "run-p dev:*",
|
||||
"test:run": "playwright test --project chrome firefox safari",
|
||||
"test:local": "playwright test --project local",
|
||||
"test:server": "pnpm --parallel run test:server",
|
||||
"test:prepare": "pnpm --parallel run test:prepare"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.25.1",
|
||||
"@types/node": "^16.11.57",
|
||||
"http-proxy": "^1.18.1",
|
||||
"npm-run-all": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
1505
packages/backend/Cargo.lock
generated
Normal file
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "cryptgeon"
|
||||
version = "1.0.0"
|
||||
version = "2.0.3"
|
||||
authors = ["cupcakearmy <hi@nicco.io>"]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "cryptgeon"
|
||||
@@ -11,11 +11,15 @@ path = "src/main.rs"
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
actix-web = "3"
|
||||
actix-files = "0.5"
|
||||
serde = "1"
|
||||
actix-web = "4"
|
||||
actix-files = "0.6"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
lazy_static = "1"
|
||||
ring = "0.16"
|
||||
bs62 = "0.1"
|
||||
memcache = "0.15"
|
||||
byte-unit = "4"
|
||||
dotenv = "0.15"
|
||||
mime = "0.3"
|
||||
env_logger = "0.9"
|
||||
redis = "0.21.5"
|
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"
|
||||
}
|
||||
}
|
12
packages/backend/src/api.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use actix_web::web;
|
||||
|
||||
use crate::note;
|
||||
use crate::status;
|
||||
|
||||
pub fn init(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/api")
|
||||
.service(note::init())
|
||||
.service(status::init()),
|
||||
);
|
||||
}
|
17
packages/backend/src/client.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use actix_files::{Files, NamedFile};
|
||||
use actix_web::{web, Result};
|
||||
|
||||
use crate::config;
|
||||
|
||||
pub fn init(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
Files::new("/", config::FRONTEND_PATH.to_string())
|
||||
.index_file("index.html")
|
||||
.use_etag(true),
|
||||
);
|
||||
}
|
||||
|
||||
pub async fn index() -> Result<NamedFile> {
|
||||
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
|
||||
Ok(NamedFile::open(index)?)
|
||||
}
|
45
packages/backend/src/config.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use byte_unit::Byte;
|
||||
|
||||
// Internal
|
||||
lazy_static! {
|
||||
pub static ref VERSION: String = option_env!("CARGO_PKG_VERSION")
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
pub static ref FRONTEND_PATH: String =
|
||||
std::env::var("FRONTEND_PATH").unwrap_or("../frontend/build".to_string());
|
||||
pub static ref LISTEN_ADDR: String =
|
||||
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:5000".to_string());
|
||||
pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string());
|
||||
}
|
||||
|
||||
// CONFIG
|
||||
lazy_static! {
|
||||
pub static ref LIMIT: usize =
|
||||
Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string()))
|
||||
.unwrap()
|
||||
.get_bytes() as usize;
|
||||
pub static ref MAX_VIEWS: u32 = std::env::var("MAX_VIEWS")
|
||||
.unwrap_or("100".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref MAX_EXPIRATION: u32 = std::env::var("MAX_EXPIRATION")
|
||||
.unwrap_or("360".to_string()) // 6 hours in minutes
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref ALLOW_ADVANCED: bool = std::env::var("ALLOW_ADVANCED")
|
||||
.unwrap_or("true".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// THEME
|
||||
lazy_static! {
|
||||
pub static ref THEME_IMAGE: String = std::env::var("THEME_IMAGE")
|
||||
.unwrap_or("".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref THEME_TEXT: String = std::env::var("THEME_TEXT")
|
||||
.unwrap_or("".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
36
packages/backend/src/main.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use actix_web::{
|
||||
middleware::{self, Logger},
|
||||
web, App, HttpServer,
|
||||
};
|
||||
use dotenv::dotenv;
|
||||
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
mod api;
|
||||
mod client;
|
||||
mod config;
|
||||
mod note;
|
||||
mod size;
|
||||
mod status;
|
||||
mod store;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
dotenv().ok();
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
|
||||
|
||||
return HttpServer::new(|| {
|
||||
App::new()
|
||||
.wrap(Logger::new("\"%r\" %s %b %T"))
|
||||
.wrap(middleware::Compress::default())
|
||||
.wrap(middleware::DefaultHeaders::default())
|
||||
.configure(size::init)
|
||||
.configure(api::init)
|
||||
.configure(client::init)
|
||||
.default_service(web::to(client::index))
|
||||
})
|
||||
.bind(config::LISTEN_ADDR.to_string())?
|
||||
.run()
|
||||
.await;
|
||||
}
|
@@ -4,9 +4,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Note {
|
||||
pub contents: String,
|
||||
pub views: Option<u8>,
|
||||
pub expiration: Option<u64>,
|
||||
pub meta: String,
|
||||
pub contents: String,
|
||||
pub views: Option<u32>,
|
||||
pub expiration: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
@@ -14,12 +15,13 @@ pub struct NoteInfo {}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NotePublic {
|
||||
pub contents: String,
|
||||
pub meta: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
pub fn generate_id() -> String {
|
||||
let mut id: [u8; 64] = [0; 64];
|
||||
let sr = ring::rand::SystemRandom::new();
|
||||
let _ = sr.fill(&mut id);
|
||||
return bs62::encode_data(&id);
|
||||
let mut id: [u8; 32] = [0; 32];
|
||||
let sr = ring::rand::SystemRandom::new();
|
||||
let _ = sr.fill(&mut id);
|
||||
return bs62::encode_data(&id);
|
||||
}
|
143
packages/backend/src/note/routes.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use actix_web::{delete, get, post, web, HttpResponse, Responder, Scope};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::config;
|
||||
use crate::note::{generate_id, Note, NoteInfo, NotePublic};
|
||||
use crate::store;
|
||||
|
||||
pub fn now() -> u32 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as u32
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct NotePath {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[get("/{id}")]
|
||||
async fn one(path: web::Path<NotePath>) -> impl Responder {
|
||||
let p = path.into_inner();
|
||||
let note = store::get(&p.id);
|
||||
|
||||
match note {
|
||||
Ok(Some(_)) => HttpResponse::Ok().json(NoteInfo {}),
|
||||
Ok(None) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CreateResponse {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[post("/")]
|
||||
async fn create(note: web::Json<Note>) -> impl Responder {
|
||||
let mut n = note.into_inner();
|
||||
let id = generate_id();
|
||||
let bad_req = HttpResponse::BadRequest().finish();
|
||||
if n.views == None && n.expiration == None {
|
||||
return bad_req;
|
||||
}
|
||||
if !*config::ALLOW_ADVANCED {
|
||||
n.views = Some(1);
|
||||
n.expiration = None;
|
||||
}
|
||||
match n.views {
|
||||
Some(v) => {
|
||||
if v > *config::MAX_VIEWS {
|
||||
return bad_req;
|
||||
}
|
||||
n.expiration = None; // views overrides expiration
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match n.expiration {
|
||||
Some(e) => {
|
||||
if e > *config::MAX_EXPIRATION {
|
||||
return bad_req;
|
||||
}
|
||||
let expiration = now() + (e * 60);
|
||||
n.expiration = Some(expiration);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match store::set(&id.clone(), &n.clone()) {
|
||||
Ok(_) => return HttpResponse::Ok().json(CreateResponse { id: id }),
|
||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[delete("/{id}")]
|
||||
async fn delete(path: web::Path<NotePath>) -> impl Responder {
|
||||
let p = path.into_inner();
|
||||
let note = store::get(&p.id);
|
||||
match note {
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
Ok(None) => return HttpResponse::NotFound().finish(),
|
||||
Ok(Some(note)) => {
|
||||
let mut changed = note.clone();
|
||||
if changed.views == None && changed.expiration == None {
|
||||
return HttpResponse::BadRequest().finish();
|
||||
}
|
||||
match changed.views {
|
||||
Some(v) => {
|
||||
changed.views = Some(v - 1);
|
||||
let id = p.id.clone();
|
||||
if v <= 1 {
|
||||
match store::del(&id) {
|
||||
Err(e) => {
|
||||
return HttpResponse::InternalServerError().body(e.to_string())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
match store::set(&id, &changed.clone()) {
|
||||
Err(e) => {
|
||||
return HttpResponse::InternalServerError().body(e.to_string())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let n = now();
|
||||
match changed.expiration {
|
||||
Some(e) => {
|
||||
if e < n {
|
||||
match store::del(&p.id.clone()) {
|
||||
Ok(_) => return HttpResponse::BadRequest().finish(),
|
||||
Err(e) => {
|
||||
return HttpResponse::InternalServerError().body(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
return HttpResponse::Ok().json(NotePublic {
|
||||
contents: changed.contents,
|
||||
meta: changed.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Status {
|
||||
version: String,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
pub fn init() -> Scope {
|
||||
web::scope("/notes")
|
||||
.service(one)
|
||||
.service(create)
|
||||
.service(delete)
|
||||
}
|
12
packages/backend/src/size.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use crate::config;
|
||||
use actix_web::web;
|
||||
use mime;
|
||||
|
||||
pub fn init(cfg: &mut web::ServiceConfig) {
|
||||
let json = web::JsonConfig::default().limit(*config::LIMIT);
|
||||
let plain = web::PayloadConfig::default()
|
||||
.limit(*config::LIMIT)
|
||||
.mimetype(mime::STAR_STAR);
|
||||
// cfg.app_data(plain);
|
||||
cfg.app_data(json).app_data(plain);
|
||||
}
|
5
packages/backend/src/status/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod model;
|
||||
mod routes;
|
||||
|
||||
pub use model::*;
|
||||
pub use routes::*;
|
15
packages/backend/src/status/model.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Status {
|
||||
// General
|
||||
pub version: String,
|
||||
// Config
|
||||
pub max_size: u32,
|
||||
pub max_views: u32,
|
||||
pub max_expiration: u32,
|
||||
pub allow_advanced: bool,
|
||||
// Theme
|
||||
pub theme_image: String,
|
||||
pub theme_text: String,
|
||||
}
|
21
packages/backend/src/status/routes.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use actix_web::{get, web, HttpResponse, Responder, Scope};
|
||||
|
||||
use crate::config;
|
||||
use crate::status::Status;
|
||||
|
||||
#[get("/")]
|
||||
async fn get_status() -> impl Responder {
|
||||
return HttpResponse::Ok().json(Status {
|
||||
version: config::VERSION.to_string(),
|
||||
max_size: *config::LIMIT as u32,
|
||||
max_views: *config::MAX_VIEWS,
|
||||
max_expiration: *config::MAX_EXPIRATION,
|
||||
allow_advanced: *config::ALLOW_ADVANCED,
|
||||
theme_image: config::THEME_IMAGE.to_string(),
|
||||
theme_text: config::THEME_TEXT.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn init() -> Scope {
|
||||
web::scope("/status").service(get_status)
|
||||
}
|
55
packages/backend/src/store.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use redis;
|
||||
use redis::Commands;
|
||||
|
||||
use crate::note::now;
|
||||
use crate::note::Note;
|
||||
|
||||
lazy_static! {
|
||||
static ref REDIS_CLIENT: String = std::env::var("REDIS")
|
||||
.unwrap_or("redis://127.0.0.1/".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn get_connection() -> Result<redis::Connection, &'static str> {
|
||||
let client =
|
||||
redis::Client::open(REDIS_CLIENT.to_string()).map_err(|_| "Unable to connect to redis")?;
|
||||
client
|
||||
.get_connection()
|
||||
.map_err(|_| "Unable to connect to redis")
|
||||
}
|
||||
|
||||
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
|
||||
let serialized = serde_json::to_string(¬e.clone()).unwrap();
|
||||
let mut conn = get_connection()?;
|
||||
|
||||
conn.set(id, serialized)
|
||||
.map_err(|_| "Unable to set note in redis")?;
|
||||
match note.expiration {
|
||||
Some(e) => {
|
||||
let seconds = e - now();
|
||||
conn.expire(id, seconds as usize)
|
||||
.map_err(|_| "Unable to set expiration on notion")?
|
||||
}
|
||||
None => {}
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
|
||||
let mut conn = get_connection()?;
|
||||
let value: Option<String> = conn.get(id).map_err(|_| "Could not load note in redis")?;
|
||||
match value {
|
||||
None => return Ok(None),
|
||||
Some(s) => {
|
||||
let deserialize: Note = serde_json::from_str(&s).unwrap();
|
||||
return Ok(Some(deserialize));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn del(id: &String) -> Result<(), &'static str> {
|
||||
let mut conn = get_connection()?;
|
||||
conn.del(id).map_err(|_| "Unable to delete note in redis")?;
|
||||
Ok(())
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/.svelte
|
||||
/.svelte-kit
|
||||
/build
|
||||
/functions
|
18
packages/frontend/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Cryptgeon Frontend
|
||||
|
||||
## Locale
|
||||
|
||||
Download with these settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"format": "json",
|
||||
"indentation": "tab",
|
||||
"json_unescaped_slashes": true,
|
||||
"export_sort": "first_added",
|
||||
"original_filenames": false,
|
||||
"export_empty_as": "skip",
|
||||
"add_newline_eof": true,
|
||||
"replace_breaks": false
|
||||
}
|
||||
```
|
8
packages/frontend/licenses.csv
Normal file
@@ -0,0 +1,8 @@
|
||||
├─ MIT: 13
|
||||
├─ ISC: 2
|
||||
├─ BSD-3-Clause: 1
|
||||
├─ (MPL-2.0 OR Apache-2.0): 1
|
||||
├─ BSD-2-Clause: 1
|
||||
├─ 0BSD: 1
|
||||
└─ Apache-2.0: 1
|
||||
|
|
50
packages/frontend/locales/de.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "Hinweis",
|
||||
"file": "Datei",
|
||||
"advanced": "erweitert",
|
||||
"create": "erstellen",
|
||||
"loading": "läd",
|
||||
"mode": "Modus",
|
||||
"views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}",
|
||||
"minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}",
|
||||
"max": "max",
|
||||
"share_link": "Link teilen",
|
||||
"copy_clipboard": "in die Zwischenablage kopieren",
|
||||
"copied_to_clipboard": "in die Zwischenablage kopiert",
|
||||
"encrypting": "verschlüsseln",
|
||||
"decrypting": "entschlüsselt",
|
||||
"uploading": "hochladen",
|
||||
"downloading": "wird heruntergeladen"
|
||||
},
|
||||
"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.",
|
||||
"explanation": "die Notiz verfällt und wird nach {type} zerstört.",
|
||||
"new_note": "neue Note",
|
||||
"new_note_notice": "<b>Verfügbarkeit:</b><br />es ist nicht garantiert, dass die Notiz gespeichert wird, da alles im Speicher gehalten wird. Wenn dieser voll ist, werden die ältesten Notizen entfernt.<br />(Sie werden wahrscheinlich keine Probleme haben, seien Sie nur gewarnt).",
|
||||
"errors": {
|
||||
"note_to_big": "Notiz konnte nicht erstellt werden. Notiz ist zu groß",
|
||||
"note_error": "konnte keine Notiz erstellen. Bitte versuchen Sie es erneut.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "Notiz ist leer."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "notiz erstellt."
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"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.",
|
||||
"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",
|
||||
"show_note": "Notiz anzeigen",
|
||||
"warning_will_not_see_again": "haben Sie <b>keine</b> Gelegenheit, die Notiz noch einmal zu sehen.",
|
||||
"download_all": "alle herunterladen"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Ausgewählte Dateien",
|
||||
"no_files_selected": "Keine Dateien ausgewählt"
|
||||
}
|
||||
}
|
50
packages/frontend/locales/en.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "note",
|
||||
"file": "file",
|
||||
"advanced": "advanced",
|
||||
"create": "create",
|
||||
"loading": "loading",
|
||||
"mode": "mode",
|
||||
"views": "{n, plural, =0 {views} =1 {1 view} other {# views}}",
|
||||
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
|
||||
"max": "max",
|
||||
"share_link": "share link",
|
||||
"copy_clipboard": "copy to clipboard",
|
||||
"copied_to_clipboard": "copied to clipboard",
|
||||
"encrypting": "encrypting",
|
||||
"decrypting": "decrypting",
|
||||
"uploading": "uploading",
|
||||
"downloading": "downloading"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
|
||||
"explanation": "the note will expire and be destroyed after {type}.",
|
||||
"new_note": "new note",
|
||||
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
|
||||
"errors": {
|
||||
"note_to_big": "could not create note. note is to big",
|
||||
"note_error": "could not create note. please try again.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "note is empty."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "note created."
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "note was not found or was already deleted.",
|
||||
"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",
|
||||
"show_note": "show note",
|
||||
"warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.",
|
||||
"download_all": "download all"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Selected Files",
|
||||
"no_files_selected": "No Files Selected"
|
||||
}
|
||||
}
|
50
packages/frontend/locales/es.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "nota",
|
||||
"file": "archivo",
|
||||
"advanced": "avanzado",
|
||||
"create": "crear",
|
||||
"loading": "cargando",
|
||||
"mode": "modo",
|
||||
"views": "{n, plural, =0 {vistas} =1 {1 vista} other {# vistas}}",
|
||||
"minutes": "{n, plural, =0 {minutos} =1 {1 minuto} other {# minutos}}",
|
||||
"max": "max",
|
||||
"share_link": "compartir enlace",
|
||||
"copy_clipboard": "copiar al portapapeles",
|
||||
"copied_to_clipboard": "copiado al portapapeles",
|
||||
"encrypting": "encriptando",
|
||||
"decrypting": "descifrando",
|
||||
"uploading": "cargando",
|
||||
"downloading": "descargando"
|
||||
},
|
||||
"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.",
|
||||
"explanation": "la nota expirará y se destruirá después de {type}.",
|
||||
"new_note": "nueva nota",
|
||||
"new_note_notice": "<b>disponibilidad:</b><br />no se garantiza que la nota se almacene, ya que todo se guarda en la memoria RAM, si se llena se eliminarán las notas más antiguas.<br />(probablemente estará bien, sólo está advertido.)",
|
||||
"errors": {
|
||||
"note_to_big": "no se pudo crear la nota. la nota es demasiado grande",
|
||||
"note_error": "No se ha podido crear la nota. Por favor, inténtelo de nuevo.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "la nota está vacía."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "nota creada."
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"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.",
|
||||
"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",
|
||||
"show_note": "mostrar nota",
|
||||
"warning_will_not_see_again": " <b>no</b> tendrás la oportunidad de volver a ver la nota.",
|
||||
"download_all": "descargar todo"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Archivos seleccionados",
|
||||
"no_files_selected": "No hay archivos seleccionados"
|
||||
}
|
||||
}
|
50
packages/frontend/locales/fr.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "note",
|
||||
"file": "fichier",
|
||||
"advanced": "avancé",
|
||||
"create": "créer",
|
||||
"loading": "chargement",
|
||||
"mode": "mode",
|
||||
"views": "{n, plural, =0 {vues} =1 {1 vue} other {# vues}}",
|
||||
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
|
||||
"max": "max",
|
||||
"share_link": "partager le lien",
|
||||
"copy_clipboard": "copier dans le presse-papiers",
|
||||
"copied_to_clipboard": "copié dans le presse-papiers",
|
||||
"encrypting": "cryptage",
|
||||
"decrypting": "déchiffrer",
|
||||
"uploading": "téléchargement",
|
||||
"downloading": "téléchargement"
|
||||
},
|
||||
"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.",
|
||||
"explanation": "la note expirera et sera détruite après {type}.",
|
||||
"new_note": "nouvelle note",
|
||||
"new_note_notice": "<b>disponibilité :</b><br />la note n'est pas garantie d'être stockée car tout est conservé dans la mémoire vive, si elle se remplit les notes les plus anciennes seront supprimées.<br />(vous serez probablement bien, soyez juste averti.)",
|
||||
"errors": {
|
||||
"note_to_big": "Impossible de créer une note. La note est trop grande",
|
||||
"note_error": "n'a pas pu créer de note. Veuillez réessayer.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "La note est vide."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "note créée."
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"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.",
|
||||
"unsupported_type": "type de note non supporté."
|
||||
},
|
||||
"explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.",
|
||||
"show_note": "note de présentation",
|
||||
"warning_will_not_see_again": "vous <b>n'aurez pas</b> la chance de revoir la note.",
|
||||
"download_all": "télécharger tout"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Fichiers sélectionnés",
|
||||
"no_files_selected": "Aucun fichier sélectionné"
|
||||
}
|
||||
}
|
50
packages/frontend/locales/it.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "nota",
|
||||
"file": "file",
|
||||
"advanced": "avanzato",
|
||||
"create": "crea",
|
||||
"loading": "carica",
|
||||
"mode": "modalita",
|
||||
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
|
||||
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
|
||||
"max": "max",
|
||||
"share_link": "condividi link",
|
||||
"copy_clipboard": "copia negli appunti",
|
||||
"copied_to_clipboard": "copiato negli appunti",
|
||||
"encrypting": "criptando",
|
||||
"decrypting": "decifrando",
|
||||
"uploading": "caricamento",
|
||||
"downloading": "scaricando"
|
||||
},
|
||||
"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.",
|
||||
"explanation": "la nota scadrà e sarà distrutta dopo {type}.",
|
||||
"new_note": "nuova nota",
|
||||
"new_note_notice": "<b>disponibilità:</b><br />la nota non è garantita per essere memorizzata come tutto è tenuto in ram, se si riempie le note più vecchie saranno rimosse.<br />(probabilmente andrà bene, basta essere avvertiti).",
|
||||
"errors": {
|
||||
"note_to_big": "impossibile creare una nota. la nota è troppo grande",
|
||||
"note_error": "Impossibile creare la nota. Riprova.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "la nota è vuota."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "nota creata."
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"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.",
|
||||
"unsupported_type": "tipo di nota non supportato."
|
||||
},
|
||||
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
|
||||
"show_note": "mostra la nota",
|
||||
"warning_will_not_see_again": " <b>non</b> avrete la possibilità di rivedere la nota.",
|
||||
"download_all": "scarica tutti"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "File selezionati",
|
||||
"no_files_selected": "Nessun file selezionato"
|
||||
}
|
||||
}
|
50
packages/frontend/locales/zh_CN.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "密信",
|
||||
"file": "上传文件",
|
||||
"advanced": "高级设置",
|
||||
"create": "创建",
|
||||
"loading": "加载中",
|
||||
"mode": "模式",
|
||||
"views": "{n, plural, =0 {可查看次数} =1 {1 次查看} other {# 次查看}}",
|
||||
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
|
||||
"max": "最大值",
|
||||
"share_link": "分享链接",
|
||||
"copy_clipboard": "复制到剪切版",
|
||||
"copied_to_clipboard": "已复制到剪切板",
|
||||
"encrypting": "加密",
|
||||
"decrypting": "解密",
|
||||
"uploading": "上传",
|
||||
"downloading": "下载"
|
||||
},
|
||||
"home": {
|
||||
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
|
||||
"explanation": "该密信会在{type}后失效。",
|
||||
"new_note": "新建密信",
|
||||
"new_note_notice": "<b>可用性警示:</b><br />由于加密鸽的所有数据是全部保存在内存中的,所以如果加密鸽的可用内存被用光了那么它将会删除最早的密信以释放内存,因此不保证该密信的可用性。<br />(一般情况下是您应该是不会遇到这个问题,只是警示一下。)",
|
||||
"errors": {
|
||||
"note_to_big": "无法创建密信,这个密信太大了!",
|
||||
"note_error": "无法创建密信,请再试一遍。",
|
||||
"max": "最大文件大小: {n}",
|
||||
"empty_content": "密信为空!"
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "注释创建。"
|
||||
}
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "该密信无法被找到或者它已经被删除了!",
|
||||
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!",
|
||||
"unsupported_type": "不支持的票据类型。"
|
||||
},
|
||||
"explanation": "点击下方的按钮可以查看密信,如果它到达了限制将会被删除",
|
||||
"show_note": "查看密信",
|
||||
"warning_will_not_see_again": "您将<b>无法</b>再次查看该密信",
|
||||
"download_all": "下载全部"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "已选中的文件",
|
||||
"no_files_selected": "没有文件被选中"
|
||||
}
|
||||
}
|
37
packages/frontend/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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": "^7.3.1",
|
||||
"@sveltejs/adapter-static": "1.0.0-next.42",
|
||||
"@sveltejs/kit": "1.0.0-next.480",
|
||||
"@types/dompurify": "^2.3.3",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@zerodevx/svelte-toast": "^0.7.2",
|
||||
"adm-zip": "^0.5.9",
|
||||
"dotenv": "^16.0.1",
|
||||
"svelte": "^3.49.0",
|
||||
"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.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/fira-mono": "^4.5.8",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"dompurify": "^2.3.10",
|
||||
"file-saver": "^2.0.5",
|
||||
"pretty-bytes": "^5.6.0"
|
||||
}
|
||||
}
|
54
packages/frontend/scripts/locale.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import dotenv from 'dotenv'
|
||||
import { LokaliseApi } from '@lokalise/node-api'
|
||||
import https from 'https'
|
||||
import AdmZip from 'adm-zip'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const apiKey = process.env.LOKALISE_API_KEY
|
||||
const project_id = process.env.LOKALISE_PROJECT
|
||||
if (!apiKey) throw new Error('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
|
||||
if (!project_id) throw new Error('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
|
||||
const client = new LokaliseApi({ apiKey })
|
||||
|
||||
const WGet = (url) =>
|
||||
new Promise((done) => {
|
||||
https
|
||||
.get(url, (res) => {
|
||||
const data = []
|
||||
res
|
||||
.on('data', (chunk) => {
|
||||
data.push(chunk)
|
||||
})
|
||||
.on('end', () => {
|
||||
let buffer = Buffer.concat(data)
|
||||
done(buffer)
|
||||
})
|
||||
})
|
||||
.on('error', (err) => {
|
||||
console.log('download error:', err)
|
||||
})
|
||||
})
|
||||
|
||||
async function download() {
|
||||
// For details see: https://app.lokalise.com/api2docs/curl/#transition-download-files-post
|
||||
const download = await client.files().download(project_id, {
|
||||
format: 'json',
|
||||
indentation: 'tab',
|
||||
json_unescaped_slashes: true,
|
||||
original_filenames: false,
|
||||
bundle_structure: '%LANG_ISO%.%FORMAT%',
|
||||
export_sort: 'first_added',
|
||||
export_empty_as: 'skip',
|
||||
add_newline_eof: true,
|
||||
replace_breaks: false,
|
||||
})
|
||||
const buffered = await WGet(download.bundle_url)
|
||||
const zip = new AdmZip(buffered)
|
||||
zip.extractAllTo('./locales', true)
|
||||
}
|
||||
|
||||
download().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
@@ -14,7 +14,9 @@
|
||||
--ui-text-0: #fefefe;
|
||||
--ui-text-1: #eee;
|
||||
--ui-clr-primary: hsl(186, 65%, 55%);
|
||||
--ui-clr-primary-alt: hsl(186, 85%, 35%);
|
||||
--ui-clr-error: hsl(357, 77%, 51%);
|
||||
--ui-clr-error-alt: hsl(357, 87%, 41%);
|
||||
|
||||
--ui-anim: all 150ms ease;
|
||||
}
|
||||
@@ -97,3 +99,28 @@ fieldset {
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.box {
|
||||
width: 100%;
|
||||
min-height: min(calc(100vh - 30rem), 20rem);
|
||||
margin: 0;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 30rem) {
|
||||
.box {
|
||||
min-height: calc(100vh - 25rem);
|
||||
}
|
||||
}
|
||||
|
||||
.box:hover,
|
||||
.box:focus {
|
||||
border-color: var(--ui-clr-primary);
|
||||
}
|
||||
|
||||
.tr {
|
||||
text-align: right;
|
||||
}
|
@@ -5,9 +5,9 @@
|
||||
<link rel="icon" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
%svelte.head%
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div id="svelte">%svelte.body%</div>
|
||||
<div id="svelte">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
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(),
|
||||
}
|
78
packages/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export type NoteMeta = { type: 'text' | 'file' }
|
||||
|
||||
export type Note = {
|
||||
contents: string
|
||||
meta: NoteMeta
|
||||
views?: number
|
||||
expiration?: number
|
||||
}
|
||||
export type NoteInfo = {}
|
||||
export type NotePublic = Pick<Note, 'contents' | 'meta'>
|
||||
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
|
||||
|
||||
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
|
||||
contents: Blob
|
||||
}
|
||||
|
||||
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
|
||||
contents: string
|
||||
}
|
||||
|
||||
type CallOptions = {
|
||||
url: string
|
||||
method: string
|
||||
body?: any
|
||||
}
|
||||
|
||||
export class PayloadToLargeError extends Error {}
|
||||
|
||||
export async function call(options: CallOptions) {
|
||||
const response = await fetch('/api/' + options.url, {
|
||||
method: options.method,
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 413) throw new PayloadToLargeError()
|
||||
else throw new Error('API call failed')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export async function create(note: Note) {
|
||||
const { meta, ...rest } = note
|
||||
const body: NoteCreate = {
|
||||
...rest,
|
||||
meta: JSON.stringify(meta),
|
||||
}
|
||||
const data = await call({
|
||||
url: 'notes/',
|
||||
method: 'post',
|
||||
body,
|
||||
})
|
||||
return data as { id: string }
|
||||
}
|
||||
|
||||
export async function get(id: string): Promise<NotePublic> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'delete',
|
||||
})
|
||||
const { contents, meta } = data
|
||||
return {
|
||||
contents,
|
||||
meta: JSON.parse(meta) as NoteMeta,
|
||||
}
|
||||
}
|
||||
|
||||
export async function info(id: string): Promise<NoteInfo> {
|
||||
const data = await call({
|
||||
url: `notes/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
return data
|
||||
}
|
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
|
||||
}
|
||||
}
|
5
packages/frontend/src/lib/icons/IconContrast.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
|
||||
><title>Contrast</title><path
|
||||
d="M256 32C132.29 32 32 132.29 32 256s100.29 224 224 224 224-100.29 224-224S379.71 32 256 32zM128.72 383.28A180 180 0 01256 76v360a178.82 178.82 0 01-127.28-52.72z"
|
||||
/></svg
|
||||
>
|
After Width: | Height: | Size: 287 B |
7
packages/frontend/src/lib/icons/IconCopy.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
|
||||
><title>Copy</title><path
|
||||
d="M456 480H136a24 24 0 01-24-24V128a16 16 0 0116-16h328a24 24 0 0124 24v320a24 24 0 01-24 24z"
|
||||
/><path
|
||||
d="M112 80h288V56a24 24 0 00-24-24H60a28 28 0 00-28 28v316a24 24 0 0024 24h24V112a32 32 0 0132-32z"
|
||||
/></svg
|
||||
>
|
After Width: | Height: | Size: 325 B |
5
packages/frontend/src/lib/icons/IconDice.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
|
||||
><title>Dice</title><path
|
||||
d="M48 366.92L240 480V284L48 170zM192 288c8.84 0 16 10.75 16 24s-7.16 24-16 24-16-10.75-16-24 7.16-24 16-24zm-96 32c8.84 0 16 10.75 16 24s-7.16 24-16 24-16-10.75-16-24 7.16-24 16-24zM272 284v196l192-113.08V170zm48 140c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm0-88c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm96 32c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm0-88c-8.84 0-16-10.75-16-24s7.16-24 16-24 16 10.75 16 24-7.16 24-16 24zm32 77.64zM256 32L64 144l192 112 192-112zm0 120c-13.25 0-24-7.16-24-16s10.75-16 24-16 24 7.16 24 16-10.75 16-24 16z"
|
||||
/></svg
|
||||
>
|
After Width: | Height: | Size: 736 B |
5
packages/frontend/src/lib/icons/IconEye.svelte
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
|
||||
><title>Eye</title><circle cx="256" cy="256" r="64" /><path
|
||||
d="M394.82 141.18C351.1 111.2 304.31 96 255.76 96c-43.69 0-86.28 13-126.59 38.48C88.52 160.23 48.67 207 16 256c26.42 44 62.56 89.24 100.2 115.18C159.38 400.92 206.33 416 255.76 416c49 0 95.85-15.07 139.3-44.79C433.31 345 469.71 299.82 496 256c-26.38-43.43-62.9-88.56-101.18-114.82zM256 352a96 96 0 1196-96 96.11 96.11 0 01-96 96z"
|
||||
/></svg
|
||||
>
|
After Width: | Height: | Size: 483 B |
7
packages/frontend/src/lib/icons/IconEyeOff.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
|
||||
><title>Eye Off</title><path
|
||||
d="M63.998 86.004l21.998-21.998L448 426.01l-21.998 21.998zM259.34 192.09l60.57 60.57a64.07 64.07 0 00-60.57-60.57zM252.66 319.91l-60.57-60.57a64.07 64.07 0 0060.57 60.57z"
|
||||
/><path
|
||||
d="M256 352a96 96 0 01-92.6-121.34l-69.07-69.08C66.12 187.42 39.24 221.14 16 256c26.42 44 62.56 89.24 100.2 115.18C159.38 400.92 206.33 416 255.76 416A233.47 233.47 0 00335 402.2l-53.61-53.6A95.84 95.84 0 01256 352zM256 160a96 96 0 0192.6 121.34L419.26 352c29.15-26.25 56.07-61.56 76.74-96-26.38-43.43-62.9-88.56-101.18-114.82C351.1 111.2 304.31 96 255.76 96a222.92 222.92 0 00-78.21 14.29l53.11 53.11A95.84 95.84 0 01256 160z"
|
||||
/></svg
|
||||
>
|
After Width: | Height: | Size: 732 B |
22
packages/frontend/src/lib/stores/status.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { call } from '$lib/api'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export type Status = {
|
||||
version: string
|
||||
max_size: number
|
||||
max_views: number
|
||||
max_expiration: number
|
||||
allow_advanced: boolean
|
||||
theme_image: string
|
||||
theme_text: string
|
||||
}
|
||||
|
||||
export const status = writable<null | Status>(null)
|
||||
|
||||
export async function init() {
|
||||
const data = await call({
|
||||
url: 'status/',
|
||||
method: 'get',
|
||||
})
|
||||
status.set(data)
|
||||
}
|
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),
|
||||
}
|
19
packages/frontend/src/lib/ui/AboutParagraph.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let title: string
|
||||
</script>
|
||||
|
||||
<p>
|
||||
<b>▶ {title}</b>
|
||||
<slot />
|
||||
</p>
|
||||
|
||||
<style>
|
||||
b {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
p > :global(span) {
|
||||
padding-left: 1.25em;
|
||||
}
|
||||
</style>
|
54
packages/frontend/src/lib/ui/AdvancedParameters.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import type { Note } from '$lib/api'
|
||||
import { status } from '$lib/stores/status'
|
||||
import Switch from '$lib/ui/Switch.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
|
||||
export let note: Note
|
||||
export let timeExpiration = false
|
||||
</script>
|
||||
|
||||
<div class="fields">
|
||||
<TextInput
|
||||
data-testid="field-views"
|
||||
type="number"
|
||||
label={$t('common.views', { values: { n: 0 } })}
|
||||
bind:value={note.views}
|
||||
disabled={timeExpiration}
|
||||
max={$status?.max_views}
|
||||
validate={(v) =>
|
||||
($status && v <= $status?.max_views) ||
|
||||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
|
||||
/>
|
||||
<div class="middle-switch">
|
||||
<Switch
|
||||
data-testid="switch-advanced-toggle"
|
||||
label={$t('common.mode')}
|
||||
bind:value={timeExpiration}
|
||||
color={false}
|
||||
/>
|
||||
</div>
|
||||
<TextInput
|
||||
data-testid="field-expiration"
|
||||
type="number"
|
||||
label={$t('common.minutes', { values: { n: 0 } })}
|
||||
bind:value={note.expiration}
|
||||
disabled={!timeExpiration}
|
||||
max={$status?.max_expiration}
|
||||
validate={(v) =>
|
||||
($status && v < $status?.max_expiration) ||
|
||||
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.middle-switch {
|
||||
margin: 0 1rem;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
78
packages/frontend/src/lib/ui/FileUpload.svelte
Normal file
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import type { FileDTO } from '$lib/api'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||
|
||||
export let label: string = ''
|
||||
export let files: FileDTO[] = []
|
||||
|
||||
function fileToDTO(file: File): FileDTO {
|
||||
return {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
contents: file,
|
||||
}
|
||||
}
|
||||
|
||||
async function onInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input?.files?.length) {
|
||||
files = [...files, ...Array.from(input.files).map(fileToDTO)]
|
||||
}
|
||||
}
|
||||
|
||||
function clear(e: Event) {
|
||||
e.preventDefault()
|
||||
files = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<label>
|
||||
<small>
|
||||
{label}
|
||||
</small>
|
||||
<input {...$$restProps} type="file" on:change={onInput} multiple />
|
||||
<div class="box">
|
||||
{#if files.length}
|
||||
<div>
|
||||
<b>{$t('file_upload.selected_files')}</b>
|
||||
{#each files as file}
|
||||
<div class="file">
|
||||
{file.name}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="spacer" />
|
||||
<Button on:click={clear}>Clear</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<b>{$t('file_upload.no_files_selected')}</b>
|
||||
<br />
|
||||
<small>
|
||||
{$t('common.max')}: <MaxSize />
|
||||
</small>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
37
packages/frontend/src/lib/ui/Icon.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" context="module">
|
||||
import IconContrast from '$lib/icons/IconContrast.svelte'
|
||||
import IconCopy from '$lib/icons/IconCopy.svelte'
|
||||
import IconDice from '$lib/icons/IconDice.svelte'
|
||||
import IconEye from '$lib/icons/IconEye.svelte'
|
||||
import IconEyeOff from '$lib/icons/IconEyeOff.svelte'
|
||||
|
||||
const map = {
|
||||
contrast: IconContrast,
|
||||
copy: IconCopy,
|
||||
dice: IconDice,
|
||||
eye: IconEye,
|
||||
'eye-off': IconEyeOff,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let icon: keyof typeof map
|
||||
</script>
|
||||
|
||||
<div on:click {...$$restProps}>
|
||||
{#if map[icon]}
|
||||
<svelte:component this={map[icon]} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
display: inline-block;
|
||||
contain: strict;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
div > :global(svg) {
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
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 |
18
packages/frontend/src/lib/ui/MaxSize.svelte
Normal file
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
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>
|
||||
|
||||
<span>
|
||||
{#if $status !== null}
|
||||
{prettyBytes($status.max_size * overhead, { binary: true })}
|
||||
{:else}
|
||||
{$_('common.loading')}
|
||||
{/if}
|
||||
</span>
|
37
packages/frontend/src/lib/ui/NoteResult.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" context="module">
|
||||
export type NoteResult = {
|
||||
password: string
|
||||
id: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
|
||||
export let result: NoteResult
|
||||
|
||||
function reset() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<TextInput
|
||||
type="text"
|
||||
readonly
|
||||
label={$t('common.share_link')}
|
||||
value="{window.location.origin}/note/{result.id}#{result.password}"
|
||||
copy
|
||||
data-testid="share-link"
|
||||
/>
|
||||
<br />
|
||||
<p>
|
||||
{@html $t('home.new_note_notice')}
|
||||
</p>
|
||||
<br />
|
||||
<Button on:click={reset}>{$t('home.new_note')}</Button>
|
||||
|
||||
<style>
|
||||
</style>
|
93
packages/frontend/src/lib/ui/ShowNote.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts" context="module">
|
||||
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import DOMPurify from 'dompurify'
|
||||
import { saveAs } from 'file-saver'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import type { FileDTO, NotePublic } from '$lib/api'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import { copy } from '$lib/utils'
|
||||
|
||||
export let note: DecryptedNote
|
||||
|
||||
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
|
||||
let files: FileDTO[] = []
|
||||
|
||||
$: if (note.meta.type === 'file') {
|
||||
files = note.contents
|
||||
}
|
||||
|
||||
$: download = () => {
|
||||
for (const file of files) {
|
||||
downloadFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(file: FileDTO) {
|
||||
const f = new File([file.contents], file.name, {
|
||||
type: file.type,
|
||||
})
|
||||
saveAs(f)
|
||||
}
|
||||
|
||||
function contentWithLinks(content: string): string {
|
||||
const replaced = content.replace(
|
||||
RE_URL,
|
||||
(url) => `<a href="${url}" rel="noreferrer">${url}</a>`
|
||||
)
|
||||
return DOMPurify.sanitize(replaced, { USE_PROFILES: { html: true } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
|
||||
<div data-testid="result">
|
||||
{#if note.meta.type === 'text'}
|
||||
<div class="note">
|
||||
{@html contentWithLinks(note.contents)}
|
||||
</div>
|
||||
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
|
||||
{:else}
|
||||
{#each files as file}
|
||||
<div class="note file">
|
||||
<b on:click={() => downloadFile(file)}>↓ {file.name}</b>
|
||||
<small> {file.type} - {prettyBytes(file.size)}</small>
|
||||
</div>
|
||||
{/each}
|
||||
<Button on:click={download}>{$t('show.download_all')}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
outline: none;
|
||||
padding: 0.5rem;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.note b {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.note.file {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.note.file small {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
</style>
|
@@ -4,7 +4,7 @@
|
||||
export let color = true
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div {...$$restProps}>
|
||||
<label class="switch">
|
||||
<small>{label}</small>
|
||||
<input type="checkbox" bind:checked={value} />
|
||||
@@ -49,7 +49,7 @@
|
||||
height: 2rem;
|
||||
width: 1.25rem;
|
||||
left: 0.125rem;
|
||||
bottom: 0.1rem;
|
||||
bottom: 0.125rem;
|
||||
background-color: var(--ui-bg-1);
|
||||
-webkit-transition: 0.4s;
|
||||
transition: var(--ui-anim);
|
11
packages/frontend/src/lib/ui/TextArea.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
export let label: string = ''
|
||||
export let value: string
|
||||
</script>
|
||||
|
||||
<label>
|
||||
<small>
|
||||
{label}
|
||||
</small>
|
||||
<textarea class="box" {...$$restProps} bind:value />
|
||||
</label>
|
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { getRandomBytes, Hex } from '$lib/crypto'
|
||||
|
||||
import copyToClipboard from 'copy-to-clipboard'
|
||||
|
||||
import Icon from './Icon.svelte'
|
||||
import { Crypto, Hex } from '$lib/crypto'
|
||||
import Icon from '$lib/ui/Icon.svelte'
|
||||
import { copy as copyFN } from '$lib/utils'
|
||||
|
||||
export let label: string = ''
|
||||
export let value
|
||||
|
||||
export let value: any
|
||||
export let validate: (value: any) => boolean | string = () => true
|
||||
export let copy: boolean = false
|
||||
export let random: boolean = false
|
||||
|
||||
@@ -15,6 +13,8 @@
|
||||
const isPassword = initialType === 'password'
|
||||
let hidden = true
|
||||
|
||||
$: valid = validate(value)
|
||||
|
||||
$: if (isPassword) {
|
||||
value
|
||||
$$restProps.type = hidden ? initialType : 'text'
|
||||
@@ -23,28 +23,28 @@
|
||||
function toggle() {
|
||||
hidden = !hidden
|
||||
}
|
||||
function copyFN() {
|
||||
copyToClipboard(value)
|
||||
}
|
||||
function randomFN() {
|
||||
value = Hex.encode(getRandomBytes(20))
|
||||
value = Hex.encode(Crypto.getRandomBytes(32))
|
||||
}
|
||||
</script>
|
||||
|
||||
<label>
|
||||
<small disabled={$$restProps.disabled}>
|
||||
{label}
|
||||
{#if valid !== true}
|
||||
<span class="error-text">{valid}</span>
|
||||
{/if}
|
||||
</small>
|
||||
<input bind:value {...$$restProps} />
|
||||
<input bind:value {...$$restProps} class:valid={valid === true} />
|
||||
<div class="icons">
|
||||
{#if isPassword}
|
||||
<Icon class="icon" icon={hidden ? 'eye-sharp' : 'eye-off-sharp'} on:click={toggle} />
|
||||
<Icon class="icon" icon={hidden ? 'eye' : 'eye-off'} on:click={toggle} />
|
||||
{/if}
|
||||
{#if random}
|
||||
<Icon class="icon" icon="dice-sharp" on:click={randomFN} />
|
||||
<Icon class="icon" icon="dice" on:click={randomFN} />
|
||||
{/if}
|
||||
{#if copy}
|
||||
<Icon class="icon" icon="copy-sharp" on:click={copyFN} />
|
||||
<Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} />
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
@@ -55,6 +55,10 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
label > small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
@@ -68,6 +72,10 @@
|
||||
border-color: var(--ui-clr-primary);
|
||||
}
|
||||
|
||||
input:not(.valid) {
|
||||
border-color: var(--ui-clr-error);
|
||||
}
|
||||
|
||||
.icons {
|
||||
border: 1px red;
|
||||
position: absolute;
|