Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77b7ae1de6 | ||
|
|
be617383f4 | ||
|
|
3ff1f8202b | ||
|
|
e81dde63ff | ||
|
|
4f5f829c81 | ||
|
|
c78845ca7a | ||
|
|
dd213d6b41 | ||
|
|
849ab7e4c5 | ||
|
|
6a4d40e7be | ||
|
|
2f4ebb90e6 | ||
|
|
e0d22283c8 | ||
|
|
062054f450 | ||
|
|
1f180a2e53 | ||
|
|
6b29c6f069 | ||
|
|
40f3559533 | ||
|
|
ac686e1935 | ||
|
|
2cb984405f | ||
|
|
8a000ce131 | ||
|
|
38987efc8a | ||
|
|
c3e475c16d | ||
|
|
5ff4d42aa6 | ||
|
|
9c4fbc30f9 | ||
|
|
f55925de86 | ||
|
|
afbe6aac0f | ||
|
|
e14abeee74 | ||
|
|
f5823fb533 | ||
|
|
3e3d528d5a | ||
|
|
84ec85e96f | ||
|
|
905bce0072 | ||
|
|
e8c3b53e49 | ||
|
|
d40bbd25ea | ||
|
|
339c3ac445 | ||
|
|
e27915db06 | ||
|
|
d400cfc8e6 | ||
|
|
198accddeb | ||
|
|
3cbe9fabf2 | ||
|
|
2923aab916 | ||
|
|
b5102dc647 | ||
|
|
12d8b87cc1 | ||
|
|
f42662812f | ||
|
|
a551b16216 | ||
|
|
09840dcf0a | ||
|
|
24e99b84e0 | ||
|
|
690b955d5d | ||
|
|
9b0155dc9a | ||
|
|
0a56c4c572 | ||
|
|
f6bf8c656c | ||
|
|
1a243cc96a | ||
|
|
482795dd9a | ||
|
|
2907e7c002 | ||
|
|
57001e90a4 | ||
|
|
4cc9d8a758 | ||
|
|
d652c4ee1e | ||
|
|
096be03966 | ||
|
|
c53cde6886 | ||
|
|
0fa5a35dae | ||
|
|
ebbb4efa04 | ||
|
|
a248440bfd | ||
|
|
a1db60d159 | ||
|
|
c2653bee84 | ||
|
|
a2d2acc5de | ||
|
|
4cc821150d | ||
|
|
e7fb844f66 | ||
|
|
567a0bed68 | ||
|
|
c13e53404c | ||
|
|
82862f0e3e | ||
|
|
e20f4626e7 | ||
|
|
e440e4b7e0 | ||
|
|
808d846737 | ||
|
|
63c16a797b | ||
|
|
ea50590532 | ||
|
|
b22c3122d7 | ||
|
|
18af2b2f45 | ||
|
|
c2b557246b | ||
|
|
df9cd08473 | ||
|
|
0b8e1d1b2e | ||
|
|
70481341b9 | ||
|
|
6271ec1ee9 | ||
|
|
c7ec587a2d | ||
|
|
3e8e82f51c | ||
|
|
c314d4b485 | ||
|
|
57ea5f0b28 | ||
|
|
fca8761515 | ||
|
|
a47b8a482c | ||
|
|
847fc9677d | ||
|
|
6979be0c4a | ||
|
|
f61d3ece8b | ||
|
|
14d3e9eb03 | ||
|
|
7c6ba654f6 | ||
|
|
cb20224317 | ||
|
|
085b1c20df | ||
|
|
4b1f939281 | ||
|
|
7b919f2a53 | ||
|
|
868b49c1c3 | ||
|
|
6b5eea34a1 | ||
|
|
d70bee14af | ||
|
|
4960260076 | ||
|
|
3247a0cfca | ||
|
|
9527a499ed | ||
|
|
b5590bb5ef | ||
|
|
7691dc80f8 | ||
|
|
f8d8fa05b0 | ||
|
|
7aae690850 | ||
|
|
e15d9eb537 | ||
|
|
7fe70a6f3b | ||
|
|
d86ef79abd | ||
|
|
23446a4c74 | ||
|
|
ee92928d1f | ||
|
|
b60a079bbe | ||
|
|
ba474dff31 | ||
|
|
3cb002ee33 | ||
|
|
a10d8735dd | ||
|
|
dfa2401eea | ||
|
|
ea58d89f98 | ||
|
|
eaca1a981d | ||
|
|
199755d18e | ||
|
|
724d0709d3 | ||
|
|
bd5acca97a | ||
|
|
a0a99cd3cc | ||
|
|
c3794fa2b6 | ||
|
|
f9962c76c1 | ||
|
|
c2b81bc04d | ||
|
|
a45f6a3772 | ||
|
|
2006be0434 | ||
|
|
ca72e94e3c | ||
|
|
dbcb3870aa | ||
|
|
3ea176cc1f | ||
|
|
145f9ef18f | ||
|
|
784c54236b | ||
|
|
5648c76f78 | ||
|
|
7761c795df | ||
|
|
4aadeb492a | ||
|
|
0d9f3fe9c7 | ||
|
|
8ccfdd6e2e | ||
|
|
f790438104 | ||
|
|
5936f4588c | ||
|
|
d3c04f8fda | ||
|
|
f8c17487bd | ||
|
|
ed3e5f48a0 | ||
|
|
e08c9d1871 | ||
|
|
6d2150b0b6 | ||
|
|
3a68693be1 | ||
|
|
a612eec220 | ||
|
|
98d3b0d394 | ||
|
|
6aed2e2756 | ||
|
|
6bb527198a | ||
|
|
7050389316 | ||
|
|
0725a0c6f7 | ||
|
|
c8efcc04fc | ||
|
|
19bf155653 | ||
|
|
9a4e84db62 | ||
|
|
32cd3843a7 | ||
|
|
9b48d39c82 | ||
|
|
239e950f8e | ||
|
|
b00846ce9d | ||
|
|
e70f06f99f | ||
|
|
4781882c72 | ||
|
|
549dfb55db | ||
|
|
2653a4bccf | ||
|
|
7213e6c690 | ||
|
|
8a5f667ff6 | ||
|
|
fc3938701e | ||
|
|
23b4f81dac | ||
|
|
7c68620d8b | ||
|
|
eb76fe085a | ||
|
|
38540b33f2 |
|
Before Width: | Height: | Size: 30 KiB |
@@ -10,35 +10,32 @@ jobs:
|
||||
cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
cache: 'pnpm'
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm run build
|
||||
pnpm install
|
||||
pnpm --filter cryptgeon build
|
||||
|
||||
- run: npm publish
|
||||
working-directory: ./packages/cli
|
||||
- run: pnpm publish --filter cryptgeon
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: docker/setup-qemu-action@v2
|
||||
- uses: docker/setup-buildx-action@v2
|
||||
with:
|
||||
install: true
|
||||
- uses: actions/checkout@v7
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Docker Labels
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: cupcakearmy/cryptgeon
|
||||
tags: |
|
||||
@@ -46,12 +43,12 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
|
||||
@@ -10,30 +10,36 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# Node
|
||||
- uses: pnpm/action-setup@v3
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
cache: 'pnpm'
|
||||
node-version-file: '.nvmrc'
|
||||
|
||||
# Docker
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
|
||||
- uses: docker/setup-qemu-action@v4
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
- name: Prepare
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm install
|
||||
pnpm exec playwright install --with-deps
|
||||
pnpm run test:prepare
|
||||
|
||||
- name: Rust tests
|
||||
run: cargo test --manifest-path packages/backend/Cargo.toml
|
||||
|
||||
- name: Shared tests
|
||||
run: pnpm --filter @cryptgeon/shared test
|
||||
|
||||
- name: Run your tests
|
||||
run: pnpm test
|
||||
- uses: actions/upload-artifact@v3
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results
|
||||
name: playwright-report
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
@@ -8,3 +8,4 @@ target
|
||||
|
||||
# Testing
|
||||
test-results
|
||||
tmp
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Contributing
|
||||
|
||||
## Requirements
|
||||
|
||||
- [mise](https://mise.jdx.dev) — manages pnpm, rust, node (see `mise.toml`)
|
||||
- docker or [colima](https://github.com/abiosoft/colima) (for cache)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
mise install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Make sure docker/colima is running. This starts the cache (valkey/redis), the rust backend, the web client, and the CLI. The app is at [localhost:3000](http://localhost:3000).
|
||||
|
||||
## Tests
|
||||
|
||||
End-to-end tests with Playwright.
|
||||
|
||||
```sh
|
||||
pnpm run test:prepare
|
||||
pnpm run test:local
|
||||
```
|
||||
|
||||
## Release
|
||||
|
||||
1. Update version across packages:
|
||||
|
||||
```sh
|
||||
./version.mjs <semver>
|
||||
```
|
||||
|
||||
2. Create and push the tag:
|
||||
|
||||
```sh
|
||||
git tag v<semver>
|
||||
git push --tags
|
||||
```
|
||||
|
||||
The CI workflow publishes the CLI to npm and the Docker image to Docker Hub automatically.
|
||||
@@ -15,13 +15,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/:id",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
":id"
|
||||
],
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ":id"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
@@ -40,13 +35,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/:id",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
":id"
|
||||
],
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ":id"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
@@ -95,13 +85,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/:id",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
":id"
|
||||
],
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ":id"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
@@ -170,13 +155,8 @@
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ""]
|
||||
}
|
||||
},
|
||||
"response": [
|
||||
@@ -196,13 +176,8 @@
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ""]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
@@ -253,13 +228,8 @@
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ""]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
@@ -310,13 +280,8 @@
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ""]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
@@ -360,13 +325,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/:id",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
":id"
|
||||
],
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ":id"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
@@ -384,13 +344,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/:id",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
":id"
|
||||
],
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ":id"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
@@ -438,13 +393,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/notes/:id",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"notes",
|
||||
":id"
|
||||
],
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["notes", ":id"],
|
||||
"variable": [
|
||||
{
|
||||
"key": "id",
|
||||
@@ -495,13 +445,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/status/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"status",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["status", ""]
|
||||
}
|
||||
},
|
||||
"response": [
|
||||
@@ -512,13 +457,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/status/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"status",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["status", ""]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
@@ -562,13 +502,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/live/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"live",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["live", ""]
|
||||
},
|
||||
"description": "Return `200` for healthy service. `503` if service is unavailable."
|
||||
},
|
||||
@@ -580,13 +515,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/live/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"live",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["live", ""]
|
||||
}
|
||||
},
|
||||
"status": "OK",
|
||||
@@ -620,13 +550,8 @@
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{BASE}}/live/",
|
||||
"host": [
|
||||
"{{BASE}}"
|
||||
],
|
||||
"path": [
|
||||
"live",
|
||||
""
|
||||
]
|
||||
"host": ["{{BASE}}"],
|
||||
"path": ["live", ""]
|
||||
}
|
||||
},
|
||||
"status": "Service Unavailable",
|
||||
@@ -663,25 +588,21 @@
|
||||
"listen": "prerequest",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
"exec": [""]
|
||||
}
|
||||
},
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
""
|
||||
]
|
||||
"exec": [""]
|
||||
}
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "BASE",
|
||||
"value": "http://localhost:1234/api",
|
||||
"value": "http://localhost:3000/api",
|
||||
"type": "default"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# FRONTEND
|
||||
FROM node:18-alpine as client
|
||||
FROM node:24-alpine AS client
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
@@ -11,20 +11,20 @@ RUN pnpm run build
|
||||
|
||||
|
||||
# BACKEND
|
||||
FROM rust:1.76-alpine as backend
|
||||
FROM rust:1.95-alpine AS backend
|
||||
WORKDIR /tmp
|
||||
RUN apk add libc-dev openssl-dev alpine-sdk
|
||||
RUN apk add --no-cache libc-dev openssl-dev alpine-sdk
|
||||
COPY ./packages/backend ./
|
||||
RUN cargo build --release
|
||||
RUN RUSTFLAGS="-Ctarget-feature=-crt-static" cargo build --release
|
||||
|
||||
|
||||
# RUNNER
|
||||
FROM alpine
|
||||
FROM alpine:3
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache curl
|
||||
RUN apk add --no-cache curl libgcc
|
||||
COPY --from=backend /tmp/target/release/cryptgeon .
|
||||
COPY --from=client /tmp/packages/frontend/build ./frontend
|
||||
ENV FRONTEND_PATH="./frontend"
|
||||
ENV REDIS="redis://redis/"
|
||||
ENV CACHE="redis://cache/"
|
||||
EXPOSE 8000
|
||||
ENTRYPOINT [ "/app/cryptgeon" ]
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<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">
|
||||
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
|
||||
<br/><br/>
|
||||
|
||||
EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md)
|
||||
@@ -22,8 +22,6 @@ _cryptgeon_ is a secure, open source sharing note or file service inspired by [_
|
||||
It includes a server, a web page and a CLI client.
|
||||
|
||||
> 🌍 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.
|
||||
|
||||
## Live Service / Demo
|
||||
|
||||
@@ -39,6 +37,12 @@ npx cryptgeon send text "This is a secret note"
|
||||
|
||||
For more documentation about the CLI see the [readme](./packages/cli/README.md).
|
||||
|
||||
### Raycast Extension
|
||||
|
||||
There is an [official Raycast extension](https://www.raycast.com/cupcakearmy/cryptgeon).
|
||||
|
||||
<a title="Install cryptgeon Raycast Extension" href="https://www.raycast.com/cupcakearmy/cryptgeon"><img src="https://www.raycast.com/cupcakearmy/cryptgeon/install_button@2x.png?v=1.1" height="64" alt="" style="height: 64px;"></a>
|
||||
|
||||
## Features
|
||||
|
||||
- send text or files
|
||||
@@ -56,6 +60,8 @@ client side with the <code>key</code> and then sent to the server. data is store
|
||||
never persisted to disk. the server never sees the encryption key and cannot decrypt the contents
|
||||
of the notes even if it tried to.
|
||||
|
||||
> View counts are guaranteed with one running instance of cryptgeon. Multiple instances connected to the same cache instance can run into race conditions, where a note might be retrieved more than the view count allows.
|
||||
|
||||
## Screenshot
|
||||
|
||||

|
||||
@@ -63,18 +69,24 @@ of the notes even if it tried to.
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
||||
| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `CACHE` | `redis://cache/` | Cache URL (valkey or redis) 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. |
|
||||
| `ALLOW_FILES` | `true` | Allow uploading files. If set to `false`, users will only be allowed to create text notes. |
|
||||
| `ID_LENGTH` | `32` | Set the size of the note `id` in bytes. By default this is `32` bytes. This is useful for reducing link size. _This setting does not affect encryption strength_. |
|
||||
| `CACHE_PREFIX` | `""` | Optional prefix for all cache keys. Useful when sharing a cache instance with other apps via ACL namespaces. |
|
||||
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
|
||||
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
|
||||
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
|
||||
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
|
||||
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
|
||||
| `THEME_NEW_NOTE_NOTICE` | `true` | Show the message about how notes are stored in the memory and may be evicted after creating a new note. Defaults to `true`. |
|
||||
| `THEME_HOME_LINK` | `true` | Show the `/home` link in the footer. Defaults to `true`. |
|
||||
| `IMPRINT_URL` | `""` | Custom url for an Imprint hosted somewhere else. Must be publicly reachable. Takes precedence above `IMPRINT_HTML`. |
|
||||
| `IMPRINT_HTML` | `""` | Alternative to `IMPRINT_URL`, this can be used to specify the HTML code to show on `/imprint`. Only `IMPRINT_HTML` or `IMPRINT_URL` should be specified, not both. |
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -89,19 +101,24 @@ Docker is the easiest way. There is the [official image here](https://hub.docker
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
cache:
|
||||
image: valkey/valkey:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: valkey-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://redis.io/docs/manual/eviction/
|
||||
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
||||
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
depends_on:
|
||||
- redis
|
||||
- cache
|
||||
environment:
|
||||
# Size limit for a single note.
|
||||
SIZE_LIMIT: 4 MiB
|
||||
@@ -110,7 +127,7 @@ services:
|
||||
|
||||
# Optional health checks
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
|
||||
# interval: 1m
|
||||
# timeout: 3s
|
||||
# retries: 2
|
||||
@@ -139,53 +156,15 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
|
||||
- English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
|
||||
- German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
|
||||
|
||||
## Development
|
||||
### Written Guides
|
||||
|
||||
**Requirements**
|
||||
- French by [zarevskaya](https://belginux.com/installer-cryptgeon-avec-docker/)
|
||||
- Italian by [@nicfab](https://notes.nicfab.eu/it/posts/cryptgeon/)
|
||||
- English by [@nicfab](https://notes.nicfab.eu/en/posts/cryptgeon/)
|
||||
|
||||
- `pnpm`: `>=6`
|
||||
- `node`: `>=18`
|
||||
- `rust`: edition `2021`
|
||||
## Contributing
|
||||
|
||||
**Install**
|
||||
|
||||
```bash
|
||||
pnpm 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.
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Running `pnpm run dev` in the root folder will start the following things:
|
||||
|
||||
- redis docker container
|
||||
- rust backend
|
||||
- client
|
||||
- cli
|
||||
|
||||
You can see the app under [localhost:1234](http://localhost:1234).
|
||||
|
||||
> There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json)
|
||||
|
||||
### Tests
|
||||
|
||||
Tests are end to end tests written with Playwright.
|
||||
|
||||
```sh
|
||||
pnpm run test:prepare
|
||||
|
||||
# Use the test or test:local script. The local version only runs in one browser for quicker development.
|
||||
pnpm run test:local
|
||||
```
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
<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.md) | [简体中文](README_zh-CN.md) | ES
|
||||
@@ -22,8 +21,6 @@ _cryptgeon_ es un servicio seguro y de código abierto para compartir notas o ar
|
||||
Incluye un servidor, una página web y una interfaz de línea de comandos (CLI, por sus siglas en inglés).
|
||||
|
||||
> 🌍 Si quieres traducir este proyecto no dudes en ponerte en contacto conmigo.
|
||||
>
|
||||
> Gracias a [Lokalise](https://lokalise.com/) por darnos acceso gratis a su plataforma.
|
||||
|
||||
## Demo
|
||||
|
||||
@@ -43,7 +40,7 @@ Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/RE
|
||||
|
||||
- enviar texto o archivos
|
||||
- el servidor no puede desencriptar el contenido debido a que la encriptación se hace del lado del cliente
|
||||
- restriccion de vistas o de tiempo
|
||||
- restricción de vistas o de tiempo
|
||||
- en memoria, sin persistencia
|
||||
- compatibilidad obligatoria con el modo oscuro
|
||||
|
||||
@@ -60,40 +57,46 @@ se usa para guardar y recuperar la nota. Después la nota es encriptada con la <
|
||||
## Variables de entorno
|
||||
|
||||
| Variable | Default | Descripción |
|
||||
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `REDIS` | `redis://redis/` | Redis URL a la que conectarse. [Según el formato](https://docs.rs/redis/latest/redis/#connection-parameters) |
|
||||
| `SIZE_LIMIT` | `1 KiB` | Tamaño máximo. Valores aceptados según la [unidad byte](https://docs.rs/byte-unit/). <br> `512 MiB` es el máximo permitido. <br> El frontend mostrará ese número, incluyendo el ~35% de sobrecarga de codificación. |
|
||||
| `MAX_VIEWS` | `100` | Número máximo de vistas. |
|
||||
| `MAX_EXPIRATION` | `360` | Tiempo máximo de expiración en minutos. |
|
||||
| `ALLOW_ADVANCED` | `true` | Permitir configuración personalizada. Si se establece en `false` todas las notas serán de una sola vista. |
|
||||
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es util para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
|
||||
| `ID_LENGTH` | `32` | Establece el tamaño en bytes de la `id` de la nota. Por defecto es de `32` bytes. Esto es útil para reducir el tamaño del link. _Esta configuración no afecta el nivel de encriptación_. |
|
||||
| `VERBOSITY` | `warn` | Nivel de verbosidad del backend. [Posibles valores](https://docs.rs/env_logger/latest/env_logger/#enabling-logging): `error`, `warn`, `info`, `debug`, `trace` |
|
||||
| `THEME_IMAGE` | `""` | Imagen personalizada para reemplazar el logo. Debe ser accesible públicamente. |
|
||||
| `THEME_TEXT` | `""` | Texto personalizado para reemplazar la descripción bajo el logo. |
|
||||
| `THEME_PAGE_TITLE` | `""` | Texto personalizado para el título |
|
||||
| `THEME_FAVICON` | `""` | Url personalizada para el favicon. Debe ser accesible públicamente. |
|
||||
| `THEME_HOME_LINK` | `true` | Mostrar el enlace `/home` en el pie de página. El valor predeterminado es `true`. |
|
||||
|
||||
## Despliegue
|
||||
|
||||
> ℹ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptacón.
|
||||
> ℹ️ Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptación.
|
||||
|
||||
> ℹ️ Hay un endpoint para verificar el estado, lo encontramos en `/api/health/`. Regresa un código 200 o 503.
|
||||
|
||||
### Docker
|
||||
|
||||
Docker es la manera más fácil. Aquí encontramos [la imágen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
|
||||
Docker es la manera más fácil. Aquí encontramos [la imagen oficial](https://hub.docker.com/r/cupcakearmy/cryptgeon).
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: redis-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://redis.io/docs/manual/eviction/
|
||||
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
||||
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
@@ -136,57 +139,13 @@ Hay una [guía](https://mariushosting.com/how-to-install-cryptgeon-on-your-synol
|
||||
- En inglés, por [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
|
||||
- En alemán, por [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
|
||||
|
||||
## Desarrollo
|
||||
## Contribuir
|
||||
|
||||
**Requisitos**
|
||||
|
||||
- `pnpm`: `>=6`
|
||||
- `node`: `>=18`
|
||||
- `rust`: edition `2021`
|
||||
|
||||
**Instalación**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
# También necesitas cargo-watch, si no lo tienes instalado.
|
||||
# https://lib.rs/crates/cargo-watch
|
||||
cargo install cargo-watch
|
||||
```
|
||||
|
||||
**Ejecutar**
|
||||
|
||||
Asegurate de que docker se esté ejecutando.
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Ejecutando `pnpm run dev` en la carpeta raíz iniciará lo siguiente:
|
||||
|
||||
- redis docker container
|
||||
- rust backend
|
||||
- client
|
||||
- cli
|
||||
|
||||
Puedes ver la app en [localhost:1234](http://localhost:1234).
|
||||
|
||||
> Existe una colección de Postman con algunas peticiones de ejemplo [disponible en el repo](./Cryptgeon.postman_collection.json)
|
||||
|
||||
### Tests
|
||||
|
||||
Los tests son end-to-end tests escritos con Playwright.
|
||||
|
||||
```sh
|
||||
pnpm run test:prepare
|
||||
|
||||
# Usa el script test o test:local. La versión local solo corre en el navegador para acelerar el desarrollo.
|
||||
pnpm run test:local
|
||||
```
|
||||
Ver [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## Seguridad
|
||||
|
||||
Por favor dirigite a la sección de seguridad [aquí](./SECURITY.md).
|
||||
Por favor dirígete a la sección de seguridad [aquí](./SECURITY.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
<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) | 简体中文 | [ES](README_ES.md)
|
||||
@@ -21,8 +20,6 @@
|
||||
_加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全、开源共享密信和文件共享服务器
|
||||
|
||||
> 🌍 如果你想翻译此项目请随时与我联系.
|
||||
>
|
||||
> 感谢 [Lokalise](https://lokalise.com/) 提供免费的平台服务支持
|
||||
|
||||
## 演示示例
|
||||
|
||||
@@ -48,7 +45,7 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
|
||||
## 环境变量
|
||||
|
||||
| 变量名称 | 默认值 | 描述 |
|
||||
| ----------------- | ---------------- | --------------------------------------------------------------------------------- |
|
||||
| ---------------- | ---------------- | --------------------------------------------------------------------------------- |
|
||||
| `REDIS` | `redis://redis/` | Redis 连接 URL。 |
|
||||
| `SIZE_LIMIT` | `1 KiB` | 最大请求体(body)限制。有关支持的数值请查看 [字节单位](https://docs.rs/byte-unit/) |
|
||||
| `MAX_VIEWS` | `100` | 密信最多查看次数限制 |
|
||||
@@ -56,6 +53,7 @@ _加密鸽_ 是一个受 [_PrivNote_](https://privnote.com)项目启发的安全
|
||||
| `ALLOW_ADVANCED` | `true` | 是否允许自定义设置,该项如果设为`false`,则不会显示自定义设置模块 |
|
||||
| `THEME_IMAGE` | `""` | 自定义 Logo 图片,你在这里填写的的图片链接必须是可以公开访问的。 |
|
||||
| `THEME_TEXT` | `""` | 自定义在 Logo 下方的文本。 |
|
||||
| `THEME_HOME_LINK` | `true` | 是否在页脚显示 `/home` 链接。默认为 `true`。 |
|
||||
|
||||
## 部署
|
||||
|
||||
@@ -69,11 +67,19 @@ Docker 是最简单的部署方式。这里是[官方镜像的地址](https://hu
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: redis-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
@@ -99,7 +105,7 @@ services:
|
||||
- 域名 `example.org`
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
version: "3.8"
|
||||
|
||||
networks:
|
||||
proxy:
|
||||
@@ -108,7 +114,14 @@ networks:
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
# This is required to stay in RAM only.
|
||||
command: redis-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://redis.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
@@ -125,54 +138,9 @@ services:
|
||||
- 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 正在运行
|
||||
|
||||
```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
|
||||
```
|
||||
参见 [CONTRIBUTING.md](./CONTRIBUTING.md)。
|
||||
|
||||
###### Attributions
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
},
|
||||
{
|
||||
"path": "packages/cli"
|
||||
},
|
||||
{
|
||||
"path": "packages/shared"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
# DEV Compose file.
|
||||
# For a production file see: README.md
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
cache:
|
||||
image: valkey/valkey:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: valkey-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
app:
|
||||
build: .
|
||||
env_file: .dev.env
|
||||
env_file: .env.dev
|
||||
depends_on:
|
||||
- redis
|
||||
- cache
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 1234:8000
|
||||
- 3000:8000
|
||||
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/api/live/']
|
||||
test: ['CMD', 'curl', '--fail', 'http://127.0.0.1:8000/healthz']
|
||||
interval: 1m
|
||||
timeout: 3s
|
||||
retries: 2
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
cache:
|
||||
image: valkey/valkey:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: valkey-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://redis.io/docs/manual/eviction/
|
||||
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
|
||||
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
depends_on:
|
||||
- redis
|
||||
- cache
|
||||
environment:
|
||||
# Size limit for a single note.
|
||||
SIZE_LIMIT: 4 MiB
|
||||
@@ -19,7 +22,7 @@ services:
|
||||
|
||||
# Optional health checks
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
|
||||
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/healthz"]
|
||||
# interval: 1m
|
||||
# timeout: 3s
|
||||
# retries: 2
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Todo
|
||||
|
||||
- also update readmes in other languages
|
||||
- use catalog install for common deps (typescript, vite, tsdown, etc. ) please suggest.
|
||||
- move formatting, linting, type checking and git hooks to vite-plus (uses oxlint, oxfmt, vitest and git hook dispatcher)
|
||||
- re-add CSP (Content-Security-Policy) into axum router ( (was in csp.rs, removed as unused)
|
||||
@@ -0,0 +1,66 @@
|
||||
# v3 Breaking Changes
|
||||
|
||||
A list of changes users and operators need to consider when upgrading from v2 to v3.
|
||||
|
||||
## API
|
||||
|
||||
- All note endpoints moved under `/api/v3/notes/` (was `/api/notes/`)
|
||||
- Status endpoint moved to `/api/v3/status` (was `/api/status`)
|
||||
- All request/response bodies are now **MessagePack** (`Content-Type: application/msgpack`), not JSON
|
||||
- Health check moved to `/healthz` (was `/api/live`)
|
||||
- Notes can now have **both** `views` and `expiration` set simultaneously (previously mutually exclusive)
|
||||
|
||||
## API payload structure
|
||||
|
||||
The wire format changed entirely. v2 used:
|
||||
|
||||
```json
|
||||
{ "contents": "<encrypted string>", "meta": "<stringified JSON>", "views": 5, "expiration": 30 }
|
||||
```
|
||||
|
||||
v3 uses msgpack:
|
||||
|
||||
```
|
||||
{ meta: { views?, expiration?, extra? }, data: <encrypted bytes> }
|
||||
```
|
||||
|
||||
- `meta.extra` holds client-opaque data (e.g. scrypt derivation params), size-limited (default 512 bytes)
|
||||
- `data` is the encrypted blob — the server never inspects its contents
|
||||
- The encrypted inner payload is itself msgpack: `{ type: "text", data: string }` or `{ type: "files", data: [{ name, mime, size, data }] }`
|
||||
|
||||
## Environment variables
|
||||
|
||||
| v2 | v3 |
|
||||
| ------------------- | -------------------- |
|
||||
| `REDIS` | `CACHE` |
|
||||
| `REDIS_PREFIX` | `CACHE_PREFIX` |
|
||||
| _(new)_ | `EXTRA_SIZE_LIMIT` |
|
||||
|
||||
The `CACHE` env var accepts any RESP-compatible URL (valkey or redis).
|
||||
`EXTRA_SIZE_LIMIT` (default `512`) limits the `extra` field size in bytes.
|
||||
|
||||
## Docker / Compose
|
||||
|
||||
- The `redis` service in docker-compose is renamed to `cache`
|
||||
- Healthcheck URL updated: `http://127.0.0.1:8000/api/live/` → `http://127.0.0.1:8000/healthz`
|
||||
- The default image stays `valkey/valkey:7-alpine` but operators can swap for any redis-compatible image
|
||||
|
||||
## CLI (`cryptgeon` npm package)
|
||||
|
||||
- Dropped `occulto` dependency — now uses `@noble/ciphers` + `@noble/hashes` internally
|
||||
- Encryption changed from AES to **XChaCha20-Poly1305**
|
||||
- The local `shared/` module removed — now imports from `@cryptgeon/shared` (workspace-internal)
|
||||
- Notes created with v2 (AES) are **not readable** by v3 and vice versa
|
||||
|
||||
## Frontend
|
||||
|
||||
- Dropped `occulto` dependency
|
||||
- Package import changed from `cryptgeon/shared` to `@cryptgeon/shared`
|
||||
- Notes created in v2 are not accessible from the v3 frontend
|
||||
|
||||
## Storage
|
||||
|
||||
- Cache storage format changed from JSON blobs to hashes with atomic `HINCRBY` for view counting
|
||||
- The per-note lock (`lock.rs`) is removed — no longer needed
|
||||
- Existing v2 notes in cache are **not migrated** and will be inaccessible after upgrade
|
||||
- Ensure cache is empty (or flush) before deploying v3
|
||||
@@ -0,0 +1,323 @@
|
||||
# v3 Plan
|
||||
|
||||
> Status: **Draft** — agreed on architecture, schema open for iteration.
|
||||
>
|
||||
> See also: [v3 Breaking Changes](./v3-breaking-changes.md) for the upgrade guide.
|
||||
|
||||
## Goals
|
||||
|
||||
- **XChaCha20-Poly1305** for encryption (replaces AES/`occulto`)
|
||||
- **MessagePack** for all API request/response bodies (replaces JSON)
|
||||
- **LZ4 compression** for note payloads (client-side, before encryption — pure JS, no wasm)
|
||||
- **Cache hashes** (valkey or redis, both speak RESP) for storage — replaces JSON-blob-per-key
|
||||
- **Clean break** from v1 — no backward compatibility, no v1 routes
|
||||
- Remove all Redis references (env vars, service names, docs) in favor of the generic "cache" naming, so operators can choose valkey or redis
|
||||
- Shared TypeScript package as the single source of truth for crypto + API client + types
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Keeping v1 alive alongside v3
|
||||
- Changing the backend language/framework (stays Rust + axum)
|
||||
- Changing storage backend (stays valkey or redis via the `redis` crate — no separate crate)
|
||||
- Publishing `@cryptgeon/shared` as a standalone npm package (workspace-internal for now)
|
||||
|
||||
---
|
||||
|
||||
## 1. Shared package — `@cryptgeon/shared`
|
||||
|
||||
Location: `packages/shared` (currently empty).
|
||||
|
||||
ESM-only, TypeScript-only. Consumed by both `packages/cli` and `packages/frontend` via workspace dependency.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `@noble/ciphers` — XChaCha20-Poly1305
|
||||
- `@noble/hashes` — scrypt
|
||||
- `@msgpack/msgpack` — encode/decode
|
||||
- `lz4js` — LZ4 compression (pure JS, no wasm)
|
||||
- `ky` — HTTP client
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
packages/shared/src/
|
||||
index.ts # re-exports
|
||||
crypto.ts # key derivation, encrypt, decrypt
|
||||
compression.ts # LZ4 compress / decompress
|
||||
types.ts # Note, NoteMetadata, FileDTO, Status, etc.
|
||||
api.ts # high-level client: create, info, view, status
|
||||
api.test.ts # tests
|
||||
crypto.test.ts # tests
|
||||
compression.test.ts # tests
|
||||
```
|
||||
|
||||
### `crypto.ts`
|
||||
|
||||
- `deriveKey(password: string): Uint8Array` — scrypt, N=2^15, r=8, p=1, dkLen=32, fixed app-specific salt
|
||||
- `generateKey(): Uint8Array` — `randomBytes(32)`
|
||||
- `encrypt(data: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).encrypt(data)`
|
||||
- `decrypt(ciphertext: Uint8Array, key: Uint8Array): Uint8Array` — `managedNonce(xchacha20poly1305)(key).decrypt(ciphertext)`
|
||||
|
||||
> **Note (carried over from msgpack branch):** the v2 stub had a bug — `decrypt` passed `key` as a second arg to `chacha.decrypt`, which only takes ciphertext. v3 must not repeat this.
|
||||
|
||||
### `compression.ts`
|
||||
|
||||
- `compress(data: Uint8Array): Uint8Array` — LZ4 block format
|
||||
- `decompress(data: Uint8Array): Uint8Array` — LZ4 block format
|
||||
|
||||
Compression is a **client-only** concern. The server never sees or knows about compression — it stores the encrypted `data` blob as opaque bytes. The pipeline is:
|
||||
|
||||
```
|
||||
msgpack encode → LZ4 compress → XChaCha20-Poly1305 encrypt
|
||||
XChaCha20-Poly1305 decrypt → LZ4 decompress → msgpack decode
|
||||
```
|
||||
|
||||
Compression runs on the plaintext (inner msgpack), never on ciphertext — encrypted data is high-entropy and incompressible. Always-on for v3 (all clients share the same package, no interop flag needed).
|
||||
|
||||
### `api.ts`
|
||||
|
||||
High-level client. All requests/responses are msgpack (`Content-Type: application/msgpack`). Methods:
|
||||
|
||||
- `setOptions({ server })` / `getOptions()`
|
||||
- `create(note, key): Promise<{ id: string }>` — encodes msgpack, compresses (LZ4), encrypts, POST `/api/v3/notes/`
|
||||
- `info(id): Promise<NoteInfo>` — GET `/api/v3/notes/{id}`, returns metadata only (no `data`)
|
||||
- `view(id, key): Promise<NotePublic>` — DELETE `/api/v3/notes/{id}`, decrypts `data`, decompresses (LZ4), decodes msgpack
|
||||
- `status(): Promise<Status>` — GET `/api/v3/status` (still JSON — server config, not note data)
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend (Rust)
|
||||
|
||||
### 2.1 Storage — `store.rs` rewrite
|
||||
|
||||
Switch from JSON-blob-per-key to **cache hashes** (valkey or redis, both speak RESP):
|
||||
|
||||
```
|
||||
Key: {CACHE_PREFIX}{id}
|
||||
Fields:
|
||||
views (i32) # remaining views, or absent
|
||||
expiration (u32) # unix timestamp, or absent
|
||||
type ("text"|"file")
|
||||
derivation (msgpack bytes, optional) # scrypt salt+params if password-based
|
||||
data (bytes) # encrypted msgpack blob
|
||||
```
|
||||
|
||||
Functions:
|
||||
|
||||
- `set(id, note)` → `HSET` all fields + `EXPIRE` (if time-limited)
|
||||
- `get_meta(id)` → `HMGET views expiration type derivation` — never touches `data` (cheap preview)
|
||||
- `get_data(id)` → `HGET data` — only when consuming
|
||||
- `decrement_view(id)` → `HINCRBY views -1` — **atomic**, see 2.2
|
||||
- `del(id)` → `DEL`
|
||||
- `can_reach_cache()` — health check (renamed from `can_reach_redis`)
|
||||
|
||||
Use `rmp-serde` for msgpack (de)serialization of note structs.
|
||||
|
||||
### 2.2 Remove `lock.rs`
|
||||
|
||||
The per-id `Mutex` map in `SharedState` existed only because the consume endpoint did non-atomic read-modify-write on `views`. With `HINCRBY` this is atomic at the cache level.
|
||||
|
||||
- Delete `packages/backend/src/lock.rs`
|
||||
- Remove `SharedState` from `main.rs` (the `.with_state(shared_state)` call)
|
||||
- Remove the lock map + `Arc`/`Mutex` imports
|
||||
|
||||
### 2.3 Rewrite `note/`
|
||||
|
||||
- `model.rs` — msgpack-compatible structs (derive `Serialize`/`Deserialize` for `rmp-serde`)
|
||||
- `routes.rs` — three handlers:
|
||||
|
||||
#### `create` — `POST /api/v3/notes/`
|
||||
|
||||
- Accepts `application/msgpack` body (raw `Bytes`)
|
||||
- Deserialize with rmp-serde
|
||||
- Validate:
|
||||
- At least one of `views`/`expiration` must be set
|
||||
- `views` ≤ `MAX_VIEWS` and ≥ 1
|
||||
- `expiration` ≤ `MAX_EXPIRATION` (minutes) and ≥ 1
|
||||
- If `ALLOW_ADVANCED=false`: force `views=1, expiration=None`
|
||||
- Store via `store::set`
|
||||
- Return `{ id }` as msgpack
|
||||
|
||||
#### `preview` (info) — `GET /api/v3/notes/{id}`
|
||||
|
||||
- `store::get_meta(id)` — does not load `data`
|
||||
- Return metadata as msgpack (no `data` field)
|
||||
- `404` if not found
|
||||
|
||||
#### `view` (consume) — `DELETE /api/v3/notes/{id}`
|
||||
|
||||
- If `views` is set:
|
||||
- `HINCRBY views -1` (atomic)
|
||||
- If result ≤ 0: `HGET data`, `DEL` key, return data
|
||||
- If result > 0: `HGET data`, return data (note survives for remaining views)
|
||||
- If `views` is not set (time-only):
|
||||
- `HGET data`, `DEL` key, return data
|
||||
- Expiration handled lazily by cache (`EXPIRE` on the key) — no manual `if e < n` check on read
|
||||
|
||||
### 2.4 Config — `config.rs`
|
||||
|
||||
Rename:
|
||||
- `REDIS` env → `CACHE`
|
||||
- `REDIS_PREFIX` → `CACHE_PREFIX`
|
||||
- `REDIS_CLIENT` static → `CACHE_CLIENT`
|
||||
|
||||
Everything else stays.
|
||||
|
||||
### 2.5 Health — `health/mod.rs`
|
||||
|
||||
- Rename `can_reach_redis` → `can_reach_cache`. Update panic message in `main.rs`.
|
||||
- Move route from `/api/live` to `/healthz` (k8s standard). Not under `/api/v3/` — health checks are infrastructure, not API surface.
|
||||
|
||||
### 2.6 Status — `status/mod.rs`
|
||||
|
||||
Keep as JSON. It's server configuration, not note data — msgpack adds nothing. Frontend fetches once on load.
|
||||
|
||||
### 2.7 Dependencies — `Cargo.toml`
|
||||
|
||||
- Add `rmp-serde` (msgpack)
|
||||
- Remove `serde_json` if no longer used (status endpoint still uses `Json<T>` which needs `serde_json` — keep)
|
||||
- Keep `redis` crate (RESP client, works with valkey and redis)
|
||||
|
||||
---
|
||||
|
||||
## 3. "Both" constraint (views AND expiration)
|
||||
|
||||
New in v3: a note can have **both** `views` and `expiration` set simultaneously.
|
||||
|
||||
Implementation:
|
||||
- `views` decremented via `HINCRBY views -1` on each consume
|
||||
- `expiration` set via key-level `EXPIRE` (unix timestamp → seconds remaining)
|
||||
- Whichever trips first removes the note:
|
||||
- Views hit 0 → we `DEL` on the last consume
|
||||
- Time expires → cache lazily removes the key
|
||||
- No read-time expiration check needed in application code
|
||||
|
||||
---
|
||||
|
||||
## 4. Infra / docs cleanup
|
||||
|
||||
- `docker-compose.dev.yaml`: rename `redis` service → `cache` (image stays `valkey/valkey:7-alpine`, operators can swap for redis)
|
||||
- `docker-compose.yaml` (if present): same
|
||||
- `Dockerfile`: `ENV REDIS=...` → `ENV CACHE=...`
|
||||
- `package.json` (root): `dev:docker` script service name
|
||||
- `README.md`, `README_ES.md`, `README_zh-CN.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, `examples/*` — replace "redis" with "cache" (or "valkey/redis" where context calls for naming the implementation)
|
||||
- `Cryptgeon.postman_collection.json` — update content types to `application/msgpack`
|
||||
- `.env.dev` — update `REDIS` → `CACHE` if present
|
||||
- Healthcheck URLs: update all `/api/live` references → `/healthz` (docker-compose files, README, postman collection)
|
||||
|
||||
---
|
||||
|
||||
## 5. CLI (`packages/cli`)
|
||||
|
||||
- Drop `occulto` dependency
|
||||
- Delete `packages/cli/src/shared/` (api.ts, adapters.ts, shared.ts) — replaced by `@cryptgeon/shared`
|
||||
- `actions/upload.ts` and `actions/download.ts` call into `@cryptgeon/shared` API client
|
||||
- Package still published as `cryptgeon` on npm
|
||||
- `@cryptgeon/shared` stays workspace-internal (not published) for now
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend (`packages/frontend`)
|
||||
|
||||
- Drop `occulto` dependency
|
||||
- Import from `@cryptgeon/shared` instead of `cryptgeon/shared`
|
||||
- Update `package.json`: `"cryptgeon": "workspace:*"` → `"@cryptgeon/shared": "workspace:*"`
|
||||
- Update files:
|
||||
- `src/lib/views/Create.svelte`
|
||||
- `src/lib/ui/ShowNote.svelte`
|
||||
- `src/lib/ui/FileUpload.svelte`
|
||||
- `src/lib/ui/PastedFilesPreview.svelte`
|
||||
- `src/lib/ui/AdvancedParameters.svelte`
|
||||
- `src/lib/stores/status.ts`
|
||||
- `src/routes/note/[id]/+page.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 7. msgpack note schema — "matrioshka" design
|
||||
|
||||
The server is **agnostic to the content**. It only sees an outer envelope with metadata and an opaque encrypted blob. The content type (text vs. files) lives inside the encrypted inner layer, invisible to the server.
|
||||
|
||||
### Outer layer (server-visible)
|
||||
|
||||
```
|
||||
{
|
||||
meta: {
|
||||
expiration: u32? # optional, unix timestamp
|
||||
views: u32? # optional, remaining view count
|
||||
extra: bytes? # optional, client-opaque, size-limited
|
||||
}
|
||||
data: bytes # encrypted inner msgpack blob
|
||||
}
|
||||
```
|
||||
|
||||
- `meta.expiration` / `meta.views`: at least one must be set; both can be set simultaneously (see section 3)
|
||||
- `meta.extra`: opaque client-owned data the server stores and returns verbatim in preview, but never interprets. Used for `derivation` (scrypt salt + params) so the client knows at preview time whether to prompt for a password. Size-limited (e.g. 512 bytes) to prevent abuse.
|
||||
|
||||
### Inner layer (encrypted, client-only)
|
||||
|
||||
Inside the encrypted `data` blob, after decryption, is a msgpack union:
|
||||
|
||||
```
|
||||
# Text note
|
||||
{ type: "text", data: string }
|
||||
|
||||
# File note
|
||||
{ type: "files", data: [{ name: string, mime: string, data: bytes }] }
|
||||
```
|
||||
|
||||
The server never sees this structure — it stores/retrieves `data` as opaque bytes.
|
||||
|
||||
The inner msgpack blob is **LZ4-compressed before encryption** (see `compression.ts`). Full client pipeline: `msgpack encode → lz4 compress → xchacha20poly1305 encrypt`, reversed on consume. The server is unaware of compression — it only ever handles the encrypted `data` bytes.
|
||||
|
||||
### Endpoints
|
||||
|
||||
#### `POST /api/v3/notes/` — create
|
||||
|
||||
**Request** (msgpack): outer layer `{ meta: { expiration?, views?, extra? }, data }`
|
||||
|
||||
**Response** (msgpack): `{ id: string }`
|
||||
|
||||
#### `GET /api/v3/notes/{id}` — preview / info
|
||||
|
||||
**Response** (msgpack): `{ meta: { expiration?, views?, extra? } }`
|
||||
|
||||
Returns metadata only — does not load `data` from cache. Client inspects `meta.extra` to determine key derivation strategy (password vs. URL-fragment key) before consuming.
|
||||
|
||||
#### `DELETE /api/v3/notes/{id}` — view / consume
|
||||
|
||||
**Response** (msgpack): `{ meta: { expiration?, views?, extra? }, data: bytes }`
|
||||
|
||||
Returns the full envelope. Client decrypts `data` using key derived from `meta.extra` (if present) or URL fragment, then decodes the inner msgpack to get text/files.
|
||||
|
||||
#### `GET /api/v3/status` — server config
|
||||
|
||||
**Response** (JSON, not msgpack): server configuration, not note data. Kept as JSON for simplicity.
|
||||
|
||||
### Valkey hash field layout
|
||||
|
||||
```
|
||||
Key: {CACHE_PREFIX}{id}
|
||||
Fields:
|
||||
views (i32) # remaining views, or absent
|
||||
expiration (u32) # unix timestamp, or absent
|
||||
extra (bytes) # client-opaque, size-limited
|
||||
data (bytes) # encrypted msgpack blob
|
||||
```
|
||||
|
||||
`get_meta(id)` does `HMGET views expiration extra` — never touches `data`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation order
|
||||
|
||||
1. Shared package scaffolding (package.json, tsconfig, vitest config)
|
||||
2. `crypto.ts` + tests
|
||||
3. `compression.ts` + tests
|
||||
4. `types.ts`
|
||||
5. Backend: config rename + store rewrite + remove lock.rs
|
||||
6. Backend: note routes rewrite (msgpack)
|
||||
7. `api.ts` in shared (client) + tests
|
||||
8. CLI rewrite (drop shared/, use @cryptgeon/shared)
|
||||
9. Frontend migration
|
||||
10. Infra/docs cleanup (cache rename, compose, Dockerfile)
|
||||
11. Integration tests (playwright)
|
||||
@@ -2,7 +2,15 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
image: valkey/valkey:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: valkey-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
|
||||
@@ -108,8 +108,15 @@ networks:
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
image: valkey/valkey:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: valkey-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Assumptions:
|
||||
|
||||
- Traefik 2 installed.
|
||||
- Traefik 2/3 installed.
|
||||
- External proxy docker network `proxy`.
|
||||
- A certificate resolver `le`.
|
||||
- A https entrypoint `secure`.
|
||||
@@ -17,8 +17,15 @@ networks:
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
image: valkey/valkey:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: valkey-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
app:
|
||||
image: cupcakearmy/cryptgeon:latest
|
||||
@@ -34,3 +41,51 @@ services:
|
||||
- traefik.http.routers.cryptgeon.entrypoints=secure
|
||||
- traefik.http.routers.cryptgeon.tls.certresolver=le
|
||||
```
|
||||
|
||||
## With basic auth
|
||||
|
||||
Some times it's useful to hide the service behind auth. This is easily achieved with traefik middleware. Many reverse proxies support similar features, so while traefik is used in this example, other reverse proxies can do the same.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:v3.0
|
||||
command:
|
||||
- "--api.insecure=true"
|
||||
- "--providers.docker=true"
|
||||
- "--providers.docker.exposedbydefault=false"
|
||||
- "--entrypoints.web.address=:80"
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:7-alpine
|
||||
# This is required to stay in RAM only.
|
||||
command: valkey-server --save "" --appendonly no
|
||||
# Set a size limit. See link below on how to customise.
|
||||
# https://valkey.io/docs/latest/operate/rs/databases/memory-performance/eviction-policy/
|
||||
# --maxmemory 1gb --maxmemory-policy allkeys-lrulpine
|
||||
# This prevents the creation of an anonymous volume.
|
||||
tmpfs:
|
||||
- /data
|
||||
|
||||
cryptgeon:
|
||||
image: cupcakearmy/cryptgeon
|
||||
depends_on:
|
||||
- redis
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.cryptgeon.rule=Host(`cryptgeon.localhost`)"
|
||||
- "traefik.http.routers.cryptgeon.entrypoints=web"
|
||||
- "traefik.http.routers.cryptgeon.middlewares=cryptgeon-auth"
|
||||
- "traefik.http.middlewares.cryptgeon-auth.basicauth.users=user:$$2y$$05$$juUw0zgc5ebvJ00MFPVVLujF6P.rcEMbGZ99Jfq6ZWEa1dgetacEq"
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
1. Open http://cryptgeon.localhost
|
||||
2. Log in with `user` and `secret`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[tools]
|
||||
pnpm = "11.5.0"
|
||||
rust = "1.95"
|
||||
watchexec = "latest"
|
||||
# Node loaded below from .nvmrc
|
||||
|
||||
[settings]
|
||||
idiomatic_version_file_enable_tools = ["node"]
|
||||
|
||||
[env]
|
||||
SIZE_LIMIT = "100mb"
|
||||
@@ -1,21 +1,23 @@
|
||||
{
|
||||
"scripts": {
|
||||
"dev:docker": "docker-compose -f docker-compose.dev.yaml up redis",
|
||||
"dev:docker": "docker compose -f docker-compose.dev.yaml up cache",
|
||||
"dev:packages": "pnpm --parallel run dev",
|
||||
"dev": "run-p dev:*",
|
||||
"dev": "pnpm --parallel run /dev/",
|
||||
"docker:up": "docker compose -f docker-compose.dev.yaml up",
|
||||
"docker:build": "docker compose -f docker-compose.dev.yaml build",
|
||||
"test": "playwright test --project chrome firefox safari",
|
||||
"test:local": "playwright test --project chrome",
|
||||
"test:server": "run-s docker:up",
|
||||
"test:prepare": "run-p build docker:build",
|
||||
"test": "playwright test --project=chrome --project=firefox --project=safari",
|
||||
"test:local": "playwright test --project=chrome",
|
||||
"test:server": "docker compose -f docker-compose.dev.yaml up",
|
||||
"test:dl-browsers": "playwright install",
|
||||
"test:prepare": "pnpm run build && pnpm run docker:build",
|
||||
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/node": "^20.11.24",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"shelljs": "^0.8.5"
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^24.13.3"
|
||||
},
|
||||
"packageManager": "pnpm@8.15.4"
|
||||
"packageManager": "pnpm@11.5.0",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
[package]
|
||||
name = "cryptgeon"
|
||||
version = "2.5.0"
|
||||
version = "3.0.0"
|
||||
authors = ["cupcakearmy <hi@nicco.io>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.76"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
[[bin]]
|
||||
name = "cryptgeon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
actix-web = "4"
|
||||
actix-files = "0.6"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
# Core
|
||||
axum = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["full"] }
|
||||
redis = { version = "1", features = ["tls-native-tls"] }
|
||||
|
||||
# Utility
|
||||
serde_json = "1"
|
||||
rmp-serde = "1"
|
||||
lazy_static = "1"
|
||||
ring = "0.16"
|
||||
ring = "0.17"
|
||||
bs62 = "0.1"
|
||||
byte-unit = "4"
|
||||
dotenv = "0.15"
|
||||
mime = "0.3"
|
||||
env_logger = "0.9"
|
||||
log = "0.4"
|
||||
redis = "0.23"
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"private": true,
|
||||
"name": "@cryptgeon/backend",
|
||||
"scripts": {
|
||||
"dev": "cargo watch -x 'run --bin cryptgeon'",
|
||||
"dev": "watchexec -r -e rs cargo run",
|
||||
"build": "cargo build --release",
|
||||
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:1234 cargo run",
|
||||
"test:server": "SIZE_LIMIT=10MiB LISTEN_ADDR=0.0.0.0:3000 cargo run",
|
||||
"test:prepare": "cargo build"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
use actix_web::web;
|
||||
|
||||
use crate::health;
|
||||
use crate::note;
|
||||
use crate::status;
|
||||
|
||||
pub fn init(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/api")
|
||||
.service(note::init())
|
||||
.service(status::init())
|
||||
.service(health::init()),
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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)?)
|
||||
}
|
||||
@@ -34,6 +34,26 @@ lazy_static! {
|
||||
.unwrap_or("32".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref CACHE_PREFIX: String = std::env::var("CACHE_PREFIX")
|
||||
.unwrap_or("".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref ALLOW_FILES: bool = std::env::var("ALLOW_FILES")
|
||||
.unwrap_or("true".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref IMPRINT_URL: String = std::env::var("IMPRINT_URL")
|
||||
.unwrap_or("".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
|
||||
.unwrap_or("".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref EXTRA_SIZE_LIMIT: usize = std::env::var("EXTRA_SIZE_LIMIT")
|
||||
.unwrap_or("512".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// THEME
|
||||
@@ -54,4 +74,12 @@ lazy_static! {
|
||||
.unwrap_or("".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref THEME_NEW_NOTE_NOTICE: bool = std::env::var("THEME_NEW_NOTE_NOTICE")
|
||||
.unwrap_or("true".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
pub static ref THEME_HOME_LINK: bool = std::env::var("THEME_HOME_LINK")
|
||||
.unwrap_or("true".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
mod routes;
|
||||
use crate::store;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
pub use routes::*;
|
||||
pub async fn report_health() -> (StatusCode,) {
|
||||
if store::can_reach_cache() {
|
||||
return (StatusCode::OK,);
|
||||
} else {
|
||||
return (StatusCode::SERVICE_UNAVAILABLE,);
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
use actix_web::{get, web, HttpResponse, Responder, Scope};
|
||||
|
||||
use crate::store;
|
||||
|
||||
#[get("/")]
|
||||
async fn get_live() -> impl Responder {
|
||||
if store::can_reach_redis() {
|
||||
return HttpResponse::Ok();
|
||||
} else {
|
||||
return HttpResponse::ServiceUnavailable();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init() -> Scope {
|
||||
web::scope("/live").service(get_live)
|
||||
}
|
||||
@@ -1,44 +1,69 @@
|
||||
use actix_web::{
|
||||
middleware::{self, Logger},
|
||||
web::{self},
|
||||
App, HttpServer,
|
||||
use axum::{
|
||||
Router, ServiceExt,
|
||||
extract::{DefaultBodyLimit, Request},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use dotenv::dotenv;
|
||||
use log::error;
|
||||
use tower::Layer;
|
||||
use tower_http::{
|
||||
compression::CompressionLayer,
|
||||
normalize_path::NormalizePathLayer,
|
||||
services::{ServeDir, ServeFile},
|
||||
};
|
||||
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
mod api;
|
||||
mod client;
|
||||
mod config;
|
||||
mod health;
|
||||
mod note;
|
||||
mod size;
|
||||
mod status;
|
||||
mod store;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
dotenv().ok();
|
||||
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
|
||||
|
||||
if !store::can_reach_redis() {
|
||||
error!("cannot reach redis");
|
||||
panic!("canont reach redis");
|
||||
if !store::can_reach_cache() {
|
||||
println!("cannot reach cache");
|
||||
panic!("cannot reach cache");
|
||||
}
|
||||
|
||||
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;
|
||||
let notes_routes = Router::new()
|
||||
.route("/", post(note::create))
|
||||
.route("/{id}", delete(note::view))
|
||||
.route("/{id}", get(note::preview));
|
||||
let health_routes = Router::new().route("/healthz", get(health::report_health));
|
||||
let status_routes = Router::new().route("/status", get(status::get_status));
|
||||
let v3_routes = Router::new()
|
||||
.nest("/notes", notes_routes)
|
||||
.merge(status_routes);
|
||||
|
||||
let api_routes = Router::new().nest("/v3", v3_routes);
|
||||
|
||||
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
|
||||
let serve_dir =
|
||||
ServeDir::new(config::FRONTEND_PATH.to_string()).not_found_service(ServeFile::new(index));
|
||||
let app = Router::new()
|
||||
.nest("/api", api_routes)
|
||||
.merge(health_routes)
|
||||
.fallback_service(serve_dir)
|
||||
.layer(DefaultBodyLimit::max(*config::LIMIT))
|
||||
.layer(
|
||||
CompressionLayer::new()
|
||||
.br(true)
|
||||
.deflate(true)
|
||||
.gzip(true)
|
||||
.zstd(true),
|
||||
);
|
||||
|
||||
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(config::LISTEN_ADDR.to_string())
|
||||
.await
|
||||
.unwrap();
|
||||
println!("listening on {}", listener.local_addr().unwrap());
|
||||
axum::serve(listener, ServiceExt::<Request>::into_make_service(app))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -5,22 +5,35 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::config;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Note {
|
||||
pub meta: String,
|
||||
pub contents: String,
|
||||
pub struct NoteMeta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub views: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expiration: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub extra: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NoteInfo {
|
||||
pub meta: String,
|
||||
pub struct CreateRequest {
|
||||
pub meta: NoteMeta,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct NotePublic {
|
||||
pub meta: String,
|
||||
pub contents: String,
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateResponse {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MetaResponse {
|
||||
pub meta: NoteMeta,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct NoteResponse {
|
||||
pub meta: NoteMeta,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn generate_id() -> String {
|
||||
@@ -32,5 +45,5 @@ pub fn generate_id() -> String {
|
||||
let _ = sr.fill(&mut id);
|
||||
result.push_str(&bs62::encode_data(&id));
|
||||
}
|
||||
return result;
|
||||
result
|
||||
}
|
||||
@@ -1,143 +1,144 @@
|
||||
use actix_web::{delete, get, post, web, HttpResponse, Responder, Scope};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
body::Bytes,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use crate::config;
|
||||
use crate::note::{generate_id, Note, NoteInfo, NotePublic};
|
||||
use crate::note::{CreateRequest, generate_id};
|
||||
use crate::store;
|
||||
use crate::config;
|
||||
|
||||
pub fn now() -> u32 {
|
||||
use super::{CreateResponse, MetaResponse, NoteResponse, NoteMeta};
|
||||
|
||||
pub fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as u32
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct NotePath {
|
||||
#[derive(Deserialize)]
|
||||
pub struct NoteParams {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[get("/{id}")]
|
||||
async fn one(path: web::Path<NotePath>) -> impl Responder {
|
||||
let p = path.into_inner();
|
||||
let note = store::get(&p.id);
|
||||
pub async fn create(body: Bytes) -> Response {
|
||||
let req: CreateRequest = match rmp_serde::from_slice(&body) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid msgpack").into_response(),
|
||||
};
|
||||
|
||||
match note {
|
||||
Ok(Some(n)) => HttpResponse::Ok().json(NoteInfo { meta: n.meta }),
|
||||
Ok(None) => HttpResponse::NotFound().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
|
||||
}
|
||||
if req.meta.views.is_none() && req.meta.expiration.is_none() {
|
||||
return (StatusCode::BAD_REQUEST, "At least views or expiration must be set").into_response();
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CreateResponse {
|
||||
id: String,
|
||||
if req.meta.extra.len() > *config::EXTRA_SIZE_LIMIT {
|
||||
return (StatusCode::BAD_REQUEST, "Extra data too large").into_response();
|
||||
}
|
||||
|
||||
#[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;
|
||||
}
|
||||
let mut meta = req.meta;
|
||||
|
||||
if !*config::ALLOW_ADVANCED {
|
||||
n.views = Some(1);
|
||||
n.expiration = None;
|
||||
meta.views = Some(1);
|
||||
meta.expiration = None;
|
||||
}
|
||||
match n.views {
|
||||
|
||||
match meta.views {
|
||||
Some(v) => {
|
||||
if v > *config::MAX_VIEWS || v < 1 {
|
||||
return bad_req;
|
||||
return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
|
||||
}
|
||||
n.expiration = None; // views overrides expiration
|
||||
}
|
||||
_ => {}
|
||||
None => {}
|
||||
}
|
||||
match n.expiration {
|
||||
|
||||
let expiration_ts = match meta.expiration {
|
||||
Some(e) => {
|
||||
if e > *config::MAX_EXPIRATION {
|
||||
return bad_req;
|
||||
if e > *config::MAX_EXPIRATION || e < 1 {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
|
||||
}
|
||||
let expiration = now() + (e * 60);
|
||||
n.expiration = Some(expiration);
|
||||
Some(now() + (e as u64 * 60))
|
||||
}
|
||||
_ => {}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let id = generate_id();
|
||||
let views = meta.views.map(|v| v as i64);
|
||||
|
||||
match store::set(&id, &req.data, views, expiration_ts, &meta.extra) {
|
||||
Ok(_) => {
|
||||
let resp = CreateResponse { id };
|
||||
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
||||
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
||||
}
|
||||
match store::set(&id.clone(), &n.clone()) {
|
||||
Ok(_) => return HttpResponse::Ok().json(CreateResponse { id: id }),
|
||||
Err(e) => return HttpResponse::InternalServerError().body(e.to_string()),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
pub async fn preview(Path(NoteParams { id }): Path<NoteParams>) -> Response {
|
||||
match store::get_meta(&id) {
|
||||
Ok(Some((views, expiration, extra))) => {
|
||||
let meta = NoteMeta {
|
||||
views: views.map(|v| v as u32),
|
||||
expiration: expiration.map(|e| e as u32),
|
||||
extra,
|
||||
};
|
||||
let resp = MetaResponse { meta };
|
||||
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
||||
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
||||
}
|
||||
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())
|
||||
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
pub async fn view(Path(NoteParams { id }): Path<NoteParams>) -> Response {
|
||||
let (views, expiration, extra) = match store::get_meta(&id) {
|
||||
Ok(Some(v)) => v,
|
||||
_ => return (StatusCode::NOT_FOUND).into_response(),
|
||||
};
|
||||
|
||||
let has_views = views.is_some();
|
||||
|
||||
if has_views {
|
||||
let remaining = match store::decrement_views(&id) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return (StatusCode::NOT_FOUND).into_response(),
|
||||
};
|
||||
|
||||
let data = match store::get_data(&id) {
|
||||
Ok(Some(d)) => d,
|
||||
_ => return (StatusCode::NOT_FOUND).into_response(),
|
||||
};
|
||||
|
||||
if remaining <= 0 {
|
||||
let _ = store::del(&id);
|
||||
}
|
||||
|
||||
let meta = NoteMeta {
|
||||
views: Some(if remaining > 0 { remaining as u32 } else { 0 }),
|
||||
expiration: expiration.map(|e| e as u32),
|
||||
extra,
|
||||
};
|
||||
let resp = NoteResponse { meta, data };
|
||||
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
||||
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
||||
} else {
|
||||
match store::set(&id, &changed.clone()) {
|
||||
Err(e) => {
|
||||
return HttpResponse::InternalServerError().body(e.to_string())
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let data = match store::get_data(&id) {
|
||||
Ok(Some(d)) => d,
|
||||
_ => return (StatusCode::NOT_FOUND).into_response(),
|
||||
};
|
||||
|
||||
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())
|
||||
let meta = NoteMeta {
|
||||
views: None,
|
||||
expiration: expiration.map(|e| e as u32),
|
||||
extra,
|
||||
};
|
||||
let resp = NoteResponse { meta, data };
|
||||
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
|
||||
(StatusCode::OK, Bytes::from(bytes)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,5 +1,45 @@
|
||||
mod model;
|
||||
mod routes;
|
||||
use crate::config;
|
||||
use axum::{http::StatusCode, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
pub use model::*;
|
||||
pub use routes::*;
|
||||
#[derive(Serialize)]
|
||||
pub struct Status {
|
||||
// General
|
||||
pub version: String,
|
||||
// Config
|
||||
pub max_size: u32,
|
||||
pub max_views: u32,
|
||||
pub max_expiration: u32,
|
||||
pub allow_advanced: bool,
|
||||
pub allow_files: bool,
|
||||
pub imprint_url: String,
|
||||
pub imprint_html: String,
|
||||
// Theme
|
||||
pub theme_image: String,
|
||||
pub theme_text: String,
|
||||
pub theme_page_title: String,
|
||||
pub theme_favicon: String,
|
||||
pub theme_new_note_notice: bool,
|
||||
pub theme_home_link: bool,
|
||||
}
|
||||
|
||||
pub async fn get_status() -> (StatusCode, Json<Status>) {
|
||||
let status = 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,
|
||||
allow_files: *config::ALLOW_FILES,
|
||||
imprint_url: config::IMPRINT_URL.to_string(),
|
||||
imprint_html: config::IMPRINT_HTML.to_string(),
|
||||
theme_new_note_notice: *config::THEME_NEW_NOTE_NOTICE,
|
||||
theme_home_link: *config::THEME_HOME_LINK,
|
||||
theme_image: config::THEME_IMAGE.to_string(),
|
||||
theme_text: config::THEME_TEXT.to_string(),
|
||||
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
|
||||
theme_favicon: config::THEME_FAVICON.to_string(),
|
||||
};
|
||||
|
||||
(StatusCode::OK, Json(status))
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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,
|
||||
pub theme_page_title: String,
|
||||
pub theme_favicon: String,
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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(),
|
||||
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
|
||||
theme_favicon: config::THEME_FAVICON.to_string()
|
||||
});
|
||||
}
|
||||
|
||||
pub fn init() -> Scope {
|
||||
web::scope("/status").service(get_status)
|
||||
}
|
||||
@@ -1,63 +1,83 @@
|
||||
use redis;
|
||||
use redis::Commands;
|
||||
|
||||
use crate::note::now;
|
||||
use crate::note::Note;
|
||||
use crate::config;
|
||||
|
||||
lazy_static! {
|
||||
static ref REDIS_CLIENT: String = std::env::var("REDIS")
|
||||
static ref CACHE_URL: String = std::env::var("CACHE")
|
||||
.unwrap_or("redis://127.0.0.1/".to_string())
|
||||
.parse()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn get_connection() -> Result<redis::Connection, &'static str> {
|
||||
fn prefixed(id: &str) -> String {
|
||||
format!("{}{}", config::CACHE_PREFIX.as_str(), id)
|
||||
}
|
||||
|
||||
fn conn() -> 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")
|
||||
redis::Client::open(CACHE_URL.to_string()).map_err(|_| "Unable to connect to cache")?;
|
||||
client.get_connection().map_err(|_| "Unable to connect to cache")
|
||||
}
|
||||
|
||||
pub fn can_reach_redis() -> bool {
|
||||
let conn = get_connection();
|
||||
return match conn {
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
};
|
||||
pub fn can_reach_cache() -> bool {
|
||||
conn().is_ok()
|
||||
}
|
||||
|
||||
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
|
||||
let serialized = serde_json::to_string(¬e.clone()).unwrap();
|
||||
let mut conn = get_connection()?;
|
||||
pub fn set(id: &str, data: &[u8], views: Option<i64>, expiration: Option<u64>, extra: &[u8]) -> Result<(), &'static str> {
|
||||
let key = prefixed(id);
|
||||
let mut c = conn()?;
|
||||
|
||||
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")?
|
||||
c.hset::<_, _, _, ()>(&key, "data", data).map_err(|_| "Unable to set note")?;
|
||||
c.hset::<_, _, _, ()>(&key, "extra", extra).map_err(|_| "Unable to set note")?;
|
||||
|
||||
if let Some(v) = views {
|
||||
c.hset::<_, _, _, ()>(&key, "views", v).map_err(|_| "Unable to set note")?;
|
||||
}
|
||||
None => {}
|
||||
};
|
||||
if let Some(e) = expiration {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
let ttl = e.saturating_sub(now);
|
||||
c.expire::<_, ()>(&key, ttl as i64).map_err(|_| "Unable to set expiration")?;
|
||||
}
|
||||
|
||||
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 get_meta(id: &str) -> Result<Option<(Option<i64>, Option<u64>, Vec<u8>)>, &'static str> {
|
||||
let key = prefixed(id);
|
||||
let mut c = conn()?;
|
||||
|
||||
let exists: bool = c.exists::<_, bool>(&key).map_err(|_| "Cache error")?;
|
||||
if !exists {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
pub fn del(id: &String) -> Result<(), &'static str> {
|
||||
let mut conn = get_connection()?;
|
||||
conn.del(id).map_err(|_| "Unable to delete note in redis")?;
|
||||
let views: Option<i64> = c.hget::<_, _, Option<i64>>(&key, "views").map_err(|_| "Cache error")?;
|
||||
let expiration: Option<u64> = c.hget::<_, _, Option<u64>>(&key, "expiration").map_err(|_| "Cache error")?;
|
||||
let extra: Vec<u8> = c.hget::<_, _, Vec<u8>>(&key, "extra").unwrap_or_default();
|
||||
|
||||
Ok(Some((views, expiration, extra)))
|
||||
}
|
||||
|
||||
pub fn get_data(id: &str) -> Result<Option<Vec<u8>>, &'static str> {
|
||||
let key = prefixed(id);
|
||||
let mut c = conn()?;
|
||||
let data: Option<Vec<u8>> = c.hget::<_, _, Option<Vec<u8>>>(&key, "data").map_err(|_| "Cache error")?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub fn decrement_views(id: &str) -> Result<i64, &'static str> {
|
||||
let key = prefixed(id);
|
||||
let mut c = conn()?;
|
||||
let result: i64 = c.hincr::<_, _, _, i64>(&key, "views", -1).map_err(|_| "Cache error")?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn del(id: &str) -> Result<(), &'static str> {
|
||||
let key = prefixed(id);
|
||||
let mut c = conn()?;
|
||||
c.del::<_, ()>(&key).map_err(|_| "Unable to delete note")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cryptgeon",
|
||||
"version": "2.5.0",
|
||||
"version": "3.0.0",
|
||||
"homepage": "https://github.com/cupcakearmy/cryptgeon",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -9,41 +9,37 @@
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
".": "./dist/index.mjs"
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./dist/index.d.mts",
|
||||
"bin": {
|
||||
"cryptgeon": "./dist/cli.cjs"
|
||||
"cryptgeon": "./dist/cli.mjs"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"bin": "run-s build package",
|
||||
"build": "tsc && ./scripts/build.js",
|
||||
"dev": "./scripts/build.js --watch",
|
||||
"package": "./scripts/package.js",
|
||||
"build": "vp pack",
|
||||
"dev": "vp pack --watch",
|
||||
"prepublishOnly": "run-s build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commander-js/extra-typings": "^12.0.1",
|
||||
"dependencies": {
|
||||
"@cryptgeon/shared": "workspace:*",
|
||||
"@types/inquirer": "^9.0.7",
|
||||
"@types/mime": "^3.0.4",
|
||||
"@types/node": "^20.11.24",
|
||||
"commander": "^12.0.0",
|
||||
"esbuild": "^0.20.1",
|
||||
"inquirer": "^9.2.15",
|
||||
"mime": "^4.0.1",
|
||||
"occulto": "^2.0.3",
|
||||
"pkg": "^5.8.1",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"typescript": "^5.3.3"
|
||||
"@msgpack/msgpack": "^3.1.3",
|
||||
"@commander-js/extra-typings": "^12.1.0",
|
||||
"inquirer": "^9.3.8",
|
||||
"mime": "^4.1.0",
|
||||
"pretty-bytes": "^6.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/strictest": "catalog:",
|
||||
"@types/inquirer": "^9.0.9",
|
||||
"@types/node": "^22.15.3",
|
||||
"commander": "^12.1.0",
|
||||
"typescript": "catalog:",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { build, context } from 'esbuild'
|
||||
import pkg from '../package.json' assert { type: 'json' }
|
||||
|
||||
const options = {
|
||||
entryPoints: ['./src/cli.ts'],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
platform: 'node',
|
||||
outfile: './dist/cli.cjs',
|
||||
define: { VERSION: `"${pkg.version}"` },
|
||||
}
|
||||
|
||||
const watch = process.argv.slice(2)[0] === '--watch'
|
||||
if (watch) {
|
||||
;(await context(options)).watch()
|
||||
} else {
|
||||
await build(options)
|
||||
|
||||
// Also build internals to expose
|
||||
await build({
|
||||
entryPoints: ['./src/index.ts'],
|
||||
bundle: true,
|
||||
minify: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
outfile: './dist/index.js',
|
||||
define: { VERSION: `"${pkg.version}"` },
|
||||
})
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { exec } from 'pkg'
|
||||
|
||||
const targets = [
|
||||
'node18-macos-arm64',
|
||||
'node18-macos-x64',
|
||||
'node18-linux-arm64',
|
||||
'node18-linux-x64',
|
||||
'node18-win-arm64',
|
||||
'node18-win-x64',
|
||||
]
|
||||
|
||||
for (const target of targets) {
|
||||
console.log(`🚀 Building ${target}`)
|
||||
await exec(['./dist/cli.cjs', '--target', target, '--output', `./bin/${target.replace('node18', 'cryptgeon')}`])
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import inquirer from 'inquirer'
|
||||
import { access, constants, writeFile } from 'node:fs/promises'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { decode } from '@msgpack/msgpack'
|
||||
import pretty from 'pretty-bytes'
|
||||
import { decrypt, deriveKey, setServer, info, get, decompress } from '@cryptgeon/shared'
|
||||
|
||||
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
|
||||
setServer(url.origin)
|
||||
const id = url.pathname.split('/')[2]
|
||||
if (!id) throw new Error('Invalid URL')
|
||||
const meta = await info(id)
|
||||
if (!meta) throw new Error('Note does not exist or is expired')
|
||||
|
||||
let key: Uint8Array
|
||||
if (meta.extra && meta.extra.length > 0) {
|
||||
if (suggestedPassword) {
|
||||
const derivation = decode(meta.extra) as any
|
||||
key = deriveKey(suggestedPassword, new Uint8Array(derivation.salt))
|
||||
} else {
|
||||
const response = await inquirer.prompt([
|
||||
{ type: 'password', message: 'Note password', name: 'password' },
|
||||
])
|
||||
const derivation = decode(meta.extra) as any
|
||||
key = deriveKey(response.password, new Uint8Array(derivation.salt))
|
||||
}
|
||||
} else {
|
||||
const hex = url.hash.slice(1)
|
||||
key = new Uint8Array(Buffer.from(hex, 'hex'))
|
||||
}
|
||||
|
||||
const note = await get(id)
|
||||
if (!note) throw new Error('Could not load note')
|
||||
|
||||
const decrypted = decrypt(note.data, key)
|
||||
const content = decode(decompress(decrypted)) as any
|
||||
|
||||
switch (content.type) {
|
||||
case 'files':
|
||||
const files: { name: string; data: Uint8Array }[] = content.data
|
||||
let selected: typeof files
|
||||
if (all) {
|
||||
selected = files
|
||||
} else {
|
||||
const { names } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
message: 'What files should be saved?',
|
||||
name: 'names',
|
||||
choices: files.map((f) => ({
|
||||
value: f.name,
|
||||
name: `${f.name} - ${pretty(f.data.length, { binary: true })}`,
|
||||
checked: true,
|
||||
})),
|
||||
},
|
||||
])
|
||||
selected = files.filter((f) => names.includes(f.name))
|
||||
}
|
||||
if (!selected.length) throw new Error('No files selected')
|
||||
await Promise.all(
|
||||
selected.map(async (f) => {
|
||||
let filename = resolve(f.name)
|
||||
try {
|
||||
await access(filename, constants.R_OK)
|
||||
filename = resolve(`${Date.now()}-${f.name}`)
|
||||
} catch {}
|
||||
await writeFile(filename, f.data)
|
||||
console.log(`Saved: ${basename(filename)}`)
|
||||
})
|
||||
)
|
||||
break
|
||||
case 'text':
|
||||
console.log(content.data)
|
||||
break
|
||||
default:
|
||||
throw new Error('Unknown content type')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
import { encode } from '@msgpack/msgpack'
|
||||
import mime from 'mime'
|
||||
import { encrypt, generateKey, deriveKey, randomBytes, getServer, create, compress } from '@cryptgeon/shared'
|
||||
|
||||
export type UploadOptions = { views?: number; expiration?: number; password?: string }
|
||||
|
||||
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
|
||||
const { password, ...noteOptions } = options
|
||||
|
||||
let key: Uint8Array
|
||||
let extra = new Uint8Array()
|
||||
if (password) {
|
||||
const salt = randomBytes(16)
|
||||
key = deriveKey(password, salt)
|
||||
extra = encode({ salt, N: 32768, r: 8, p: 1 })
|
||||
} else {
|
||||
key = generateKey()
|
||||
}
|
||||
|
||||
let inner: Uint8Array
|
||||
if (typeof input === 'string') {
|
||||
inner = encode({ type: 'text', data: input })
|
||||
} else {
|
||||
const files = await Promise.all(
|
||||
input.map(async (path) => {
|
||||
const data = new Uint8Array(await readFile(path))
|
||||
const extension = path.substring(path.indexOf('.') + 1)
|
||||
const type = mime.getType(extension) ?? 'application/octet-stream'
|
||||
return { name: basename(path), mime: type, size: data.length, data }
|
||||
})
|
||||
)
|
||||
inner = encode({ type: 'files', data: files })
|
||||
}
|
||||
|
||||
const data = encrypt(compress(inner), key)
|
||||
const result = await create({ meta: { ...noteOptions, extra }, data })
|
||||
let url = `${getServer()}/note/${result.id}`
|
||||
if (!password) url += `#${Buffer.from(key).toString('hex')}`
|
||||
return url
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Argument, Option, program } from '@commander-js/extra-typings'
|
||||
import { setBase, status } from '@cryptgeon/shared'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
|
||||
import { download } from './download.js'
|
||||
import { parseFile, parseNumber } from './parsers.js'
|
||||
import { getStdin } from './stdin.js'
|
||||
import { upload } from './upload.js'
|
||||
import { checkConstrains, exit } from './utils.js'
|
||||
import { download } from './actions/download.js'
|
||||
import { upload } from './actions/upload.js'
|
||||
import { setServer, status } from '@cryptgeon/shared'
|
||||
import { parseFile, parseNumber } from './utils/parsers.js'
|
||||
import { getStdin } from './utils/stdin.js'
|
||||
import { checkConstrains, exit } from './utils/utils.js'
|
||||
|
||||
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
|
||||
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
|
||||
@@ -21,7 +21,8 @@ const views = new Option('-v --views <number>', 'Amount of views before getting
|
||||
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
|
||||
|
||||
// Node 18 guard
|
||||
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required')
|
||||
const major = Number(process.version.slice(1).split('.')[0])
|
||||
if (!Number.isFinite(major) || major < 18) exit('Node 18 or higher is required')
|
||||
|
||||
// @ts-ignore
|
||||
const version: string = VERSION
|
||||
@@ -33,15 +34,12 @@ program
|
||||
.description('show information about the server')
|
||||
.addOption(server)
|
||||
.action(async (options) => {
|
||||
setBase(options.server)
|
||||
setServer(options.server!)
|
||||
const response = await status()
|
||||
const formatted = {
|
||||
...response,
|
||||
max_size: prettyBytes(response.max_size),
|
||||
}
|
||||
for (const key of Object.keys(formatted)) {
|
||||
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
|
||||
}
|
||||
const formatted = Object.fromEntries(
|
||||
Object.entries({ ...response, max_size: prettyBytes(response.max_size) })
|
||||
.filter(([key]) => !key.startsWith('theme_'))
|
||||
)
|
||||
console.table(formatted)
|
||||
})
|
||||
|
||||
@@ -54,11 +52,15 @@ send
|
||||
.addOption(minutes)
|
||||
.addOption(password)
|
||||
.action(async (files, options) => {
|
||||
setBase(options.server!)
|
||||
setServer(options.server!)
|
||||
await checkConstrains(options)
|
||||
options.password ||= await getStdin()
|
||||
try {
|
||||
const url = await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
|
||||
const url = await upload(files, {
|
||||
...(options.views !== undefined ? { views: options.views } : {}),
|
||||
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
|
||||
password: options.password,
|
||||
})
|
||||
console.log(`Note created:\n\n${url}`)
|
||||
} catch {
|
||||
exit('Could not create note')
|
||||
@@ -72,11 +74,15 @@ send
|
||||
.addOption(minutes)
|
||||
.addOption(password)
|
||||
.action(async (text, options) => {
|
||||
setBase(options.server!)
|
||||
setServer(options.server!)
|
||||
await checkConstrains(options)
|
||||
options.password ||= await getStdin()
|
||||
try {
|
||||
const url = await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
|
||||
const url = await upload(text, {
|
||||
...(options.views !== undefined ? { views: options.views } : {}),
|
||||
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
|
||||
password: options.password,
|
||||
})
|
||||
console.log(`Note created:\n\n${url}`)
|
||||
} catch {
|
||||
exit('Could not create note')
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Adapters, get, info, setBase } from '@cryptgeon/shared'
|
||||
import inquirer from 'inquirer'
|
||||
import { access, constants, writeFile } from 'node:fs/promises'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { AES, Hex } from 'occulto'
|
||||
import pretty from 'pretty-bytes'
|
||||
|
||||
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
|
||||
setBase(url.origin)
|
||||
const id = url.pathname.split('/')[2]
|
||||
const preview = await info(id).catch(() => {
|
||||
throw new Error('Note does not exist or is expired')
|
||||
})
|
||||
|
||||
// Password
|
||||
let password: string
|
||||
const derivation = preview?.meta.derivation
|
||||
if (derivation) {
|
||||
if (suggestedPassword) {
|
||||
password = suggestedPassword
|
||||
} else {
|
||||
const response = await inquirer.prompt([
|
||||
{
|
||||
type: 'password',
|
||||
message: 'Note password',
|
||||
name: 'password',
|
||||
},
|
||||
])
|
||||
password = response.password
|
||||
}
|
||||
} else {
|
||||
password = url.hash.slice(1)
|
||||
}
|
||||
|
||||
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
|
||||
const note = await get(id)
|
||||
|
||||
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
|
||||
switch (note.meta.type) {
|
||||
case 'file':
|
||||
const files = await Adapters.Files.decrypt(note.contents, key).catch(() => {
|
||||
throw couldNotDecrypt
|
||||
})
|
||||
if (!files) {
|
||||
throw new Error('No files found in note')
|
||||
}
|
||||
|
||||
let selected: typeof files
|
||||
if (all) {
|
||||
selected = files
|
||||
} else {
|
||||
const { names } = await inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
message: 'What files should be saved?',
|
||||
name: 'names',
|
||||
choices: files.map((file) => ({
|
||||
value: file.name,
|
||||
name: `${file.name} - ${file.type} - ${pretty(file.size, { binary: true })}`,
|
||||
checked: true,
|
||||
})),
|
||||
},
|
||||
])
|
||||
selected = files.filter((file) => names.includes(file.name))
|
||||
}
|
||||
|
||||
if (!selected.length) throw new Error('No files selected')
|
||||
await Promise.all(
|
||||
selected.map(async (file) => {
|
||||
let filename = resolve(file.name)
|
||||
try {
|
||||
// If exists -> prepend timestamp to not overwrite the current file
|
||||
await access(filename, constants.R_OK)
|
||||
filename = resolve(`${Date.now()}-${file.name}`)
|
||||
} catch {}
|
||||
await writeFile(filename, file.contents)
|
||||
console.log(`Saved: ${basename(filename)}`)
|
||||
})
|
||||
)
|
||||
|
||||
break
|
||||
case 'text':
|
||||
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(() => {
|
||||
throw couldNotDecrypt
|
||||
})
|
||||
console.log(plaintext)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from '@cryptgeon/shared'
|
||||
export * from './download.js'
|
||||
export * from './upload.js'
|
||||
export * from './utils.js'
|
||||
export * from './actions/download.js'
|
||||
export * from './actions/upload.js'
|
||||
@@ -1,45 +0,0 @@
|
||||
import { readFile, stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
import { Adapters, BASE, create, FileDTO, Note, NoteMeta } from '@cryptgeon/shared'
|
||||
import mime from 'mime'
|
||||
import { AES, Hex } from 'occulto'
|
||||
|
||||
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
|
||||
|
||||
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
|
||||
const { password, ...noteOptions } = options
|
||||
const derived = options.password ? await AES.derive(options.password) : undefined
|
||||
const key = derived ? derived[0] : await AES.generateKey()
|
||||
|
||||
let contents: string
|
||||
let type: NoteMeta['type']
|
||||
if (typeof input === 'string') {
|
||||
contents = await Adapters.Text.encrypt(input, key)
|
||||
type = 'text'
|
||||
} else {
|
||||
const files: FileDTO[] = await Promise.all(
|
||||
input.map(async (path) => {
|
||||
const data = new Uint8Array(await readFile(path))
|
||||
const stats = await stat(path)
|
||||
const extension = path.substring(path.indexOf('.') + 1)
|
||||
const type = mime.getType(extension) ?? 'application/octet-stream'
|
||||
return {
|
||||
name: basename(path),
|
||||
size: stats.size,
|
||||
contents: data,
|
||||
type,
|
||||
} satisfies FileDTO
|
||||
})
|
||||
)
|
||||
contents = await Adapters.Files.encrypt(files, key)
|
||||
type = 'file'
|
||||
}
|
||||
|
||||
// Create the actual note and upload it.
|
||||
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
|
||||
const result = await create(note)
|
||||
let url = `${BASE}/note/${result.id}`
|
||||
if (!derived) url += `#${Hex.encode(key)}`
|
||||
return url
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function parseURL(value: string, _: URL): URL {
|
||||
}
|
||||
|
||||
export function parseNumber(value: string, _: number): number {
|
||||
const n = parseInt(value, 10)
|
||||
if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
|
||||
return n
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export function getStdin(timeout: number = 10): Promise<string> {
|
||||
resolve('')
|
||||
}, timeout)
|
||||
|
||||
process.stdin.on('error', reject)
|
||||
process.stdin.on('data', dataHandler)
|
||||
process.stdin.on('end', endHandler)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { status } from '@cryptgeon/shared'
|
||||
import { exit as exitNode } from 'node:process'
|
||||
import { status } from '@cryptgeon/shared'
|
||||
|
||||
export function exit(message: string) {
|
||||
console.error(message)
|
||||
@@ -7,13 +7,11 @@ export function exit(message: string) {
|
||||
}
|
||||
|
||||
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
|
||||
const { views, minutes } = constrains
|
||||
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
|
||||
if (!views && !minutes) constrains.views = 1
|
||||
if (!constrains.views && !constrains.minutes) constrains.views = 1
|
||||
|
||||
const response = await status()
|
||||
if (views && views > response.max_views)
|
||||
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
|
||||
if (minutes && minutes > response.max_expiration)
|
||||
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
|
||||
if (constrains.views && constrains.views > response.max_views)
|
||||
exit(`Only a maximum of ${response.max_views} views allowed. ${constrains.views} given.`)
|
||||
if (constrains.minutes && constrains.minutes > response.max_expiration)
|
||||
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${constrains.minutes} given.`)
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "node",
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "Bundler",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"strict": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
},
|
||||
"exclude": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite-plus";
|
||||
import pkg from "./package.json" with { type: "json" };
|
||||
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ["src/index.ts", "src/cli.ts"],
|
||||
dts: true,
|
||||
minify: true,
|
||||
format: ["esm"],
|
||||
target: "es2023",
|
||||
deps: { alwaysBundle: ["**"] },
|
||||
define: { VERSION: JSON.stringify(pkg.version) },
|
||||
},
|
||||
});
|
||||
@@ -1,8 +1,17 @@
|
||||
├─ MIT: 13
|
||||
├─ ISC: 2
|
||||
├─ BSD-3-Clause: 1
|
||||
├─ (MPL-2.0 OR Apache-2.0): 1
|
||||
├─ BSD-2-Clause: 1
|
||||
├─ MIT: 324
|
||||
├─ ISC: 84
|
||||
├─ Apache-2.0: 21
|
||||
├─ BlueOak-1.0.0: 14
|
||||
├─ BSD-3-Clause: 6
|
||||
├─ BSD-2-Clause: 4
|
||||
├─ OFL-1.1: 1
|
||||
├─ Python-2.0: 1
|
||||
├─ CC-BY-4.0: 1
|
||||
├─ UNKNOWN: 1
|
||||
├─ MPL-2.0: 1
|
||||
├─ CC-BY-3.0: 1
|
||||
├─ CC0-1.0: 1
|
||||
├─ (MIT AND CC-BY-3.0): 1
|
||||
├─ 0BSD: 1
|
||||
└─ Apache-2.0: 1
|
||||
└─ (MIT OR CC0-1.0): 1
|
||||
|
||||
|
||||
|
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "poznámka",
|
||||
"file": "soubor",
|
||||
"advanced": "pokročilé",
|
||||
"create": "vytvořit",
|
||||
"loading": "načítání",
|
||||
"mode": "režim",
|
||||
"views": "{n, plural, =0 {zobrazení} =1 {1 zobrazení} other {# zobrazení}}",
|
||||
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {# minutách}}",
|
||||
"max": "max",
|
||||
"share_link": "sdílet odkaz",
|
||||
"copy_clipboard": "zkopírovat do schránky",
|
||||
"copied_to_clipboard": "zkopírováno do schránky",
|
||||
"encrypting": "šifrování",
|
||||
"decrypting": "dešifrování",
|
||||
"uploading": "nahrávání",
|
||||
"downloading": "stahování",
|
||||
"qr_code": "QR kód",
|
||||
"password": "heslo"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Jednoduše posílejte <i>plně šifrované</i> a zabezpečené poznámky nebo soubory jediným kliknutím. Stačí vytvořit poznámku a sdílet odkaz.",
|
||||
"explanation": "Poznámka vyprší a bude zničena po {n}.",
|
||||
"new_note": "Nová poznámka",
|
||||
"new_note_notice": "<b>Dostupnost:</b><br />Poznámka není zaručeně uchována, protože je uložena pouze v paměti RAM. Pokud se paměť zaplní, nejstarší poznámky budou automaticky smazány.<br />(Obvykle to nebývá problém, ale je dobré o tom vědět.)",
|
||||
"errors": {
|
||||
"note_too_big": "Poznámku nelze vytvořit. Je příliš velká.",
|
||||
"note_error": "Poznámku nelze vytvořit. Zkuste to prosím znovu.",
|
||||
"max": "Max: {n}",
|
||||
"empty_content": "Poznámka je prázdná."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "Poznámka byla vytvořena."
|
||||
},
|
||||
"advanced": {
|
||||
"explanation": "Ve výchozím nastavení se pro každou poznámku generuje bezpečné heslo. Pokud chcete, můžete si nastavit vlastní heslo, které nebude součástí odkazu.",
|
||||
"custom_password": "Vlastní heslo"
|
||||
},
|
||||
"pasting": "Vkládání...",
|
||||
"pasted_files": "Vložené soubory",
|
||||
"remove": "Odstranit"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "Poznámka nebyla nalezena nebo již byla smazána.",
|
||||
"decryption_failed": "Špatné heslo. Nelze dešifrovat. Odkaz je pravděpodobně poškozen. Poznámka byla zničena.",
|
||||
"unsupported_type": "Nepodporovaný typ poznámky."
|
||||
},
|
||||
"explanation": "Klikněte níže pro zobrazení a následné smazání poznámky, pokud bylo dosaženo limitu zobrazení.",
|
||||
"show_note": "Zobrazit poznámku",
|
||||
"warning_will_not_see_again": "Tuto poznámku již <b>nebudete</b> moci znovu zobrazit.",
|
||||
"download_all": "Stáhnout vše",
|
||||
"links_found": "Odkazy nalezené v poznámce:"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Vybrané soubory",
|
||||
"no_files_selected": "Žádné soubory nevybrány",
|
||||
"clear": "Vymazat"
|
||||
}
|
||||
}
|
||||
@@ -3,29 +3,29 @@
|
||||
"note": "Notiz",
|
||||
"file": "Datei",
|
||||
"advanced": "Erweiterte Optionen",
|
||||
"create": "Notiz erstellen",
|
||||
"create": "Erstellen",
|
||||
"loading": "Lädt...",
|
||||
"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": "Link in die Zwischenablage kopieren",
|
||||
"copied_to_clipboard": "Link in die Zwischenablage kopiert.",
|
||||
"encrypting": "Notiz wird verschlüsselt...",
|
||||
"decrypting": "Notiz wird entschlüsselt...",
|
||||
"copy_clipboard": "In die Zwischenablage kopieren",
|
||||
"copied_to_clipboard": "In die Zwischenablage kopiert.",
|
||||
"encrypting": "Wird verschlüsselt...",
|
||||
"decrypting": "Wird entschlüsselt...",
|
||||
"uploading": "Hochladen",
|
||||
"downloading": "Wird heruntergeladen",
|
||||
"qr_code": "QR-Code",
|
||||
"password": "Passwort"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Senden Sie mit ganz einfach einem Klick <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien. Erstellen Sie einfach eine Notiz und teilen Sie den Link.",
|
||||
"intro": "Erstellen Sie mit einem Klick <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien und teilen Sie diese über einen Link.",
|
||||
"explanation": "Die Notiz verfällt nach {type}.",
|
||||
"new_note": "Neue Notiz",
|
||||
"new_note_notice": "<b>Wichtiger Hinweis zur Verfügbarkeit:</b><br />Es kann nicht garantiert werden, dass diese Notiz gespeichert wird, da diese <b>ausschließlich im Speicher</b> gehalten werden. Ist dieser voll, werden die ältesten Notizen entfernt.<br />(Wahrscheinlich gibt es keine derartigen Probleme, seien Sie nur vorgewarnt).",
|
||||
"errors": {
|
||||
"note_to_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
|
||||
"note_too_big": "Notiz konnte nicht erstellt werden, da sie zu groß ist.",
|
||||
"note_error": "Notiz konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "Notiz ist leer."
|
||||
@@ -34,24 +34,28 @@
|
||||
"note_created": "Notiz wurde erstellt."
|
||||
},
|
||||
"advanced": {
|
||||
"explanation": "Standardmäßig wird für jede Notiz ein sicher generiertes Passwort verwendet. Sie können alternativ ein eigenes Kennwort wählen, das nicht im Link enthalten ist.",
|
||||
"explanation": "Standardmäßig wird für jede Notiz ein generiertes, sicheres Passwort verwendet. Alternativ können Sie ein eigenes Kennwort festlegen, welches nicht im Link enthalten ist.",
|
||||
"custom_password": "Benutzerdefiniertes Passwort"
|
||||
}
|
||||
},
|
||||
"pasting": "Einfügen...",
|
||||
"pasted_files": "Eingefügte Dateien",
|
||||
"remove": "Entfernen"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "Notiz konnte nicht gefunden werden oder wurde bereits gelöscht.",
|
||||
"decryption_failed": "Notiz konnte nicht entschlüsselt werden. Vermutlich ist das Passwort falsch oder der Link defekt. Notiz wurde zerstört.",
|
||||
"decryption_failed": "Notiz konnte nicht entschlüsselt werden. Vermutlich ist das Passwort falsch oder der Link defekt. Die Notiz wurde daher gelöscht.",
|
||||
"unsupported_type": "Nicht unterstützter Notiztyp."
|
||||
},
|
||||
"explanation": "Klicken Sie unten, um die Notiz anzuzeigen und anschließend zu löschen, falls das Limit erreicht ist.",
|
||||
"explanation": "Klicken Sie auf den Button, um die Notiz anzuzeigen und anschließend zu löschen, falls ein festgelegtes Limit erreicht wurde.",
|
||||
"show_note": "Notiz anzeigen",
|
||||
"warning_will_not_see_again": "Achtung! Sie haben anschließend <b>keine</b> Gelegenheit, die Notiz noch einmal anzusehen.",
|
||||
"warning_will_not_see_again": "ACHTUNG! Sie werden anschließend <b>keine</b> Gelegenheit mehr haben, die Notiz erneut anzusehen.",
|
||||
"download_all": "Alle Dateien herunterladen",
|
||||
"links_found": "Links in der Notiz:"
|
||||
"links_found": "Gefundene Links in der Notiz:"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Ausgewählte Dateien",
|
||||
"no_files_selected": "Keine Dateien ausgewählt"
|
||||
"no_files_selected": "Keine Dateien ausgewählt",
|
||||
"clear": "Zurücksetzen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"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_too_big": "could not create note. note is too big",
|
||||
"note_error": "could not create note. please try again.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "note is empty."
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "By default, a securely generated password is used for each note. You can however also choose your own password, which is not included in the link.",
|
||||
"custom_password": "custom password"
|
||||
}
|
||||
},
|
||||
"pasting": "Pasting...",
|
||||
"pasted_files": "Pasted Files",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
@@ -44,7 +47,7 @@
|
||||
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.",
|
||||
"unsupported_type": "unsupported note type."
|
||||
},
|
||||
"explanation": "click below to show and delete the note if the counter has reached it's limit",
|
||||
"explanation": "click below to show and delete the note if the counter has reached its 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",
|
||||
@@ -52,6 +55,7 @@
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Selected Files",
|
||||
"no_files_selected": "No Files Selected"
|
||||
"no_files_selected": "No Files Selected",
|
||||
"clear": "Reset"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"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, solo está advertido.)",
|
||||
"errors": {
|
||||
"note_to_big": "no se pudo crear la nota. la nota es demasiado grande",
|
||||
"note_too_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."
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Por defecto, se utiliza una contraseña generada de forma segura para cada nota. No obstante, también puede elegir su propia contraseña, la cual no se incluye en el enlace.",
|
||||
"custom_password": "contraseña personalizada"
|
||||
}
|
||||
},
|
||||
"pasting": "Pegando...",
|
||||
"pasted_files": "Archivos pegados",
|
||||
"remove": "Eliminar"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
@@ -52,6 +55,7 @@
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Archivos seleccionados",
|
||||
"no_files_selected": "No hay archivos seleccionados"
|
||||
"no_files_selected": "No hay archivos seleccionados",
|
||||
"clear": "Restablecer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,20 @@
|
||||
"share_link": "partager le lien",
|
||||
"copy_clipboard": "copier dans le presse-papiers",
|
||||
"copied_to_clipboard": "copié dans le presse-papiers",
|
||||
"encrypting": "cryptage",
|
||||
"encrypting": "chiffrer",
|
||||
"decrypting": "déchiffrer",
|
||||
"uploading": "téléchargement",
|
||||
"uploading": "téléversement",
|
||||
"downloading": "téléchargement",
|
||||
"qr_code": "code qr",
|
||||
"password": "mot de passe"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement cryptés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.",
|
||||
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement chiffré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.)",
|
||||
"new_note_notice": "<b>disponibilité :</b><br />il n'est pas garanti que la note reste stockée car tout est conservé dans la mémoire vive; si elle se remplit, les notes les plus anciennes seront supprimées.<br />(tout ira probablement bien, soyez juste averti.)",
|
||||
"errors": {
|
||||
"note_to_big": "Impossible de créer une note. La note est trop grande",
|
||||
"note_too_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."
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Par défaut, un mot de passe généré de manière sécurisée est utilisé pour chaque note. Vous pouvez toutefois choisir votre propre mot de passe, qui n'est pas inclus dans le lien.",
|
||||
"custom_password": "mot de passe personnalisé"
|
||||
}
|
||||
},
|
||||
"pasting": "Collage...",
|
||||
"pasted_files": "Fichiers collés",
|
||||
"remove": "Supprimer"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
@@ -45,13 +48,14 @@
|
||||
"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",
|
||||
"show_note": "afficher la note",
|
||||
"warning_will_not_see_again": "vous <b>n'aurez pas</b> la chance de revoir la note.",
|
||||
"download_all": "télécharger tout",
|
||||
"download_all": "tout télécharger",
|
||||
"links_found": "liens trouvés à l’intérieur de la note :"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Fichiers sélectionnés",
|
||||
"no_files_selected": "Aucun fichier sélectionné"
|
||||
"no_files_selected": "Aucun fichier sélectionné",
|
||||
"clear": "Réinitialiser"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"advanced": "avanzato",
|
||||
"create": "crea",
|
||||
"loading": "carica",
|
||||
"mode": "modalita",
|
||||
"mode": "modalità",
|
||||
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
|
||||
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
|
||||
"max": "max",
|
||||
@@ -25,7 +25,7 @@
|
||||
"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_too_big": "impossibile creare una nota. la nota è troppo grande",
|
||||
"note_error": "Impossibile creare la nota. Riprova.",
|
||||
"max": "max: {n}",
|
||||
"empty_content": "la nota è vuota."
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "Per impostazione predefinita, per ogni nota viene utilizzata una password generata in modo sicuro. È tuttavia possibile scegliere la propria password, che non è inclusa nel link.",
|
||||
"custom_password": "password personalizzata"
|
||||
}
|
||||
},
|
||||
"pasting": "Incollando...",
|
||||
"pasted_files": "File incollati",
|
||||
"remove": "Rimuovi"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
@@ -52,6 +55,7 @@
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "File selezionati",
|
||||
"no_files_selected": "Nessun file selezionato"
|
||||
"no_files_selected": "Nessun file selezionato",
|
||||
"clear": "Reset"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"uploading": "アップロード中",
|
||||
"downloading": "ダウンロード中",
|
||||
"qr_code": "QRコード",
|
||||
"password": "暗号"
|
||||
"password": "パスワード"
|
||||
},
|
||||
"home": {
|
||||
"intro": "<i>完全に暗号化された</i> 、安全なメモやファイルをワンクリックで簡単に送信できます。メモを作成してリンクを共有するだけです。",
|
||||
@@ -25,7 +25,7 @@
|
||||
"new_note": "新しいメモ",
|
||||
"new_note_notice": "<b>可用性: </b> <br />すべてが RAM に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
|
||||
"errors": {
|
||||
"note_to_big": "メモを作成できませんでした。メモが大きすぎる",
|
||||
"note_too_big": "メモを作成できませんでした。メモが大きすぎる",
|
||||
"note_error": "メモを作成できませんでした。もう一度お試しください。",
|
||||
"max": "最大ファイルサイズ: {n}",
|
||||
"empty_content": "メモは空です。"
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
|
||||
"custom_password": "カスタムパスワード"
|
||||
}
|
||||
},
|
||||
"pasting": "貼り付け中...",
|
||||
"pasted_files": "貼り付けたファイル",
|
||||
"remove": "削除"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
@@ -46,12 +49,13 @@
|
||||
},
|
||||
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
|
||||
"show_note": "メモを表示",
|
||||
"warning_will_not_see_again": "あなた <b>できません</b> このノートをもう一度見る",
|
||||
"warning_will_not_see_again": "このノートを再度表示することは<b>できません</b>",
|
||||
"download_all": "すべてダウンロード",
|
||||
"links_found": "メモ内にあるリンク:"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "選択したファイル",
|
||||
"no_files_selected": "ファイルが選択されていません"
|
||||
"no_files_selected": "ファイルが選択されていません",
|
||||
"clear": "リセット"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "notatka",
|
||||
"file": "plik",
|
||||
"advanced": "zaawansowane",
|
||||
"create": "utwórz",
|
||||
"loading": "ładowanie",
|
||||
"mode": "tryb",
|
||||
"views": "{n, plural, =0 {wyświetleń} =1 {1 wyświetlenie} other {{n} wyświetleń}}",
|
||||
"minutes": "{n, plural, =0 {minut} =1 {1 minuta} other {{n} minuty}}",
|
||||
"max": "maks.",
|
||||
"share_link": "link udostępniania",
|
||||
"copy_clipboard": "kopiuj do schowka",
|
||||
"copied_to_clipboard": "skopiowano do schowka",
|
||||
"encrypting": "szyfrowanie",
|
||||
"decrypting": "odszyfrowywanie",
|
||||
"uploading": "wysyłanie",
|
||||
"downloading": "pobieranie",
|
||||
"qr_code": "kod QR",
|
||||
"password": "hasło"
|
||||
},
|
||||
"home": {
|
||||
"intro": "Łatwo wysyłaj <i>w pełni zaszyfrowane</i>, bezpieczne notatki lub pliki jednym kliknięciem. Po prostu utwórz notatkę i udostępnij link.",
|
||||
"explanation": "notatka wygaśnie i zostanie zniszczona po {type}.",
|
||||
"new_note": "nowa notatka",
|
||||
"new_note_notice": "<b>dostępność:</b><br />nie ma gwarancji, że notatka będzie przechowywana, ponieważ wszystko jest przechowywane w pamięci RAM, jeśli się zapełni, najstarsze notatki zostaną usunięte.<br />(prawdopodobnie nic się nie stanie, ale warto ostrzec.)",
|
||||
"errors": {
|
||||
"note_too_big": "nie można utworzyć notatki. notatka jest za duża",
|
||||
"note_error": "nie można utworzyć notatki. spróbuj ponownie.",
|
||||
"max": "maks.: {n}",
|
||||
"empty_content": "notatka jest pusta."
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "notatka utworzona."
|
||||
},
|
||||
"advanced": {
|
||||
"explanation": "Domyślnie dla każdej notatki używane jest bezpiecznie wygenerowane hasło. Możesz jednak wybrać własne hasło, które nie jest uwzględnione w linku.",
|
||||
"custom_password": "własne hasło"
|
||||
},
|
||||
"pasting": "Wklejanie...",
|
||||
"pasted_files": "Wklejone pliki",
|
||||
"remove": "Usuń"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "notatka nie została znaleziona lub została już usunięta.",
|
||||
"decryption_failed": "błędne hasło. nie można odszyfrować. prawdopodobnie uszkodzony link. notatka została zniszczona.",
|
||||
"unsupported_type": "nieobsługiwany typ notatki."
|
||||
},
|
||||
"explanation": "kliknij poniżej, aby wyświetlić i usunąć notatkę, jeśli licznik osiągnie swój limit",
|
||||
"show_note": "pokaż notatkę",
|
||||
"warning_will_not_see_again": "<b>nie będziesz mieć</b> możliwości ponownego zobaczenia notatki.",
|
||||
"download_all": "pobierz wszystko",
|
||||
"links_found": "linki znalezione w notatce:"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Wybrane pliki",
|
||||
"no_files_selected": "Nie wybrano plików",
|
||||
"clear": "Wyczyść"
|
||||
}
|
||||
}
|
||||
@@ -23,10 +23,10 @@
|
||||
"intro": "Легко отправляйте <i>полностью зашифрованные</i> защищенные заметки или файлы одним щелчком мыши. Просто создайте заметку и поделитесь ссылкой.",
|
||||
"explanation": "заметка истечет и будет уничтожена после {type}.",
|
||||
"new_note": "новая заметка",
|
||||
"new_note_notice": "<b>availability:</b><br />the note is not guaranteed to be stored as everything is kept in ram, if it fills up the oldest notes will be removed.<br />(you probably will be fine, just be warned.)",
|
||||
"new_note_notice": "<b>доступность:</b><br />сохранение заметки не гарантируется, поскольку все хранится в оперативной памяти; если она заполнится, самые старые заметки будут удалены.<br />( вероятно, все будет в порядке, просто будьте осторожны.)",
|
||||
"errors": {
|
||||
"note_to_big": "нельзя создать новую заметку. заметка слишком большая",
|
||||
"note_error": "нельзя создать новую заметку. пожалйста попробуйте позднее.",
|
||||
"note_too_big": "нельзя создать новую заметку. заметка слишком большая",
|
||||
"note_error": "нельзя создать новую заметку. пожалуйста попробуйте позже.",
|
||||
"max": "макс: {n}",
|
||||
"empty_content": "пустая заметка."
|
||||
},
|
||||
@@ -36,15 +36,18 @@
|
||||
"advanced": {
|
||||
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
|
||||
"custom_password": "пользовательский пароль"
|
||||
}
|
||||
},
|
||||
"pasting": "Вставка...",
|
||||
"pasted_files": "Вставленные файлы",
|
||||
"remove": "Удалить"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "заметка не найдена или была удалена.",
|
||||
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.",
|
||||
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. заметка уничтожена.",
|
||||
"unsupported_type": "неподдерживаемый тип заметки."
|
||||
},
|
||||
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела",
|
||||
"explanation": "щелкните ниже, чтобы показать и удалить заметку, если счетчик достиг предела",
|
||||
"show_note": "показать заметку",
|
||||
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
|
||||
"download_all": "скачать всё",
|
||||
@@ -52,6 +55,7 @@
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "Выбранные файлы",
|
||||
"no_files_selected": "Файлы не выбраны"
|
||||
"no_files_selected": "Файлы не выбраны",
|
||||
"clear": "Сброс"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"common": {
|
||||
"note": "筆記",
|
||||
"file": "檔案",
|
||||
"advanced": "進階",
|
||||
"create": "創建",
|
||||
"loading": "載入中",
|
||||
"mode": "模式",
|
||||
"views": "{n, plural, =0 {瀏覽次數} =1 {1 次瀏覽} other {# 次瀏覽}}",
|
||||
"minutes": "{n, plural, =0 {分鐘} =1 {1 分鐘} other {# 分鐘}}",
|
||||
"max": "最大",
|
||||
"share_link": "分享連結",
|
||||
"copy_clipboard": "複製到剪貼板",
|
||||
"copied_to_clipboard": "已複製到剪貼板",
|
||||
"encrypting": "加密中",
|
||||
"decrypting": "解密中",
|
||||
"uploading": "上傳中",
|
||||
"downloading": "下載中",
|
||||
"qr_code": "QR 碼",
|
||||
"password": "密碼"
|
||||
},
|
||||
"home": {
|
||||
"intro": "輕鬆地以一鍵傳送<i>完全加密</i>的安全筆記或檔案。只需創建筆記並分享連結。",
|
||||
"explanation": "筆記將在 {type} 後過期並被銷毀。",
|
||||
"new_note": "新筆記",
|
||||
"new_note_notice": "<b>可用性:</b><br />筆記不保證被儲存,因為所有內容都保留在 RAM 中,如果 RAM 填滿,最舊的筆記將被移除。<br />(您可能會沒事,只是提醒一下。)",
|
||||
"errors": {
|
||||
"note_too_big": "無法創建筆記。筆記過大",
|
||||
"note_error": "無法創建筆記。請再試一次。",
|
||||
"max": "最大值:{n}",
|
||||
"empty_content": "筆記內容為空。"
|
||||
},
|
||||
"messages": {
|
||||
"note_created": "筆記已創建。"
|
||||
},
|
||||
"advanced": {
|
||||
"explanation": "預設情況下,每個筆記都會使用安全生成的密碼。您也可以選擇自己的密碼,該密碼不會包含在連結中。",
|
||||
"custom_password": "自定義密碼"
|
||||
},
|
||||
"pasting": "正在粘貼...",
|
||||
"pasted_files": "粘貼的檔案",
|
||||
"remove": "移除"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
"not_found": "筆記未找到或已被刪除。",
|
||||
"decryption_failed": "密碼錯誤。無法解密。可能是連結已損壞。筆記已被銷毀。",
|
||||
"unsupported_type": "不支持的筆記類型。"
|
||||
},
|
||||
"explanation": "如果計數器達到限制,請點擊下方以顯示並刪除筆記",
|
||||
"show_note": "顯示筆記",
|
||||
"warning_will_not_see_again": "您將<b>無法</b>再次查看筆記。",
|
||||
"download_all": "全部下載",
|
||||
"links_found": "在筆記中找到的連結:"
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "已選擇的檔案",
|
||||
"no_files_selected": "未選擇檔案",
|
||||
"clear": "重置"
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
"new_note": "新建密信",
|
||||
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
|
||||
"errors": {
|
||||
"note_to_big": "创建失败,密信过大。",
|
||||
"note_too_big": "创建失败,密信过大。",
|
||||
"note_error": "创建失败,请稍后重试。",
|
||||
"max": "次数上限:{n}",
|
||||
"empty_content": "密信不能为空。"
|
||||
@@ -36,7 +36,10 @@
|
||||
"advanced": {
|
||||
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
|
||||
"custom_password": "自定义密码"
|
||||
}
|
||||
},
|
||||
"pasting": "正在粘贴...",
|
||||
"pasted_files": "粘贴的文件",
|
||||
"remove": "移除"
|
||||
},
|
||||
"show": {
|
||||
"errors": {
|
||||
@@ -52,6 +55,7 @@
|
||||
},
|
||||
"file_upload": {
|
||||
"selected_files": "已选中的文件",
|
||||
"no_files_selected": "没有文件被选中"
|
||||
"no_files_selected": "没有文件被选中",
|
||||
"clear": "重置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,34 +7,27 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig tsconfig.json",
|
||||
"licenses": "license-checker --summary > licenses.csv",
|
||||
"locale:download": "node scripts/locale.js",
|
||||
"licenses": "license-checker-rseidelsohn --summary > licenses.csv",
|
||||
"test:prepare": "pnpm run build"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@lokalise/node-api": "^12.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.1",
|
||||
"@sveltejs/kit": "^2.5.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@zerodevx/svelte-toast": "^0.9.5",
|
||||
"adm-zip": "^0.5.10",
|
||||
"dotenv": "^16.4.5",
|
||||
"svelte": "^4.2.12",
|
||||
"svelte-check": "^3.6.6",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.61.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@zerodevx/svelte-toast": "^0.9.6",
|
||||
"license-checker-rseidelsohn": "^5.0.1",
|
||||
"svelte": "^5.55.9",
|
||||
"svelte-check": "^4.4.8",
|
||||
"svelte-intl-precompile": "^0.12.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4"
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.14"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cryptgeon/shared": "workspace:*",
|
||||
"@fontsource/fira-mono": "^5.0.8",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"file-saver": "^2.0.5",
|
||||
"occulto": "^2.0.3",
|
||||
"pretty-bytes": "^6.1.1",
|
||||
"qrious": "^4.0.2"
|
||||
"@fontsource/fira-mono": "^5.2.7",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"uqr": "^0.1.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { LokaliseApi } from '@lokalise/node-api'
|
||||
import AdmZip from 'adm-zip'
|
||||
import dotenv from 'dotenv'
|
||||
import https from 'https'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
function exit(msg) {
|
||||
console.error(msg)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const apiKey = process.env.LOKALISE_API_KEY
|
||||
const project_id = process.env.LOKALISE_PROJECT
|
||||
if (!apiKey) exit('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
|
||||
if (!project_id) exit('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
|
||||
const client = new LokaliseApi({ apiKey })
|
||||
|
||||
const WGet = (url) =>
|
||||
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)
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<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"
|
||||
|
||||
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 316 B |
@@ -1,3 +1,5 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<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"
|
||||
|
||||
|
Before Width: | Height: | Size: 325 B After Width: | Height: | Size: 354 B |
@@ -1,3 +1,5 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<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"
|
||||
|
||||
|
Before Width: | Height: | Size: 736 B After Width: | Height: | Size: 765 B |
@@ -1,3 +1,5 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<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"
|
||||
|
||||
|
Before Width: | Height: | Size: 483 B After Width: | Height: | Size: 512 B |
@@ -1,3 +1,5 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<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"
|
||||
|
||||
|
Before Width: | Height: | Size: 732 B After Width: | Height: | Size: 761 B |
@@ -1,8 +1,25 @@
|
||||
import { status as getStatus, type Status } from '@cryptgeon/shared'
|
||||
import { status as apiStatus } from '@cryptgeon/shared'
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
export const status = writable<null | Status>(null)
|
||||
export type StatusInfo = {
|
||||
version: string
|
||||
max_size: number
|
||||
max_views: number
|
||||
max_expiration: number
|
||||
allow_advanced: boolean
|
||||
allow_files: boolean
|
||||
imprint_url: string
|
||||
imprint_html: string
|
||||
theme_image: string
|
||||
theme_text: string
|
||||
theme_page_title: string
|
||||
theme_favicon: string
|
||||
theme_new_note_notice: boolean
|
||||
theme_home_link: boolean
|
||||
}
|
||||
|
||||
export const status = writable<null | StatusInfo>(null)
|
||||
|
||||
export async function init() {
|
||||
status.set(await getStatus())
|
||||
status.set((await apiStatus()) as StatusInfo)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { toast, type SvelteToastOptions } from '@zerodevx/svelte-toast'
|
||||
import { toast } from '@zerodevx/svelte-toast'
|
||||
|
||||
export enum NotifyType {
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
|
||||
const themeMapping: Record<NotifyType, Record<string, string>> = {
|
||||
[NotifyType.Success]: {
|
||||
'--toastBackground': 'var(--ui-clr-primary)',
|
||||
'--toastBarBackground': 'var(--ui-clr-primary-alt)',
|
||||
@@ -17,7 +17,7 @@ const themeMapping: Record<NotifyType, SvelteToastOptions['theme']> = {
|
||||
}
|
||||
|
||||
function notifyFN(message: string, type: NotifyType = NotifyType.Success) {
|
||||
const options: SvelteToastOptions = {
|
||||
const options = {
|
||||
duration: 5_000,
|
||||
theme: {
|
||||
...themeMapping[type],
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script lang="ts">
|
||||
export let title: string
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
children?: Snippet
|
||||
}
|
||||
|
||||
let { title, children }: Props = $props()
|
||||
</script>
|
||||
|
||||
<p>
|
||||
<b>▶ {title}</b>
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
import { status } from '$lib/stores/status'
|
||||
import Switch from '$lib/ui/Switch.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
import type { Note } from '@cryptgeon/shared'
|
||||
|
||||
export let note: Note
|
||||
export let timeExpiration = false
|
||||
export let customPassword: string | null = null
|
||||
interface Props {
|
||||
note: { views: number; expiration: number }
|
||||
timeExpiration?: boolean
|
||||
customPassword?: string | null
|
||||
}
|
||||
|
||||
let hasCustomPassword = false
|
||||
let {
|
||||
note = $bindable(),
|
||||
timeExpiration = $bindable(false),
|
||||
customPassword = $bindable(null),
|
||||
}: Props = $props()
|
||||
|
||||
$: if (!hasCustomPassword) customPassword = null
|
||||
let hasCustomPassword = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (!hasCustomPassword) customPassword = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex col">
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
<button {...$$restProps} on:click><slot /></button>
|
||||
<script lang="ts">
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements'
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { children, ...rest }: HTMLButtonAttributes & Props = $props()
|
||||
</script>
|
||||
|
||||
<button {...rest}>{@render children?.()}</button>
|
||||
|
||||
<style>
|
||||
button {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
// @ts-ignore
|
||||
import QR from 'qrious'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import { getCSSVariable } from '$lib/utils'
|
||||
|
||||
export let value: string
|
||||
|
||||
let canvas: HTMLCanvasElement
|
||||
|
||||
$: {
|
||||
new QR({
|
||||
value,
|
||||
level: 'Q',
|
||||
size: 800,
|
||||
background: getCSSVariable('--ui-bg-0'),
|
||||
foreground: getCSSVariable('--ui-text-0'),
|
||||
element: canvas,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<small>{$t('common.qr_code')}</small>
|
||||
<div>
|
||||
<canvas bind:this={canvas} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
padding: 0.5rem;
|
||||
width: fit-content;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
background-color: var(--ui-bg-0);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -5,15 +5,20 @@
|
||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||
import type { FileDTO } from '@cryptgeon/shared'
|
||||
|
||||
export let label: string = ''
|
||||
export let files: FileDTO[] = []
|
||||
interface Props {
|
||||
label?: string
|
||||
files?: FileDTO[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
let { label = '', files = $bindable([]), ...rest }: Props = $props()
|
||||
|
||||
async function fileToDTO(file: File): Promise<FileDTO> {
|
||||
return {
|
||||
name: file.name,
|
||||
mime: file.type,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
contents: new Uint8Array(await file.arrayBuffer()),
|
||||
data: new Uint8Array(await file.arrayBuffer()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +40,7 @@
|
||||
<small>
|
||||
{label}
|
||||
</small>
|
||||
<input {...$$restProps} type="file" on:change={onInput} multiple />
|
||||
<input {...rest} type="file" onchange={onInput} multiple />
|
||||
<div class="box">
|
||||
{#if files.length}
|
||||
<div>
|
||||
@@ -45,8 +50,8 @@
|
||||
{file.name}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="spacer" />
|
||||
<Button on:click={clear}>Clear</Button>
|
||||
<div class="spacer"></div>
|
||||
<Button onclick={clear}>{$t('file_upload.clear')}</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts" context="module">
|
||||
<script lang="ts" 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'
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements'
|
||||
|
||||
const map = {
|
||||
contrast: IconContrast,
|
||||
@@ -15,22 +16,27 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export let icon: keyof typeof map
|
||||
interface Props {
|
||||
icon: keyof typeof map
|
||||
}
|
||||
|
||||
let { icon, ...rest }: HTMLButtonAttributes & Props = $props()
|
||||
</script>
|
||||
|
||||
<div on:click {...$$restProps}>
|
||||
<button type="button" {...rest}>
|
||||
{#if map[icon]}
|
||||
<svelte:component this={map[icon]} />
|
||||
{@const SvelteComponent = map[icon]}
|
||||
<SvelteComponent />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
div {
|
||||
button {
|
||||
display: inline-block;
|
||||
contain: strict;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
div > :global(svg) {
|
||||
button > :global(svg) {
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
|
Before Width: | Height: | Size: 784 B After Width: | Height: | Size: 813 B |
@@ -1,4 +1,4 @@
|
||||
<script lang="ts" context="module">
|
||||
<script lang="ts" module>
|
||||
export type NoteResult = {
|
||||
id: string
|
||||
password?: string
|
||||
@@ -7,15 +7,22 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import { status } from '$lib/stores/status'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
import Canvas from './Canvas.svelte'
|
||||
import QR from './QR.svelte'
|
||||
|
||||
export let result: NoteResult
|
||||
interface Props {
|
||||
result: NoteResult
|
||||
}
|
||||
|
||||
let { result }: Props = $props()
|
||||
|
||||
let url = $derived.by(() => {
|
||||
let url = `${window.location.origin}/note/${result.id}`
|
||||
if (result.password) url += `#${result.password}`
|
||||
return url
|
||||
})
|
||||
|
||||
function reset() {
|
||||
window.location.reload()
|
||||
@@ -32,14 +39,16 @@
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Canvas value={url} />
|
||||
<QR value={url} />
|
||||
</div>
|
||||
|
||||
{#if $status?.theme_new_note_notice}
|
||||
<p>
|
||||
{@html $t('home.new_note_notice')}
|
||||
</p>
|
||||
{/if}
|
||||
<br />
|
||||
<Button on:click={reset}>{$t('home.new_note')}</Button>
|
||||
<Button onclick={reset}>{$t('home.new_note')}</Button>
|
||||
|
||||
<style>
|
||||
div {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import type { FileDTO } from '@cryptgeon/shared'
|
||||
|
||||
interface Props {
|
||||
files: FileDTO[]
|
||||
}
|
||||
|
||||
let { files = $bindable([]) }: Props = $props()
|
||||
|
||||
let previewUrls: string[] = $state([])
|
||||
|
||||
$effect(() => {
|
||||
const urls = files.map((f) => URL.createObjectURL(new Blob([f.data.slice(0)], { type: f.mime })))
|
||||
previewUrls = urls
|
||||
return () => {
|
||||
for (const url of urls) URL.revokeObjectURL(url)
|
||||
}
|
||||
})
|
||||
|
||||
function remove(index: number) {
|
||||
const removed = files[index]
|
||||
URL.revokeObjectURL(previewUrls[index])
|
||||
files = files.toSpliced(index, 1)
|
||||
}
|
||||
|
||||
function isImage(type: string) {
|
||||
return type.startsWith('image/')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if files.length > 0}
|
||||
<div class="pasted-files-preview">
|
||||
<h4>{$t('home.pasted_files')}</h4>
|
||||
<div class="files-grid">
|
||||
{#each files as entry, index}
|
||||
<div class="file-preview">
|
||||
{#if isImage(entry.mime)}
|
||||
<img src={previewUrls[index]} class="preview-img" alt={entry.name} />
|
||||
{:else}
|
||||
<div class="file-icon">
|
||||
<div class="file-extension">
|
||||
{entry.name.split('.').pop()?.toUpperCase() || entry.mime}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="file-name">{entry.name}</div>
|
||||
<div class="file-actions">
|
||||
<Button onclick={() => remove(index)}>
|
||||
{$t('home.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pasted-files-preview {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 4px;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.pasted-files-preview h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.files-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
max-width: 150px;
|
||||
max-height: 150px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background: #f5f5f5;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.file-extension {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pasted-files-preview {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.preview-img, .file-icon {
|
||||
max-width: 120px;
|
||||
max-height: 120px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.files-grid {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { getCSSVariable } from '$lib/utils'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import { renderSVG } from 'uqr'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
}
|
||||
|
||||
let { value }: Props = $props()
|
||||
|
||||
let qr: string | null = $state(null)
|
||||
|
||||
$effect(() => {
|
||||
qr = renderSVG(value, {
|
||||
ecc: 'Q',
|
||||
blackColor: getCSSVariable('--ui-bg-0'),
|
||||
whiteColor: getCSSVariable('--ui-text-0'),
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<small>{$t('common.qr_code')}</small>
|
||||
<div>
|
||||
{#if qr}
|
||||
{@html qr}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
padding: 0.25rem;
|
||||
width: fit-content;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
background-color: var(--ui-bg-0);
|
||||
margin-top: 0.125rem;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
div :global(svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,39 +1,57 @@
|
||||
<script lang="ts" context="module">
|
||||
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
|
||||
<script lang="ts" module>
|
||||
export type DecryptedNote = {
|
||||
meta: { type: 'text' | 'file' }
|
||||
contents: any
|
||||
}
|
||||
|
||||
function saveAs(file: File) {
|
||||
const url = window.URL.createObjectURL(file)
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = url
|
||||
a.download = file.name
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
a.remove()
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { saveAs } from 'file-saver'
|
||||
import prettyBytes from 'pretty-bytes'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
import Button from '$lib/ui/Button.svelte'
|
||||
import { copy } from '$lib/utils'
|
||||
import type { FileDTO, NotePublic } from '@cryptgeon/shared'
|
||||
import type { FileDTO } from '@cryptgeon/shared'
|
||||
|
||||
export let note: DecryptedNote
|
||||
interface Props {
|
||||
note: DecryptedNote
|
||||
}
|
||||
|
||||
let { note }: Props = $props()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
let files: FileDTO[] = $state([])
|
||||
|
||||
async function downloadFile(file: FileDTO) {
|
||||
const f = new File([file.contents], file.name, {
|
||||
type: file.type,
|
||||
const f = new File([file.data.slice(0)], file.name, {
|
||||
type: file.mime,
|
||||
})
|
||||
saveAs(f)
|
||||
}
|
||||
|
||||
$: links = typeof note.contents === 'string' ? note.contents.match(RE_URL) : []
|
||||
$effect(() => {
|
||||
if (note.meta.type === 'file') {
|
||||
files = note.contents
|
||||
}
|
||||
})
|
||||
let download = $derived(() => {
|
||||
for (const file of files) {
|
||||
downloadFile(file)
|
||||
}
|
||||
})
|
||||
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
|
||||
</script>
|
||||
|
||||
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
|
||||
@@ -42,7 +60,7 @@
|
||||
<div class="note">
|
||||
{note.contents}
|
||||
</div>
|
||||
<Button on:click={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
|
||||
<Button onclick={() => copy(note.contents)}>{$t('common.copy_clipboard')}</Button>
|
||||
|
||||
{#if links && links.length}
|
||||
<div class="links">
|
||||
@@ -59,13 +77,22 @@
|
||||
{:else}
|
||||
{#each files as file}
|
||||
<div class="note file">
|
||||
<button on:click={() => downloadFile(file)}>
|
||||
<button onclick={() => downloadFile(file)}>
|
||||
<b>↓ {file.name}</b>
|
||||
</button>
|
||||
<small> {file.type} - {prettyBytes(file.size)}</small>
|
||||
<small> {file.mime} - {prettyBytes(file.size ?? file.data.length)}</small>
|
||||
</div>
|
||||
{#if file.mime.startsWith('image/')}
|
||||
{#key file.name}
|
||||
<img
|
||||
src={URL.createObjectURL(new File([file.data.slice(0)], file.name, { type: file.mime }))}
|
||||
alt={file.name}
|
||||
class="preview"
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
{/each}
|
||||
<Button on:click={download}>{$t('show.download_all')}</Button>
|
||||
<Button onclick={download}>{$t('show.download_all')}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -114,4 +141,13 @@
|
||||
margin-bottom: 0.5rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 2px solid var(--ui-bg-1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let label: string = ''
|
||||
export let value: boolean
|
||||
export let color = true
|
||||
interface Props {
|
||||
label?: string
|
||||
value: boolean
|
||||
color?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
let { label = '', value = $bindable(), color = true, ...rest }: Props = $props()
|
||||
</script>
|
||||
|
||||
<label {...$$restProps}>
|
||||
<label {...rest}>
|
||||
<small>{label}</small>
|
||||
<input type="checkbox" bind:checked={value} />
|
||||
<span class:color class="slider" />
|
||||
<span class:color class="slider"></span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let label: string = ''
|
||||
export let value: string
|
||||
interface Props {
|
||||
label?: string
|
||||
value: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
let { label = '', value = $bindable(), ...rest }: Props = $props()
|
||||
</script>
|
||||
|
||||
<label>
|
||||
<small>
|
||||
{label}
|
||||
</small>
|
||||
<textarea class="box" {...$$restProps} bind:value />
|
||||
<textarea class="box" {...rest} bind:value></textarea>
|
||||
</label>
|
||||
|
||||
@@ -1,51 +1,71 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/ui/Icon.svelte'
|
||||
import { copy as copyFN } from '$lib/utils'
|
||||
import { getRandomBytes, Hex } from 'occulto'
|
||||
import { randomBytes, bytesToHex } from '@cryptgeon/shared'
|
||||
import type { HTMLInputAttributes } from 'svelte/elements'
|
||||
|
||||
export let label: string = ''
|
||||
export let value: any
|
||||
export let validate: (value: any) => boolean | string = () => true
|
||||
export let copy: boolean = false
|
||||
export let random: boolean = false
|
||||
|
||||
const initialType = $$restProps.type
|
||||
const isPassword = initialType === 'password'
|
||||
let hidden = true
|
||||
|
||||
$: valid = validate(value)
|
||||
|
||||
$: if (isPassword) {
|
||||
value
|
||||
$$restProps.type = hidden ? initialType : 'text'
|
||||
interface Props {
|
||||
label?: string
|
||||
value: any
|
||||
validate?: (value: any) => boolean | string
|
||||
copy?: boolean
|
||||
random?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
label = '',
|
||||
value = $bindable(),
|
||||
validate = () => true,
|
||||
copy = false,
|
||||
random = false,
|
||||
...rest
|
||||
}: HTMLInputAttributes & Props = $props()
|
||||
|
||||
// svelte-ignore state_referenced_locally
|
||||
const initialType = $state(rest.type)
|
||||
const isPassword = initialType === 'password'
|
||||
let hidden = $state(true)
|
||||
|
||||
let valid = $derived(validate(value))
|
||||
let type = $derived(isPassword ? (hidden ? 'password' : 'text') : rest.type)
|
||||
|
||||
function toggle() {
|
||||
console.debug('toggle')
|
||||
hidden = !hidden
|
||||
}
|
||||
|
||||
async function randomFN() {
|
||||
value = Hex.encode(await getRandomBytes(32))
|
||||
value = bytesToHex(randomBytes(32))
|
||||
}
|
||||
</script>
|
||||
|
||||
<label>
|
||||
<small class:disabled={$$restProps.disabled}>
|
||||
<small class:disabled={rest.disabled}>
|
||||
{label}
|
||||
{#if valid !== true}
|
||||
<span class="error-text">{valid}</span>
|
||||
{/if}
|
||||
</small>
|
||||
<input bind:value {...$$restProps} class:valid={valid === true} />
|
||||
<input bind:value {...rest} {type} autocomplete="off" class:valid={valid === true} />
|
||||
<div class="icons">
|
||||
{#if isPassword}
|
||||
<Icon class="icon" icon={hidden ? 'eye' : 'eye-off'} on:click={toggle} />
|
||||
<Icon
|
||||
disabled={rest.disabled}
|
||||
class="icon"
|
||||
icon={hidden ? 'eye' : 'eye-off'}
|
||||
onclick={toggle}
|
||||
/>
|
||||
{/if}
|
||||
{#if random}
|
||||
<Icon class="icon" icon="dice" on:click={randomFN} />
|
||||
<Icon disabled={rest.disabled} class="icon" icon="dice" onclick={randomFN} />
|
||||
{/if}
|
||||
{#if copy}
|
||||
<Icon class="icon" icon="copy" on:click={() => copyFN(value.toString())} />
|
||||
<Icon
|
||||
disabled={rest.disabled}
|
||||
class="icon"
|
||||
icon="copy"
|
||||
onclick={() => copyFN(value.toString())}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<script lang="ts" context="module">
|
||||
<script lang="ts" module>
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
enum Theme {
|
||||
Dark = 'dark',
|
||||
Light = 'light',
|
||||
Auto = 'auto',
|
||||
}
|
||||
const themes = ['dark', 'light', 'auto'] as const
|
||||
type Theme = (typeof themes)[number]
|
||||
|
||||
const NextTheme = {
|
||||
[Theme.Auto]: Theme.Light,
|
||||
[Theme.Light]: Theme.Dark,
|
||||
[Theme.Dark]: Theme.Auto,
|
||||
const NextTheme: Record<Theme, Theme> = {
|
||||
auto: 'light',
|
||||
light: 'dark',
|
||||
dark: 'auto',
|
||||
}
|
||||
|
||||
function init(): Theme {
|
||||
if (typeof window !== 'undefined') {
|
||||
const saved = window.localStorage.getItem('theme') as Theme
|
||||
if (Object.values(Theme).includes(saved)) return saved
|
||||
if (themes.includes(saved)) return saved
|
||||
}
|
||||
return Theme.Auto
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
export const theme = writable<Theme>(init())
|
||||
@@ -40,7 +37,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={change}>
|
||||
<button onclick={change}>
|
||||
<Icon class="icon" icon="contrast" />
|
||||
{$theme}
|
||||
</button>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import copyToClipboard from 'copy-to-clipboard'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import { get } from 'svelte/store'
|
||||
|
||||
import { notify } from './toast'
|
||||
|
||||
export function copy(value: string) {
|
||||
copyToClipboard(value)
|
||||
window.navigator.clipboard.writeText(value)
|
||||
const msg = get(t)('common.copied_to_clipboard')
|
||||
notify.success(msg)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { AES, Hex } from 'occulto'
|
||||
import {
|
||||
deriveKey, generateKey, encrypt, randomBytes,
|
||||
bytesToHex, encode, compress,
|
||||
create as apiCreate,
|
||||
type FileDTO, type ServerNote
|
||||
} from '@cryptgeon/shared'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
import { blur } from 'svelte/transition'
|
||||
|
||||
@@ -10,33 +15,31 @@
|
||||
import FileUpload from '$lib/ui/FileUpload.svelte'
|
||||
import Loader from '$lib/ui/Loader.svelte'
|
||||
import MaxSize from '$lib/ui/MaxSize.svelte'
|
||||
import PastedFilesPreview from '$lib/ui/PastedFilesPreview.svelte'
|
||||
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
|
||||
import Switch from '$lib/ui/Switch.svelte'
|
||||
import TextArea from '$lib/ui/TextArea.svelte'
|
||||
import type { FileDTO, Note } from '@cryptgeon/shared'
|
||||
import { Adapters, PayloadToLargeError, create } from '@cryptgeon/shared'
|
||||
|
||||
let note: Note = {
|
||||
contents: '',
|
||||
meta: { type: 'text' },
|
||||
views: 1,
|
||||
expiration: 60,
|
||||
}
|
||||
let files: FileDTO[]
|
||||
let result: NoteResult | null = null
|
||||
let advanced = false
|
||||
let isFile = false
|
||||
let timeExpiration = false
|
||||
let customPassword: string | null = null
|
||||
let description = ''
|
||||
let loading: string | null = null
|
||||
let note: { views: number; expiration: number } = $state({ views: 1, expiration: 60 })
|
||||
let files: FileDTO[] = $state([])
|
||||
let result: NoteResult | null = $state(null)
|
||||
let advanced = $state(false)
|
||||
let isFile = $state(false)
|
||||
let timeExpiration = $state(false)
|
||||
let customPassword: string | null = $state(null)
|
||||
let description = $state('')
|
||||
let loading: string | null = $state(null)
|
||||
let isPasting = $state(false)
|
||||
let textContent = $state('')
|
||||
|
||||
$: if (!advanced) {
|
||||
$effect(() => {
|
||||
if (!advanced) {
|
||||
note.views = 1
|
||||
timeExpiration = false
|
||||
}
|
||||
})
|
||||
|
||||
$: {
|
||||
$effect(() => {
|
||||
description = $t('home.explanation', {
|
||||
values: {
|
||||
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
|
||||
@@ -44,54 +47,117 @@
|
||||
}),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!isFile) textContent = ''
|
||||
})
|
||||
|
||||
async function handlePaste(e: ClipboardEvent) {
|
||||
const data = e.clipboardData
|
||||
if (!data) return
|
||||
|
||||
const raw: File[] = []
|
||||
|
||||
if (data.files.length) {
|
||||
raw.push(...Array.from(data.files))
|
||||
}
|
||||
|
||||
$: note.meta.type = isFile ? 'file' : 'text'
|
||||
for (let i = 0; i < data.items.length; i++) {
|
||||
const item = data.items[i]
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) raw.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
$: if (!isFile) {
|
||||
note.contents = ''
|
||||
if (raw.length === 0) return
|
||||
e.preventDefault()
|
||||
|
||||
const seen = new Set<string>()
|
||||
const pasted: File[] = []
|
||||
for (const f of raw) {
|
||||
const key = `${f.name}|${f.size}`
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key)
|
||||
pasted.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
isPasting = true
|
||||
|
||||
const dtos: FileDTO[] = await Promise.all(
|
||||
pasted.map(async (file) => {
|
||||
const ext = file.name.includes('.') ? '' : `.${file.type.split('/')[1] || 'bin'}`
|
||||
const name =
|
||||
file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`
|
||||
const renamed = new File([file], name, { type: file.type })
|
||||
const data = new Uint8Array(await renamed.arrayBuffer())
|
||||
return {
|
||||
name: renamed.name,
|
||||
mime: renamed.type,
|
||||
size: renamed.size,
|
||||
data,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (dtos.length > 0) {
|
||||
if (!isFile) isFile = true
|
||||
files = [...files, ...dtos]
|
||||
}
|
||||
|
||||
isPasting = false
|
||||
}
|
||||
|
||||
class EmptyContentError extends Error {}
|
||||
|
||||
async function submit() {
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
loading = $t('common.encrypting')
|
||||
|
||||
const derived = customPassword && (await AES.derive(customPassword))
|
||||
const key = derived ? derived[0] : await AES.generateKey()
|
||||
const salt = customPassword ? randomBytes(16) : null
|
||||
const key = customPassword
|
||||
? deriveKey(customPassword, salt!)
|
||||
: generateKey()
|
||||
|
||||
const data: Note = {
|
||||
contents: '',
|
||||
meta: note.meta,
|
||||
}
|
||||
if (derived) data.meta.derivation = derived[1]
|
||||
let inner: Uint8Array
|
||||
if (isFile) {
|
||||
if (files.length === 0) throw new EmptyContentError()
|
||||
data.contents = await Adapters.Files.encrypt(files, key)
|
||||
inner = encode({ type: 'files', data: files })
|
||||
} else {
|
||||
if (note.contents === '') throw new EmptyContentError()
|
||||
data.contents = await Adapters.Text.encrypt(note.contents, key)
|
||||
if (textContent === '') throw new EmptyContentError()
|
||||
inner = encode({ type: 'text', data: textContent })
|
||||
}
|
||||
|
||||
const originalSize =inner.byteLength
|
||||
const compressed = compress(inner)
|
||||
const compresseedSize= compressed.byteLength
|
||||
console.debug({originalSize, compresseedSize, ratio: originalSize/compresseedSize})
|
||||
|
||||
const data = encrypt(compress(inner), key)
|
||||
const extra = customPassword
|
||||
? encode({ salt: salt!, N: 32768, r: 8, p: 1 })
|
||||
: new Uint8Array()
|
||||
const serverNote: ServerNote = {
|
||||
meta: {
|
||||
...(timeExpiration ? { expiration: parseInt(note.expiration as any) } : { views: parseInt(note.views as any) }),
|
||||
extra,
|
||||
},
|
||||
data,
|
||||
}
|
||||
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
|
||||
else data.views = parseInt(note.views as any)
|
||||
|
||||
loading = $t('common.uploading')
|
||||
const response = await create(data)
|
||||
const response = await apiCreate(serverNote)
|
||||
result = {
|
||||
id: response.id,
|
||||
password: customPassword ? undefined : Hex.encode(key),
|
||||
password: customPassword ? undefined : bytesToHex(key),
|
||||
}
|
||||
notify.success($t('home.messages.note_created'))
|
||||
} catch (e) {
|
||||
if (e instanceof PayloadToLargeError) {
|
||||
notify.error($t('home.errors.note_to_big'))
|
||||
} else if (e instanceof EmptyContentError) {
|
||||
notify.error($t('home.errors.empty_content'))
|
||||
} else {
|
||||
console.error(e)
|
||||
notify.error($t('home.errors.note_error'))
|
||||
}
|
||||
} finally {
|
||||
loading = null
|
||||
}
|
||||
@@ -104,26 +170,38 @@
|
||||
<p>
|
||||
{@html $status?.theme_text || $t('home.intro')}
|
||||
</p>
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<form onsubmit={submit} onpaste={handlePaste}>
|
||||
<fieldset disabled={loading !== null}>
|
||||
<div class="paste-indicator">
|
||||
{#if isPasting}
|
||||
<div class="pasting-overlay">
|
||||
<div class="pasting-spinner"></div>
|
||||
<span>{$t('home.pasting')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isFile}
|
||||
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
|
||||
{:else}
|
||||
<TextArea
|
||||
data-testid="text-field"
|
||||
label={$t('common.note')}
|
||||
bind:value={note.contents}
|
||||
bind:value={textContent}
|
||||
placeholder="..."
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<PastedFilesPreview bind:files />
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
{#if $status?.allow_files}
|
||||
<Switch
|
||||
data-testid="switch-file"
|
||||
class="file"
|
||||
label={$t('common.file')}
|
||||
bind:value={isFile}
|
||||
/>
|
||||
{/if}
|
||||
{#if $status?.allow_advanced}
|
||||
<Switch
|
||||
data-testid="switch-advanced"
|
||||
@@ -131,7 +209,7 @@
|
||||
bind:value={advanced}
|
||||
/>
|
||||
{/if}
|
||||
<div class="grow" />
|
||||
<div class="grow"></div>
|
||||
<div class="tr">
|
||||
<small>{$t('common.max')}: <MaxSize /> </small>
|
||||
<br />
|
||||
@@ -172,4 +250,40 @@
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.paste-indicator {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.pasting-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.pasting-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
<script lang="ts">
|
||||
import ThemeToggle from '$lib/ui/ThemeToggle.svelte'
|
||||
import { status } from '$lib/stores/status'
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<ThemeToggle />
|
||||
<nav>
|
||||
{#if $status?.theme_home_link !== false}
|
||||
<a href="/">/home</a>
|
||||
{/if}
|
||||
<a href="/about">/about</a>
|
||||
{#if $status?.imprint_url}
|
||||
<a href={$status.imprint_url} target="_blank" rel="noopener noreferrer">/imprint</a>
|
||||
{:else if $status?.imprint_html}
|
||||
<a href="/imprint">/imprint</a>
|
||||
{/if}
|
||||
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
|
||||
code
|
||||
</a>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { status } from '$lib/stores/status'
|
||||
|
||||
function reset() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<a href="/">
|
||||
{#if $status?.theme_image}
|
||||
<a onclick={reset} href="/">
|
||||
{#if $status === null}
|
||||
<!-- waiting for status to load to avoid flashing default logo -->
|
||||
{:else if $status.theme_image}
|
||||
<img alt="logo" src={$status.theme_image} />
|
||||
{:else}
|
||||
<svg
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
import { init as initStores, status } from '$lib/stores/status'
|
||||
import Footer from '$lib/views/Footer.svelte'
|
||||
import Header from '$lib/views/Header.svelte'
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { children }: Props = $props()
|
||||
|
||||
onMount(() => {
|
||||
initStores()
|
||||
@@ -22,7 +27,7 @@
|
||||
{#await waitLocale() then _}
|
||||
<main>
|
||||
<Header />
|
||||
<slot />
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
<SvelteToast />
|
||||
|
||||
@@ -40,19 +40,8 @@
|
||||
<br />
|
||||
you are welcomed to check & audit the
|
||||
<a href="https://github.com/cupcakearmy/cryptgeon" target="_blank" rel="noopener noreferrer">
|
||||
source code
|
||||
</a>.
|
||||
</span>
|
||||
</AboutParagraph>
|
||||
|
||||
<AboutParagraph title="translations">
|
||||
<span
|
||||
>translations are managed on <a
|
||||
href="https://lokalise.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Lokalise</a
|
||||
>, which granted an open source license to use the paid version. If you are interested in
|
||||
helping translating don't hesitate to contact me!
|
||||
source code</a
|
||||
>.
|
||||
</span>
|
||||
</AboutParagraph>
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { status } from '$lib/stores/status'
|
||||
|
||||
status.subscribe((config) => {
|
||||
if (config != null) {
|
||||
if (config.imprint_url) {
|
||||
window.location.href = config.imprint_url
|
||||
} else if (config.imprint_html == '') {
|
||||
goto('/about')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Imprint</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="content">
|
||||
{#if $status?.imprint_html}
|
||||
{@html $status.imprint_html}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AES, Hex } from 'occulto'
|
||||
import { deriveKey, hexToBytes, decrypt, decode, decompress, info, get as apiGet, type FileDTO } from '@cryptgeon/shared'
|
||||
import { onMount } from 'svelte'
|
||||
import { t } from 'svelte-intl-precompile'
|
||||
|
||||
@@ -7,30 +7,36 @@
|
||||
import Loader from '$lib/ui/Loader.svelte'
|
||||
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
|
||||
import TextInput from '$lib/ui/TextInput.svelte'
|
||||
import { Adapters, get, info, type NoteMeta } from '@cryptgeon/shared'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
export let data: PageData
|
||||
interface Props {
|
||||
data: PageData
|
||||
}
|
||||
|
||||
let id = data.id
|
||||
let password: string | null = null
|
||||
let note: DecryptedNote | null = null
|
||||
let exists = false
|
||||
let meta: NoteMeta | null = null
|
||||
let { data }: Props = $props()
|
||||
|
||||
let loading: string | null = null
|
||||
let error: string | null = null
|
||||
let id = $derived(data.id)
|
||||
let password: string | null = $state<string | null>(null)
|
||||
let note: DecryptedNote | null = $state(null)
|
||||
let exists = $state(false)
|
||||
let hasExtra = $state(false)
|
||||
|
||||
$: valid = !!password?.length
|
||||
let loading: string | null = $state(null)
|
||||
let error: string | null = $state(null)
|
||||
|
||||
let valid = $derived(!!password?.length)
|
||||
|
||||
onMount(async () => {
|
||||
// Check if note exists
|
||||
try {
|
||||
loading = $t('common.loading')
|
||||
password = window.location.hash.slice(1)
|
||||
const note = await info(id)
|
||||
meta = note.meta
|
||||
const meta = await info(id)
|
||||
if (meta) {
|
||||
hasExtra = !!meta.extra?.length
|
||||
exists = true
|
||||
} else {
|
||||
exists = false
|
||||
}
|
||||
} catch {
|
||||
exists = false
|
||||
} finally {
|
||||
@@ -38,34 +44,49 @@
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Get the actual contents of the note and decrypt it.
|
||||
*/
|
||||
async function show() {
|
||||
async function show(e: SubmitEvent) {
|
||||
e.preventDefault()
|
||||
try {
|
||||
if (!valid) {
|
||||
error = $t('show.errors.no_password')
|
||||
return
|
||||
}
|
||||
|
||||
// Load note
|
||||
error = null
|
||||
loading = $t('common.downloading')
|
||||
const data = await get(id)
|
||||
const serverNote = await apiGet(id)
|
||||
if (!serverNote) {
|
||||
error = $t('show.errors.not_found')
|
||||
return
|
||||
}
|
||||
|
||||
loading = $t('common.decrypting')
|
||||
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
|
||||
const key = derived ? derived[0] : Hex.decode(password!)
|
||||
switch (data.meta.type) {
|
||||
let key: Uint8Array
|
||||
if (hasExtra && serverNote.meta.extra && serverNote.meta.extra.length > 0) {
|
||||
const derivation = decode(serverNote.meta.extra) as any
|
||||
key = deriveKey(password!, new Uint8Array(derivation.salt))
|
||||
} else {
|
||||
key = hexToBytes(password!)
|
||||
}
|
||||
|
||||
const decrypted = decrypt(serverNote.data, key)
|
||||
const content = decode(decompress(decrypted)) as any
|
||||
|
||||
switch (content.type) {
|
||||
case 'text':
|
||||
note = {
|
||||
meta: { type: 'text' },
|
||||
contents: await Adapters.Text.decrypt(data.contents, key),
|
||||
contents: content.data,
|
||||
}
|
||||
break
|
||||
case 'file':
|
||||
case 'files':
|
||||
const files = (content.data as any[]).map((f: any) => ({
|
||||
...f,
|
||||
data: f.data instanceof Uint8Array ? f.data : new Uint8Array(f.data as any),
|
||||
}))
|
||||
note = {
|
||||
meta: { type: 'file' },
|
||||
contents: await Adapters.Files.decrypt(data.contents, key),
|
||||
contents: files,
|
||||
}
|
||||
break
|
||||
default:
|
||||
@@ -86,10 +107,10 @@
|
||||
{:else if note && !error}
|
||||
<ShowNote {note} />
|
||||
{:else}
|
||||
<form on:submit|preventDefault={show}>
|
||||
<form onsubmit={show}>
|
||||
<fieldset>
|
||||
<p>{$t('show.explanation')}</p>
|
||||
{#if meta?.derivation}
|
||||
{#if hasExtra}
|
||||
<TextInput
|
||||
data-testid="show-note-password"
|
||||
type="password"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite'
|
||||
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const port = 8001
|
||||
const port = 3000
|
||||
|
||||
/** @type {import('vite').UserConfig} */
|
||||
const config = {
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
server: { port },
|
||||
server: {
|
||||
port,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
preview: { port },
|
||||
plugins: [sveltekit(), precompileIntl('locales')],
|
||||
}
|
||||
|
||||
export default config
|
||||
})
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "@cryptgeon/proxy",
|
||||
"type": "module",
|
||||
"main": "./proxy.js",
|
||||
"scripts": {
|
||||
"dev": "node ."
|
||||
},
|
||||
"dependencies": {
|
||||
"http-proxy": "^1.18.1"
|
||||
}
|
||||
}
|
||||