Compare commits

..

85 Commits

Author SHA1 Message Date
c46f80aaa0 Merge pull request #93 from cupcakearmy/feat/92-health-check
Feat/92 health check
2023-06-23 10:34:18 +02:00
e2711cc887 add healthcheck endpoint and startup check 2023-06-23 10:17:13 +02:00
e02224216a add changelog 2023-06-23 10:16:28 +02:00
1b0d5449a0 update postman collection 2023-06-23 10:16:13 +02:00
9695d3a63f version bumps 2023-06-23 10:16:03 +02:00
22d4efb03e add healthcheck examples 2023-06-23 10:15:31 +02:00
97741ed73f add curl for health check 2023-06-23 10:15:14 +02:00
c9e5de0f37 about page spacing 2023-06-02 23:51:54 +02:00
dc1c03d912 Merge pull request #90 from cupcakearmy/feature/52-Add-note-id-size-option
feat: add note id size option
2023-05-30 10:31:12 +02:00
2a75acae3f docs 2023-05-30 09:43:41 +02:00
815ac4e8ba changelog 2023-05-30 09:43:31 +02:00
7c85c1e621 version bump 2023-05-30 09:43:26 +02:00
a323d48c41 feat: add note id size option 2023-05-29 16:34:59 +02:00
2bff6a37db add some metadata 2023-05-26 01:10:22 +02:00
f8223dfc62 enable sparse bundle 2023-05-26 00:21:50 +02:00
063d073c27 fix pipeline 2023-05-25 23:54:59 +02:00
ac32b97383 Merge pull request #89 from cupcakearmy/69/password
69/password
2023-05-25 23:47:08 +02:00
9c9c23d958 version bump 2023-05-25 23:29:09 +02:00
92893a5b2d github actions 2023-05-25 23:29:05 +02:00
ac68f4a540 docs 2023-05-25 19:06:07 +02:00
83b2fa5372 version bump 2023-05-25 18:15:31 +02:00
3c86f3f3be update pnpm version 2023-05-25 18:15:18 +02:00
80e64ad207 fix types 2023-05-25 18:15:05 +02:00
a5809c216c fix scripts 2023-05-25 10:17:08 +02:00
fb95a68b0d test files in cli and cross with password 2023-05-25 10:17:01 +02:00
b43b802221 add --all option, stdin and password option 2023-05-25 10:16:44 +02:00
2e89007c83 add test ids 2023-05-25 10:16:12 +02:00
fdc2722fb9 add password to frontend 2023-05-23 09:39:19 +02:00
6da28a701e translations 2023-05-23 09:39:06 +02:00
e6d1e0f44a add password to CLI 2023-05-23 09:39:00 +02:00
6000553b95 include meta in info endpoint 2023-05-23 09:38:23 +02:00
85204776d7 demo postman collection 2023-05-23 09:38:00 +02:00
c2bfe9dd0d add derivation to metadata 2023-05-23 09:37:49 +02:00
b05841a562 add timeout 2023-05-23 09:37:33 +02:00
d7e5a34b14 CLI (#84)
* move to packages

* update deps

* update deps

* actions maintenance

* don't use blob

* cli

* fix default import

* use synthetic default imports

* remove comment

* cli packaging

* node 18 guard

* packages

* build system

* testing

* test pipeline

* pipelines

* changelog

* version bump

* update locales

* update deps

* update deps

* update dependecies
2023-05-14 13:52:47 +02:00
13dfd933af Add tutorial (#83) 2023-05-13 08:41:05 +02:00
Oleg Salnikov
74840416f1 locales (#85) 2023-05-13 08:40:50 +02:00
luolongfei
9aaad5b910 update zh.json file. (#79) 2023-03-02 09:55:50 +01:00
dependabot[bot]
c246207420 Bump tokio from 1.24.1 to 1.25.0 in /packages/backend (#75)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.24.1 to 1.25.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.24.1...tokio-1.25.0)

---
updated-dependencies:
- dependency-name: tokio
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-13 19:21:06 +01:00
7ee1b8370a update deps 2023-01-14 18:54:27 +01:00
e7750699cc chose: changelog 2023-01-14 18:40:45 +01:00
e14042ea28 chinese language code 2023-01-14 18:40:16 +01:00
6fb7518b6a moved to occulto 2023-01-13 21:24:27 +01:00
436ae2a7e5 move dev ports 2023-01-13 19:36:26 +01:00
fe5ce580ab locales 2023-01-04 19:45:25 +01:00
0f882da5d1 bump version 2023-01-04 19:45:18 +01:00
ad6f136dd0 qr code 2023-01-04 19:40:37 +01:00
da527a0857 2.0.7 2022-12-26 22:54:47 +01:00
a95931ae77 update deps 2022-12-26 18:00:00 +01:00
d6c2236673 update deps 2022-12-26 12:59:37 +01:00
42a8ab5d0f test command 2022-11-15 13:15:34 +01:00
0934808a59 testing 2022-11-15 13:05:13 +01:00
88ea828b66 upload 2022-11-15 12:35:02 +01:00
41ed5c0e23 ci 2022-11-15 12:17:52 +01:00
0a98481991 use npx 2022-11-15 12:13:03 +01:00
5d62c48a35 don't use pnpm 2022-11-14 16:26:51 +01:00
0ab39023b0 test 2022-11-14 16:16:50 +01:00
7b202962e8 testing 2022-11-14 15:55:49 +01:00
7a045b3f34 test on docker image 2022-11-14 15:47:12 +01:00
cb80c8bfe4 changelog 2022-11-12 14:40:21 +01:00
74c3197e47 update dependencies and fix some a11y issues 2022-11-12 14:40:17 +01:00
6ae927ce71 update version and dependencies 2022-11-12 13:55:33 +01:00
9d13e607f5 #66 set minimum 2022-11-12 13:42:09 +01:00
0db3ef4a1f changelog and only test on x86 2022-11-04 23:34:36 +01:00
03e9fb431f put flows back together 2022-11-04 22:10:19 +01:00
b84df2866b build matrix 2022-11-02 13:36:56 +01:00
3d4fef7c23 try with matrix build 2022-11-02 13:34:41 +01:00
9d787008a4 also build docker when testing 2022-11-02 13:29:19 +01:00
687f26bb40 name the workflow 2022-11-02 13:26:57 +01:00
371a869800 use nightly cargo with sparse registry 2022-11-02 13:24:30 +01:00
321c303a8a changelog 2022-10-29 19:45:14 +02:00
2f176d84e9 wrong docker compose 2022-10-29 19:43:59 +02:00
67d4f09bd7 #62 (#63)
* #62 add theme options for title and favicon

* docs

* version bump
2022-10-27 17:26:56 +02:00
c40f009523 Update README.md 2022-10-24 16:35:54 +02:00
026f8c69d7 add size limit to redis 2022-10-24 16:11:50 +02:00
cacb808117 restructuring (#56)
* restructuring

* pin svelte kit version & parallel execution

* update svelte kit

* correct test result assets

* add timeout

* correct locale path

* simplify crypto

* fix for #58

* add verbosity flag

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

* add toasts and update deps

* changelog

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

* try playwrigth

* testing

* add pr support

* not on each commit

* add test ids

* make backend more configuratble

* 2.0.2

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

* try playwrigth

* testing

* add pr support

* not on each commit
2022-07-19 14:12:51 +02:00
142 changed files with 6145 additions and 2807 deletions

2
.dev.env Normal file
View File

@@ -0,0 +1,2 @@
SIZE_LIMIT=10MiB
VERBOSITY=debug

View File

@@ -1,15 +1,15 @@
*
!/backend/src
!/backend/Cargo.lock
!/backend/Cargo.toml
!/packages
!/package.json
!/pnpm-lock.yaml
!/pnpm-workspace.yaml
!/frontend/locales
!/frontend/src
!/frontend/static
!/frontend/.npmrc
!/frontend/package.json
!/frontend/pnpm-lock.yaml
!/frontend/svelte.config.js
!/frontend/tsconfig.json
!/frontend/vite.config.js
**/target
**/node_modules
**/dist
**/bin
**/*.tsbuildinfo
**/build
**/.svelte
**/.svelte-kit

1
.gitattributes vendored
View File

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

View File

@@ -1,41 +0,0 @@
name: ci
on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
install: true
- name: Docker Labels
id: meta
uses: crazy-max/ghaction-docker-meta@v2
with:
images: cupcakearmy/cryptgeon
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}

58
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: Publish
on:
workflow_dispatch:
push:
tags:
- 'v*.*.*'
jobs:
cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
cache: 'pnpm'
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
- run: |
pnpm install --frozen-lockfile
pnpm run build
- run: npm publish
working-directory: ./packages/cli
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
- name: Docker Labels
id: meta
uses: docker/metadata-action@v4
with:
images: cupcakearmy/cryptgeon
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}

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

@@ -0,0 +1,39 @@
name: Test
on:
push:
branches:
- main
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# Node
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3
with:
cache: 'pnpm'
node-version-file: '.nvmrc'
# Docker
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
with:
install: true
- name: Prepare
run: |
pnpm install --frozen-lockfile
pnpm exec playwright install --with-deps
pnpm run test:prepare
- name: Run your tests
run: pnpm test
- uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results

15
.gitignore vendored
View File

@@ -1,11 +1,10 @@
.env
*.tsbuildinfo
node_modules
dist
bin
# Backend
target
# Client
.DS_Store
node_modules
/.svelte
/build
/functions
.env
# Testing
test-results

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v18.16

View File

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

View File

@@ -5,6 +5,90 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.3.1] - 2023-06-23
### Added
- #92: Endpoint (`/api/live/`) for checking health status.
## [2.3.0] - 2023-05-30
### Added
- New CLI 🎉.
- Russian language.
- Option for reducing note id size (`ID_LENGTH`).
### Changed
- Moved to monorepo.
### Changed
- Default port is now 8000, not 5000.
- Moved to generic encryption library `occulto`.
### Fixed
- Bad chinese language code.
### Security
- Updated dependencies.
## [2.1.0] - 2023-01-04
### Added
- QR Code to more easily copy and share links.
## [2.0.7] - 2022-12-26
### Changed
- Svelte Kit now stable 🎉
## [2.0.6] - 2022-11-12
### Fixed
- #66 Set minimum a view.
### Security
- Updated dependencies.
## [2.0.5] - 2022-11-04
### Fixed
- Docker build pipeline.
## [2.0.4] - 2022-10-29
### Added
- `THEME_PAGE_TITLE`.
- `THEME_FAVICON`.
## [2.0.3] - 2022-10-07
### Added
- Flag for verbosity.
### Fixed
- #58 Fixed bug in the max views frontend form.
## [2.0.2] - 2022-07-20
### Added
- Toasts for events.
- E2E Tests.
- Make backend more configurable.
## [2.0.1] - 2022-07-18
### Added

View File

@@ -0,0 +1,693 @@
{
"info": {
"_postman_id": "3aaeac19-4eac-4911-b3c8-912b17a48634",
"name": "Cryptgeon",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "Notes",
"item": [
{
"name": "Preview",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": [
"{{BASE}}"
],
"path": [
"notes",
":id"
],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
},
"description": "This endpoint is to query wether a note exists, without actually opening it. No view limits are used here, as contents of the note are not available, only the `meta` field is returned, which is public."
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": [
"{{BASE}}"
],
"path": [
"notes",
":id"
],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:24:29 GMT"
}
],
"cookie": [],
"body": "{}"
},
{
"name": "404",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": [
"{{BASE}}"
],
"path": [
"notes",
":id"
],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}",
"description": "Id of the Note"
}
]
}
},
"status": "Not Found",
"code": 404,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:25:26 GMT"
}
],
"cookie": [],
"body": ""
}
]
},
{
"name": "Create",
"event": [
{
"listen": "test",
"script": {
"exec": [
"const jsonData = pm.response.json();",
"pm.collectionVariables.set('NOTE_ID', jsonData.id)",
""
],
"type": "text/javascript"
}
}
],
"request": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": [
"{{BASE}}"
],
"path": [
"notes",
""
]
}
},
"response": [
{
"name": "Simple",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 1,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": [
"{{BASE}}"
],
"path": [
"notes",
""
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
},
{
"name": "5 Minutes",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"expiration\": 5,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": [
"{{BASE}}"
],
"path": [
"notes",
""
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
},
{
"name": "3 Views",
"originalRequest": {
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"contents\": \"Some encrypted content\",\n \"views\": 3,\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE}}/notes/",
"host": [
"{{BASE}}"
],
"path": [
"notes",
""
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:31:54 GMT"
}
],
"cookie": [],
"body": "{\n \"id\": \"1QeEWDQbQY9dOo8cDDQjykaEjouqugTR6A78sjgn4VMv\"\n}"
}
]
},
{
"name": "Read",
"request": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": [
"{{BASE}}"
],
"path": [
"notes",
":id"
],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
},
"description": "This endpoint gets the actual contents of a note. It's a `DELETE` endpoint, es it decreases the `view` counter, and deletes the note if `0` is reached."
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": [
"{{BASE}}"
],
"path": [
"notes",
":id"
],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:59:07 GMT"
}
],
"cookie": [],
"body": "{\n \"meta\": \"{\\\"type\\\":\\\"text\\\"}\",\n \"contents\": \"Some encrypted content\"\n}"
},
{
"name": "404",
"originalRequest": {
"method": "DELETE",
"header": [],
"url": {
"raw": "{{BASE}}/notes/:id",
"host": [
"{{BASE}}"
],
"path": [
"notes",
":id"
],
"variable": [
{
"key": "id",
"value": "{{NOTE_ID}}"
}
]
}
},
"status": "Not Found",
"code": 404,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:59:15 GMT"
}
],
"cookie": [],
"body": ""
}
]
}
]
},
{
"name": "Status",
"item": [
{
"name": "Get server status",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/status/",
"host": [
"{{BASE}}"
],
"path": [
"status",
""
]
}
},
"response": [
{
"name": "200",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/status/",
"host": [
"{{BASE}}"
],
"path": [
"status",
""
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "connection",
"value": "close"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-type",
"value": "application/json"
},
{
"key": "date",
"value": "Tue, 23 May 2023 05:56:45 GMT"
}
],
"cookie": [],
"body": "{\n \"version\": \"2.3.0-beta.4\",\n \"max_size\": 10485760,\n \"max_views\": 100,\n \"max_expiration\": 360,\n \"allow_advanced\": true,\n \"theme_image\": \"\",\n \"theme_text\": \"\",\n \"theme_page_title\": \"\",\n \"theme_favicon\": \"\"\n}"
}
]
},
{
"name": "Health Check",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": [
"{{BASE}}"
],
"path": [
"live",
""
]
},
"description": "Return `200` for healthy service. `503` if service is unavailable."
},
"response": [
{
"name": "Healthy",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": [
"{{BASE}}"
],
"path": [
"live",
""
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "date",
"value": "Thu, 22 Jun 2023 20:17:58 GMT"
}
],
"cookie": [],
"body": null
},
{
"name": "Service Unavilable",
"originalRequest": {
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE}}/live/",
"host": [
"{{BASE}}"
],
"path": [
"live",
""
]
}
},
"status": "Service Unavailable",
"code": 503,
"_postman_previewlanguage": "plain",
"header": [
{
"key": "transfer-encoding",
"value": "chunked"
},
{
"key": "content-encoding",
"value": "gzip"
},
{
"key": "vary",
"value": "accept-encoding"
},
{
"key": "date",
"value": "Thu, 22 Jun 2023 20:18:55 GMT"
}
],
"cookie": [],
"body": null
}
]
}
]
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
}
],
"variable": [
{
"key": "BASE",
"value": "http://localhost:1234/api",
"type": "default"
},
{
"key": "NOTE_ID",
"value": "",
"type": "default"
}
]
}

View File

@@ -1,26 +1,30 @@
# FRONTEND
FROM node:16-alpine as client
FROM node:18-alpine as client
WORKDIR /tmp
RUN npm install -g pnpm@7
COPY ./frontend ./
RUN pnpm install
RUN pnpm exec svelte-kit sync
RUN npm install -g pnpm@8
COPY . .
RUN pnpm install --frozen-lockfile
RUN pnpm run build
# BACKEND
FROM rust:1.61-alpine as backend
FROM rust:1.69-alpine as backend
WORKDIR /tmp
RUN apk add libc-dev openssl-dev alpine-sdk
COPY ./backend ./
COPY ./packages/backend/Cargo.* ./
ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse
RUN cargo fetch
COPY ./packages/backend ./
RUN cargo build --release
# RUNNER
FROM alpine
WORKDIR /app
RUN apk add --no-cache curl
COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/build ./frontend/build
ENV REDIS=redis://redis/
EXPOSE 5000
COPY --from=client /tmp/packages/frontend/build ./frontend
ENV FRONTEND_PATH="./frontend"
ENV REDIS="redis://redis/"
EXPOSE 8000
ENTRYPOINT [ "/app/cryptgeon" ]

131
README.md
View File

@@ -18,18 +18,30 @@ EN | [简体中文](README_zh-CN.md)
## About?
_cryptgeon_ is a secure, open source sharing note or file service inspired by [_PrivNote_](https://privnote.com)
_cryptgeon_ is a secure, open source sharing note or file service inspired by [_PrivNote_](https://privnote.com).
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.
## Demo
## Live Service / Demo
Check out the demo and see for yourself https://cryptgeon.nicco.io.
### Web
Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org)
### CLI
```
npx cryptgeon send text "This is a secret note"
```
For more documentation about the CLI see the [readme](./packages/cli/README.md).
## Features
- send text or files
- server cannot decrypt contents due to client side encryption
- view or time constraints
- in memory, no persistence
@@ -50,19 +62,25 @@ of the notes even if it tried to.
## Environment Variables
| Variable | Default | Description |
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. |
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
| `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| Variable | Default | Description |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
| `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `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_. |
| `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 |
## Deployment
`https` is required otherwise browsers will not support the cryptographic functions.
> `https` is required otherwise browsers will not support the cryptographic functions.
> There is a health endpoint available at `/api/health/`. It returns either 200 or 503.
### Docker
@@ -76,15 +94,27 @@ version: '3.8'
services:
redis:
image: redis:7-alpine
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
app:
image: cupcakearmy/cryptgeon:latest
depends_on:
- redis
environment:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
- 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s
```
### NGINX Proxy
@@ -93,53 +123,34 @@ See the [examples/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/exam
### Traefik 2
Assumptions:
See the [examples/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik) folder.
- External proxy docker network `proxy`
- A certificate resolver `le`
- A https entrypoint `secure`
- Domain name `example.org`
### Scratch
```yaml
version: '3.8'
See the [examples/scratch](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/scratch) folder. There you'll find a guide how to setup a server and install cryptgeon from scratch.
networks:
proxy:
external: true
### Synology
services:
redis:
image: redis:7-alpine
restart: unless-stopped
There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) you can follow.
app:
image: cupcakearmy/cryptgeon:latest
restart: unless-stopped
depends_on:
- redis
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.cryptgeon.rule=Host(`example.org`)
- traefik.http.routers.cryptgeon.entrypoints=secure
- traefik.http.routers.cryptgeon.tls.certresolver=le
```
### YouTube Guides
- English by [Webnestify](https://www.youtube.com/watch?v=XAyD42I7wyI)
- 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
**Requirements**
- `pnpm`: `>=6`
- `node`: `>=16`
- `node`: `>=18`
- `rust`: edition `2021`
**Install**
```bash
pnpm install
pnpm --prefix frontend install
# Also you need cargo watch if you don't already have it installed.
# https://lib.rs/crates/cargo-watch
@@ -150,9 +161,6 @@ cargo install cargo-watch
Make sure you have docker running.
> If you are on `macOS` you might need to disable AirPlay Receiver as it uses port 5000 (So stupid...)
> https://developer.apple.com/forums/thread/682332
```bash
pnpm run dev
```
@@ -162,9 +170,34 @@ 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).
###### Attributions
> There is a Postman collection with some example requests [available in the repo](./Cryptgeon.postman_collection.json)
Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
### 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
```
## Security
Please refer to the security section [here](./SECURITY.md).
---
_Attributions_
- Test data:
- Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
- [AES Paper](https://www.cs.miami.edu/home/burt/learning/Csc688.012/rijndael/rijndael_doc_V2.pdf)
- [Unsplash Pictures](https://unsplash.com/)
- Loading animation by [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa)
- Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>

View File

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

18
SECURITY.md Normal file
View File

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

23
cryptgeon.code-workspace Normal file
View File

@@ -0,0 +1,23 @@
{
"folders": [
{
"path": "."
},
{
"path": "packages/backend"
},
{
"path": "packages/frontend"
},
{
"path": "packages/cli"
},
{
"path": "packages/shared"
}
],
"settings": {
"i18n-ally.localesPaths": ["locales"],
"cSpell.words": ["cryptgeon"]
}
}

View File

@@ -11,9 +11,15 @@ services:
app:
build: .
env_file: .dev.env
depends_on:
- redis
environment:
SIZE_LIMIT: 128 MiB
ports:
- 80:5000
- 1234:8000
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
interval: 1m
timeout: 3s
retries: 2
start_period: 5s

26
docker-compose.yaml Normal file
View File

@@ -0,0 +1,26 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
# Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
app:
image: cupcakearmy/cryptgeon:latest
depends_on:
- redis
environment:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:8000
# Optional health checks
# healthcheck:
# test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
# interval: 1m
# timeout: 3s
# retries: 2
# start_period: 5s

View File

@@ -4,7 +4,7 @@ server {
server_name _;
location / {
proxy_pass http://app:5000/;
proxy_pass http://app:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@@ -20,7 +20,7 @@ server {
ssl_trusted_certificate /path/to/fullchain.pem;
location / {
proxy_pass http://app:5000/;
proxy_pass http://app:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

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

View File

@@ -1,5 +0,0 @@
.DS_Store
node_modules
/.svelte
/build
/functions

View File

@@ -1 +0,0 @@
engine-strict=true

View File

@@ -1,47 +0,0 @@
{
"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": "复制到剪切版",
"encrypting": "加密",
"decrypting": "解密",
"uploading": "上传",
"downloading": "下载"
},
"home": {
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
"explanation": "该密信会在{type}后失效。",
"new_note": "新建密信",
"new_note_notice": "<b>可用性警示:</b><br />由于加密鸽的所有数据是全部保存在内存中的,所以如果加密鸽的可用内存被用光了那么它将会删除最早的密信以释放内存,因此不保证该密信的可用性。<br />(一般情况下是您应该是不会遇到这个问题,只是警示一下。)",
"errors": {
"note_to_big": "无法创建密信,这个密信太大了!",
"note_error": "无法创建密信,请再试一遍。",
"max": "最大文件大小: {n}",
"empty_content": "密信为空!"
},
"copied_to_clipboard": "已复制到剪切板 🔗"
},
"show": {
"errors": {
"not_found": "该密信无法被找到或者它已经被删除了!",
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!",
"unsupported_type": "不支持的票据类型。"
},
"explanation": "点击下方的按钮可以查看密信,如果它到达了限制将会被删除",
"show_note": "查看密信",
"warning_will_not_see_again": "您将<b>无法</b>再次查看该密信",
"download_all": "下载全部"
},
"file_upload": {
"selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中"
}
}

View File

@@ -1,35 +0,0 @@
{
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "svelte-kit preview",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker --summary > licenses.csv",
"locale:download": "node scripts/locale.js"
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^7.3.1",
"@sveltejs/adapter-static": "^1.0.0-next.37",
"@sveltejs/kit": "^1.0.0-next.377",
"@types/dompurify": "^2.3.3",
"@types/file-saver": "^2.0.5",
"adm-zip": "^0.5.9",
"dotenv": "^16.0.1",
"svelte": "^3.49.0",
"svelte-check": "^2.8.0",
"svelte-intl-precompile": "^0.10.1",
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.0",
"typescript": "^4.7.4",
"vite": "^3.0.1"
},
"dependencies": {
"@fontsource/fira-mono": "^4.5.8",
"copy-to-clipboard": "^3.3.1",
"dompurify": "^2.3.9",
"file-saver": "^2.0.5",
"pretty-bytes": "^5.6.0"
}
}

1609
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
/// <reference types="@sveltejs/kit" />
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@@ -1,61 +0,0 @@
import type { EncryptedFileDTO, FileDTO } from './api'
import { Crypto } from './crypto'
abstract class CryptAdapter<T> {
abstract encrypt(plaintext: T, key: CryptoKey): Promise<string>
abstract decrypt(ciphertext: string, key: CryptoKey): Promise<T>
}
class CryptTextAdapter implements CryptAdapter<string> {
async encrypt(plaintext: string, key: CryptoKey) {
return await Crypto.encrypt(new TextEncoder().encode(plaintext), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new TextDecoder().decode(plaintext)
}
}
class CryptBlobAdapter implements CryptAdapter<Blob> {
async encrypt(plaintext: Blob, key: CryptoKey) {
return await Crypto.encrypt(await plaintext.arrayBuffer(), key)
}
async decrypt(ciphertext: string, key: CryptoKey) {
const plaintext = await Crypto.decrypt(ciphertext, key)
return new Blob([plaintext], { type: 'application/octet-stream' })
}
}
class CryptFilesAdapter implements CryptAdapter<FileDTO[]> {
async encrypt(plaintext: FileDTO[], key: CryptoKey) {
const adapter = new CryptBlobAdapter()
const data: Promise<EncryptedFileDTO>[] = plaintext.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.encrypt(file.contents, key),
}))
return JSON.stringify(await Promise.all(data))
}
async decrypt(ciphertext: string, key: CryptoKey) {
const adapter = new CryptBlobAdapter()
const data: EncryptedFileDTO[] = JSON.parse(ciphertext)
const files: FileDTO[] = await Promise.all(
data.map(async (file) => ({
name: file.name,
size: file.size,
type: file.type,
contents: await adapter.decrypt(file.contents, key),
}))
)
return files
}
}
export const Adapters = {
Text: new CryptTextAdapter(),
Blob: new CryptBlobAdapter(),
Files: new CryptFilesAdapter(),
}

View File

@@ -1,78 +0,0 @@
export type NoteMeta = { type: 'text' | 'file' }
export type Note = {
contents: string
meta: NoteMeta
views?: number
expiration?: number
}
export type NoteInfo = {}
export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: Blob
}
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
contents: string
}
type CallOptions = {
url: string
method: string
body?: any
}
export class PayloadToLargeError extends Error {}
export async function call(options: CallOptions) {
const response = await fetch('/api/' + options.url, {
method: options.method,
body: options.body === undefined ? undefined : JSON.stringify(options.body),
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
if (response.status === 413) throw new PayloadToLargeError()
else throw new Error('API call failed')
}
return response.json()
}
export async function create(note: Note) {
const { meta, ...rest } = note
const body: NoteCreate = {
...rest,
meta: JSON.stringify(meta),
}
const data = await call({
url: 'notes/',
method: 'post',
body,
})
return data as { id: string }
}
export async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
})
const { contents, meta } = data
return {
contents,
meta: JSON.parse(meta) as NoteMeta,
}
}
export async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
})
return data
}

View File

@@ -1,97 +0,0 @@
export class Hex {
static encode(buffer: ArrayBuffer): string {
let s = ''
for (const i of new Uint8Array(buffer)) {
s += i.toString(16).padStart(2, '0')
}
return s
}
static decode(s: string): ArrayBuffer {
const size = s.length / 2
const buffer = new Uint8Array(size)
for (let i = 0; i < size; i++) {
const idx = i * 2
const segment = s.slice(idx, idx + 2)
buffer[i] = parseInt(segment, 16)
}
return buffer
}
}
export class ArrayBufferUtils {
static async toString(buffer: ArrayBuffer): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(new Blob([buffer]))
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
static async fromString(s: string): Promise<ArrayBuffer> {
return fetch(s)
.then((r) => r.blob())
.then((b) => b.arrayBuffer())
}
}
export class Crypto {
private static ALG = 'AES-GCM'
private static DELIMITER = ':::'
public static getRandomBytes(size: number): Uint8Array {
return window.crypto.getRandomValues(new Uint8Array(size))
}
public static getKeyFromString(password: string) {
return window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
}
public static async getDerivedForKey(key: CryptoKey, salt: ArrayBuffer) {
const iterations = 100_000
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-512',
},
key,
{ name: this.ALG, length: 256 },
true,
['encrypt', 'decrypt']
)
}
public static async encrypt(plaintext: ArrayBuffer, key: CryptoKey): Promise<string> {
const salt = this.getRandomBytes(16)
const derived = await this.getDerivedForKey(key, salt)
const iv = this.getRandomBytes(16)
const encrypted: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: this.ALG, iv },
derived,
plaintext
)
const data = [
Hex.encode(salt),
Hex.encode(iv),
await ArrayBufferUtils.toString(encrypted),
].join(this.DELIMITER)
return data
}
public static async decrypt(ciphertext: string, key: CryptoKey): Promise<ArrayBuffer> {
const splitted = ciphertext.split(this.DELIMITER)
const salt = Hex.decode(splitted[0])
const iv = Hex.decode(splitted[1])
const encrypted = await ArrayBufferUtils.fromString(splitted[2])
const derived = await this.getDerivedForKey(key, salt)
const plaintext = await window.crypto.subtle.decrypt({ name: this.ALG, iv }, derived, encrypted)
return plaintext
}
}

View File

@@ -1,22 +0,0 @@
import { call } from '$lib/api'
import { writable } from 'svelte/store'
export type Status = {
version: string
max_size: number
max_views: number
max_expiration: number
allow_advanced: boolean
theme_image: string
theme_text: string
}
export const status = writable<null | Status>(null)
export async function init() {
const data = await call({
url: 'status/',
method: 'get',
})
status.set(data)
}

View File

@@ -1,47 +0,0 @@
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import type { Note } from '$lib/api'
import { status } from '$lib/stores/status'
import Switch from '$lib/ui/Switch.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
export let note: Note
export let timeExpiration = false
</script>
<div class="fields">
<TextInput
type="number"
label={$t('common.views', { values: { n: 0 } })}
bind:value={note.views}
disabled={timeExpiration}
max={$status?.max_views}
validate={(v) =>
($status && v < $status?.max_views) ||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
/>
<div class="middle-switch">
<Switch label={$t('common.mode')} bind:value={timeExpiration} color={false} />
</div>
<TextInput
type="number"
label={$t('common.minutes', { values: { n: 0 } })}
bind:value={note.expiration}
disabled={!timeExpiration}
max={$status?.max_expiration}
validate={(v) =>
($status && v < $status?.max_expiration) ||
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
/>
</div>
<style>
.middle-switch {
margin: 0 1rem;
}
.fields {
display: flex;
}
</style>

View File

@@ -1,12 +0,0 @@
import adapter from '@sveltejs/adapter-static'
import preprocess from 'svelte-preprocess'
export default {
preprocess: preprocess(),
kit: {
adapter: adapter({
fallback: 'index.html',
}),
},
}

View File

@@ -1,13 +1,21 @@
{
"packageManager": "pnpm@8.6.3",
"scripts": {
"dev:docker": "docker-compose up redis",
"dev:backend": "cd backend && cargo watch -x 'run --bin cryptgeon'",
"dev:front": "pnpm --prefix frontend run dev",
"dev:proxy": "node proxy.mjs",
"dev": "run-p dev:*"
"dev:docker": "docker-compose -f docker-compose.dev.yaml up redis",
"dev:packages": "pnpm --parallel run dev",
"dev": "run-p 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",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
},
"devDependencies": {
"http-proxy": "^1.18.1",
"npm-run-all": "^4.1.5"
"@playwright/test": "^1.33.0",
"@types/node": "^20.1.3",
"npm-run-all": "^4.1.5",
"shelljs": "^0.8.5"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "cryptgeon"
version = "2.0.1"
version = "2.3.1"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021"
@@ -8,8 +8,6 @@ edition = "2021"
name = "cryptgeon"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
actix-web = "4"
actix-files = "0.6"
@@ -22,4 +20,5 @@ byte-unit = "4"
dotenv = "0.15"
mime = "0.3"
env_logger = "0.9"
redis = "0.21.5"
log = "0.4"
redis = "0.23"

View File

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

View File

@@ -1,5 +1,6 @@
use actix_web::web;
use crate::health;
use crate::note;
use crate::status;
@@ -7,6 +8,7 @@ pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api")
.service(note::init())
.service(status::init()),
.service(status::init())
.service(health::init()),
);
}

View File

@@ -1,14 +1,17 @@
use actix_files::{Files, NamedFile};
use actix_web::{web, Result};
use crate::config;
pub fn init(cfg: &mut web::ServiceConfig) {
cfg.service(
Files::new("/", "./frontend/build")
Files::new("/", config::FRONTEND_PATH.to_string())
.index_file("index.html")
.use_etag(true),
);
}
pub async fn index() -> Result<NamedFile> {
Ok(NamedFile::open("./frontend/build/index.html")?)
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
Ok(NamedFile::open(index)?)
}

View File

@@ -1,10 +1,15 @@
use byte_unit::Byte;
// General
// Internal
lazy_static! {
pub static ref VERSION: String = option_env!("CARGO_PKG_VERSION")
.unwrap_or("Unknown")
.to_string();
pub static ref FRONTEND_PATH: String =
std::env::var("FRONTEND_PATH").unwrap_or("../frontend/build".to_string());
pub static ref LISTEN_ADDR: String =
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:8000".to_string());
pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string());
}
// CONFIG
@@ -25,6 +30,10 @@ lazy_static! {
.unwrap_or("true".to_string())
.parse()
.unwrap();
pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string())
.parse()
.unwrap();
}
// THEME
@@ -37,4 +46,12 @@ lazy_static! {
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref THEME_PAGE_TITLE: String = std::env::var("THEME_PAGE_TITLE")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref THEME_FAVICON: String = std::env::var("THEME_FAVICON")
.unwrap_or("".to_string())
.parse()
.unwrap();
}

View File

@@ -0,0 +1,3 @@
mod routes;
pub use routes::*;

View File

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

View File

@@ -1,8 +1,10 @@
use actix_web::{
middleware::{self, Logger},
web, App, HttpServer,
web::{self},
App, HttpServer,
};
use dotenv::dotenv;
use log::error;
#[macro_use]
extern crate lazy_static;
@@ -10,6 +12,7 @@ extern crate lazy_static;
mod api;
mod client;
mod config;
mod health;
mod note;
mod size;
mod status;
@@ -18,10 +21,16 @@ mod store;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or("warning"));
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
if !store::can_reach_redis() {
error!("cannot reach redis");
panic!("canont reach redis");
}
return HttpServer::new(|| {
App::new()
.wrap(Logger::new("%a \"%r\" %s %b %T"))
.wrap(Logger::new("\"%r\" %s %b %T"))
.wrap(middleware::Compress::default())
.wrap(middleware::DefaultHeaders::default())
.configure(size::init)
@@ -29,7 +38,7 @@ async fn main() -> std::io::Result<()> {
.configure(client::init)
.default_service(web::to(client::index))
})
.bind("0.0.0.0:5000")?
.bind(config::LISTEN_ADDR.to_string())?
.run()
.await;
}

View File

@@ -2,6 +2,8 @@ use bs62;
use ring::rand::SecureRandom;
use serde::{Deserialize, Serialize};
use crate::config;
#[derive(Serialize, Deserialize, Clone)]
pub struct Note {
pub meta: String,
@@ -11,7 +13,9 @@ pub struct Note {
}
#[derive(Serialize, Deserialize, Clone)]
pub struct NoteInfo {}
pub struct NoteInfo {
pub meta: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct NotePublic {
@@ -20,8 +24,13 @@ pub struct NotePublic {
}
pub fn generate_id() -> String {
let mut id: [u8; 32] = [0; 32];
let mut result = "".to_owned();
let mut id: [u8; 1] = [0; 1];
let sr = ring::rand::SystemRandom::new();
let _ = sr.fill(&mut id);
return bs62::encode_data(&id);
for _ in 0..*config::ID_LENGTH {
let _ = sr.fill(&mut id);
result.push_str(&bs62::encode_data(&id));
}
return result;
}

View File

@@ -24,7 +24,7 @@ async fn one(path: web::Path<NotePath>) -> impl Responder {
let note = store::get(&p.id);
match note {
Ok(Some(_)) => HttpResponse::Ok().json(NoteInfo {}),
Ok(Some(n)) => HttpResponse::Ok().json(NoteInfo { meta: n.meta }),
Ok(None) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
}
@@ -49,7 +49,7 @@ async fn create(note: web::Json<Note>) -> impl Responder {
}
match n.views {
Some(v) => {
if v > *config::MAX_VIEWS {
if v > *config::MAX_VIEWS || v < 1 {
return bad_req;
}
n.expiration = None; // views overrides expiration

View File

@@ -7,5 +7,6 @@ pub fn init(cfg: &mut web::ServiceConfig) {
let plain = web::PayloadConfig::default()
.limit(*config::LIMIT)
.mimetype(mime::STAR_STAR);
// cfg.app_data(plain);
cfg.app_data(json).app_data(plain);
}

View File

@@ -12,4 +12,6 @@ pub struct Status {
// Theme
pub theme_image: String,
pub theme_text: String,
pub theme_page_title: String,
pub theme_favicon: String,
}

View File

@@ -13,6 +13,8 @@ async fn get_status() -> impl Responder {
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()
});
}

View File

@@ -19,6 +19,14 @@ fn get_connection() -> Result<redis::Connection, &'static str> {
.map_err(|_| "Unable to connect to redis")
}
pub fn can_reach_redis() -> bool {
let conn = get_connection();
return match conn {
Ok(_) => true,
Err(_) => false,
};
}
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
let serialized = serde_json::to_string(&note.clone()).unwrap();
let mut conn = get_connection()?;

54
packages/cli/README.md Normal file
View File

@@ -0,0 +1,54 @@
# Cryptgeon CLI
The CLI is a functionally identical way to interact with cryptgeon notes.
It supports text, files, expiration, password, etc.
## Installation
```bash
npx cryptgeon
# Or install globally
npm -g install cryptgeon
cryptgeon
```
## Examples
```bash
# Create simple note
cryptgeon send text "Foo bar"
# Send two files
cryptgeon send file my.pdf picture.png
# 3 views
cryptgeon send text "My message" --views 3
# 10 minutes
cryptgeon send text "My message" --minutes 10
# Custom password
cryptgeon send text "My message" --password "1337"
# Password from stdin
echo "1337" | cryptgeon send text "My message"
# Open a link
cryptgeon open https://cryptgeon.org/note/16gOIkxWjCxYNuXM8tCqMUzl...
```
## Options
### Custom server
The default server is `cryptgeon.org`, however you can use any cryptgeon server by passing the `-s` or `--server` option, or by setting the `CRYPTGEON_SERVER` environment variable.
### Password
Optionally, just like in the web ui, you can choose to use a manual password. You can do that by passing the `-p` or `--password` options, or by piping it into stdin.
```bash
echo "my pw" | cryptgeon send text "my text"
cat pass.txt | cryptgeon send text "my text"
```

43
packages/cli/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"version": "2.3.1",
"name": "cryptgeon",
"repository": {
"type": "git",
"url": "https://github.com/cupcakearmy/cryptgeon.git",
"directory": "packages/cli"
},
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"type": "module",
"engines": {
"node": ">=18"
},
"scripts": {
"dev": "./scripts/build.js --watch",
"build": "./scripts/build.js",
"package": "./scripts/package.js",
"bin": "run-s build package",
"prepublishOnly": "run-s build"
},
"main": "./dist/index.cjs",
"bin": {
"cryptgeon": "./dist/index.cjs"
},
"files": [
"dist"
],
"devDependencies": {
"@commander-js/extra-typings": "^10.0.3",
"@cryptgeon/shared": "workspace:*",
"@types/inquirer": "^9.0.3",
"@types/mime": "^3.0.1",
"@types/node": "^20.1.3",
"commander": "^10.0.1",
"esbuild": "^0.17.19",
"inquirer": "^9.2.2",
"mime": "^3.0.0",
"occulto": "^2.0.1",
"pkg": "^5.8.1",
"pretty-bytes": "^6.1.0",
"typescript": "^5.0.4"
}
}

17
packages/cli/scripts/build.js Executable file
View File

@@ -0,0 +1,17 @@
#!/usr/bin/env node
import { build, context } from 'esbuild'
import pkg from '../package.json' assert { type: 'json' }
const options = {
entryPoints: ['./src/index.ts'],
bundle: true,
minify: true,
platform: 'node',
outfile: './dist/index.cjs',
define: { VERSION: `"${pkg.version}"` },
}
const watch = process.argv.slice(2)[0] === '--watch'
if (watch) (await context(options)).watch()
else await build(options)

17
packages/cli/scripts/package.js Executable file
View File

@@ -0,0 +1,17 @@
#!/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/index.cjs', '--target', target, '--output', `./bin/${target.replace('node18', 'cryptgeon')}`])
}

View File

@@ -0,0 +1,86 @@
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'
import { exit } from './utils'
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(() => exit('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 = () => exit('Could not decrypt note. Probably an invalid password')
switch (note.meta.type) {
case 'file':
const files = await Adapters.Files.decrypt(note.contents, key).catch(couldNotDecrypt)
if (!files) {
exit('No files found in note')
return
}
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) exit('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(couldNotDecrypt)
console.log(plaintext)
break
}
}

104
packages/cli/src/index.ts Normal file
View File

@@ -0,0 +1,104 @@
#!/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 { exit } from './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)
const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile)
const text = new Argument('<text>', 'Text content of the note')
const password = new Option('-p --password <string>', 'manually set a password')
const all = new Option('-a --all', 'Save all files without prompt').default(false)
const url = new Argument('<url>', 'The url to open')
const views = new Option('-v --views <number>', 'Amount of views before getting destroyed').argParser(parseNumber)
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')
// @ts-ignore
const version: string = VERSION
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
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.`)
}
program.name('cryptgeon').version(version).configureHelp({ showGlobalOptions: true })
program
.command('info')
.description('show information about the server')
.addOption(server)
.action(async (options) => {
setBase(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]
}
console.table(formatted)
})
const send = program.command('send').description('send a note')
send
.command('file')
.addArgument(files)
.addOption(server)
.addOption(views)
.addOption(minutes)
.addOption(password)
.action(async (files, options) => {
setBase(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
})
send
.command('text')
.addArgument(text)
.addOption(server)
.addOption(views)
.addOption(minutes)
.addOption(password)
.action(async (text, options) => {
setBase(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
})
program
.command('open')
.description('open a link with text or files inside')
.addArgument(url)
.addOption(password)
.addOption(all)
.action(async (note, options) => {
try {
const url = new URL(note)
options.password ||= await getStdin()
await download(url, options.all, options.password)
} catch {
exit('Invalid URL')
}
})
program.parse()

View File

@@ -0,0 +1,27 @@
import { InvalidArgumentError, InvalidOptionArgumentError } from '@commander-js/extra-typings'
import { accessSync, constants } from 'node:fs'
import { resolve } from 'node:path'
export function parseFile(value: string, before: string[] = []) {
try {
const file = resolve(value)
accessSync(file, constants.R_OK)
return [...before, file]
} catch {
throw new InvalidArgumentError('cannot access file')
}
}
export function parseURL(value: string, _: URL): URL {
try {
return new URL(value)
} catch {
throw new InvalidArgumentError('is not a valid url')
}
}
export function parseNumber(value: string, _: number): number {
const n = parseInt(value, 10)
if (isNaN(n)) throw new InvalidOptionArgumentError('invalid number')
return n
}

24
packages/cli/src/stdin.ts Normal file
View File

@@ -0,0 +1,24 @@
export function getStdin(timeout: number = 10): Promise<string> {
return new Promise<string>((resolve, reject) => {
// Store the data from stdin in a buffer
let buffer = ''
let t: NodeJS.Timeout
const dataHandler = (d: Buffer) => (buffer += d.toString())
const endHandler = () => {
clearTimeout(t)
resolve(buffer.trim())
}
// Stop listening for data after the timeout, otherwise hangs indefinitely
t = setTimeout(() => {
process.stdin.removeListener('data', dataHandler)
process.stdin.removeListener('end', endHandler)
process.stdin.pause()
resolve('')
}, timeout)
process.stdin.on('data', dataHandler)
process.stdin.on('end', endHandler)
})
}

View File

@@ -0,0 +1,51 @@
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, TypedArray } from 'occulto'
import { exit } from './utils.js'
type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
export async function upload(input: string | string[], options: UploadOptions) {
try {
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)}`
console.log(`Note created:\n\n${url}`)
} catch {
exit('Could not create note')
}
}

View File

@@ -0,0 +1,6 @@
import { exit as exitNode } from 'node:process'
export function exit(message: string) {
console.error(message)
exitNode(1)
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "node",
"noEmit": true,
"strict": true,
"allowSyntheticDefaultImports": true
}
}

View File

@@ -1,8 +1,8 @@
├─ MIT: 12
├─ MIT: 13
├─ ISC: 2
├─ BSD-3-Clause: 1
├─ (MPL-2.0 OR Apache-2.0): 1
├─ BSD-2-Clause: 1
├─ ISC: 1
├─ 0BSD: 1
└─ Apache-2.0: 1
1 ├─ MIT: 12 ├─ MIT: 13
2 ├─ ISC: 2
3 ├─ BSD-3-Clause: 1 ├─ BSD-3-Clause: 1
4 ├─ (MPL-2.0 OR Apache-2.0): 1 ├─ (MPL-2.0 OR Apache-2.0): 1
5 ├─ BSD-2-Clause: 1 ├─ BSD-2-Clause: 1
├─ ISC: 1
6 ├─ 0BSD: 1 ├─ 0BSD: 1
7 └─ Apache-2.0: 1 └─ Apache-2.0: 1
8

View File

@@ -11,10 +11,13 @@
"max": "max",
"share_link": "Link teilen",
"copy_clipboard": "in die Zwischenablage kopieren",
"copied_to_clipboard": "in die Zwischenablage kopiert",
"encrypting": "verschlüsseln",
"decrypting": "entschlüsselt",
"uploading": "hochladen",
"downloading": "wird heruntergeladen"
"downloading": "wird heruntergeladen",
"qr_code": "qr-code",
"password": "Passwort"
},
"home": {
"intro": "Senden Sie ganz einfach <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien mit einem Klick. Erstellen Sie einfach eine Notiz und teilen Sie den Link.",
@@ -27,7 +30,13 @@
"max": "max: {n}",
"empty_content": "Notiz ist leer."
},
"copied_to_clipboard": "in die Zwischenablage kopiert 🔗"
"messages": {
"note_created": "notiz erstellt."
},
"advanced": {
"explanation": "Standardmäßig wird für jede Notiz ein sicher generiertes Passwort verwendet. Sie können jedoch auch ein eigenes Kennwort wählen, das nicht in dem Link enthalten ist.",
"custom_password": "benutzerdefiniertes Passwort"
}
},
"show": {
"errors": {

View File

@@ -11,10 +11,13 @@
"max": "max",
"share_link": "share link",
"copy_clipboard": "copy to clipboard",
"copied_to_clipboard": "copied to clipboard",
"encrypting": "encrypting",
"decrypting": "decrypting",
"uploading": "uploading",
"downloading": "downloading"
"downloading": "downloading",
"qr_code": "qr code",
"password": "password"
},
"home": {
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
@@ -27,7 +30,13 @@
"max": "max: {n}",
"empty_content": "note is empty."
},
"copied_to_clipboard": "copied to clipboard 🔗"
"messages": {
"note_created": "note created."
},
"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"
}
},
"show": {
"errors": {

View File

@@ -11,10 +11,13 @@
"max": "max",
"share_link": "compartir enlace",
"copy_clipboard": "copiar al portapapeles",
"copied_to_clipboard": "copiado al portapapeles",
"encrypting": "encriptando",
"decrypting": "descifrando",
"uploading": "cargando",
"downloading": "descargando"
"downloading": "descargando",
"qr_code": "código qr",
"password": "contraseña"
},
"home": {
"intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.",
@@ -27,7 +30,13 @@
"max": "max: {n}",
"empty_content": "la nota está vacía."
},
"copied_to_clipboard": "copiado al portapapeles 🔗"
"messages": {
"note_created": "nota creada."
},
"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, que no se incluye en el enlace.",
"custom_password": "contraseña personalizada"
}
},
"show": {
"errors": {
@@ -37,7 +46,7 @@
},
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
"show_note": "mostrar nota",
"warning_will_not_see_again": " <b>no</b> tendrás la oportunidad de volver a ver la nota.",
"warning_will_not_see_again": "<b>no</b> tendrás la oportunidad de volver a ver la nota.",
"download_all": "descargar todo"
},
"file_upload": {

View File

@@ -11,10 +11,13 @@
"max": "max",
"share_link": "partager le lien",
"copy_clipboard": "copier dans le presse-papiers",
"copied_to_clipboard": "copié dans le presse-papiers",
"encrypting": "cryptage",
"decrypting": "déchiffrer",
"uploading": "téléchargement",
"downloading": "téléchargement"
"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.",
@@ -27,7 +30,13 @@
"max": "max: {n}",
"empty_content": "La note est vide."
},
"copied_to_clipboard": "copié dans le presse-papiers 🔗"
"messages": {
"note_created": "note créée."
},
"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é"
}
},
"show": {
"errors": {

View File

@@ -11,10 +11,13 @@
"max": "max",
"share_link": "condividi link",
"copy_clipboard": "copia negli appunti",
"copied_to_clipboard": "copiato negli appunti",
"encrypting": "criptando",
"decrypting": "decifrando",
"uploading": "caricamento",
"downloading": "scaricando"
"downloading": "scaricando",
"qr_code": "codice qr",
"password": "password"
},
"home": {
"intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.",
@@ -27,7 +30,13 @@
"max": "max: {n}",
"empty_content": "la nota è vuota."
},
"copied_to_clipboard": "copiato negli appunti 🔗"
"messages": {
"note_created": "nota creata."
},
"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"
}
},
"show": {
"errors": {
@@ -37,7 +46,7 @@
},
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
"show_note": "mostra la nota",
"warning_will_not_see_again": " <b>non</b> avrete la possibilità di rivedere la nota.",
"warning_will_not_see_again": "<b>non</b> avrete la possibilità di rivedere la nota.",
"download_all": "scarica tutti"
},
"file_upload": {

View File

@@ -0,0 +1,56 @@
{
"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 に保持されるため、メモが保存されるとは限りません。いっぱいになると、最も古いメモが削除されます。 <br /> (大丈夫だと思いますが、ご了承ください。)",
"errors": {
"note_to_big": "メモを作成できませんでした。メモが大きすぎる",
"note_error": "メモを作成できませんでした。もう一度お試しください。",
"max": "最大ファイルサイズ: {n}",
"empty_content": "メモは空です。"
},
"messages": {
"note_created": "メモが作成されました。"
},
"advanced": {
"explanation": "デフォルトでは、安全に生成されたパスワードが各ノートに使用されます。しかし、リンクに含まれない独自のパスワードを選択することもできます。",
"custom_password": "カスタムパスワード"
}
},
"show": {
"errors": {
"not_found": "メモが見つからないか、既に削除されています。",
"decryption_failed": "パスワードエラー!不完全なリンクを貼り付けたり、暗号を解読しようとしたりしている可能性があります!しかし、いずれにしても、この暗号は破棄されました!",
"unsupported_type": "サポートされていないメモ タイプです。"
},
"explanation": "カウンターが上限に達した場合、ノートの表示と削除を行うには、以下をクリックします。",
"show_note": "メモを表示",
"warning_will_not_see_again": "あなた <b>できません</b> このノートをもう一度見る",
"download_all": "すべてダウンロード"
},
"file_upload": {
"selected_files": "選択したファイル",
"no_files_selected": "ファイルが選択されていません"
}
}

View File

@@ -0,0 +1,56 @@
{
"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>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": "нельзя создать новую заметку. заметка слишком большая",
"note_error": "нельзя создать новую заметку. пожалйста попробуйте позднее.",
"max": "макс: {n}",
"empty_content": "пустая заметка."
},
"messages": {
"note_created": "заметка создана."
},
"advanced": {
"explanation": "По умолчанию для каждой заметки используется безопасно сгенерированный пароль. Однако вы также можете выбрать свой собственный пароль, который не включен в ссылку.",
"custom_password": "пользовательский пароль"
}
},
"show": {
"errors": {
"not_found": "заметка не найдена или была удалена.",
"decryption_failed": "неправильный пароль. не смог расшифровать. возможно ссылка битая. записка уничтожена.",
"unsupported_type": "неподдерживаемый тип заметки."
},
"explanation": "щелкните ниже, чтобы показать и удалить примечание, если счетчик достиг предела",
"show_note": "показать заметку",
"warning_will_not_see_again": "вы <b>не сможете</b> больше просмотреть заметку.",
"download_all": "скачать всё"
},
"file_upload": {
"selected_files": "Выбранные файлы",
"no_files_selected": "Файлы не выбраны"
}
}

View File

@@ -0,0 +1,56 @@
{
"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": "二维码",
"password": "密码"
},
"home": {
"intro": "飞鸽传书,一键传输完全加密的密信或文件,阅后即焚。",
"explanation": "该密信会在{type}后失效。",
"new_note": "新建密信",
"new_note_notice": "<b>提醒:</b><br>密信保存在内存中,如果内存满了,则最早的密信将被删除以释放内存,因此不保证该密信的可用性。一般不会出现这种情况,无需担心。",
"errors": {
"note_to_big": "创建失败,密信过大。",
"note_error": "创建失败,请稍后重试。",
"max": "次数上限:{n}",
"empty_content": "密信不能为空。"
},
"messages": {
"note_created": "密信创建成功。"
},
"advanced": {
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
"custom_password": "自定义密码"
}
},
"show": {
"errors": {
"not_found": "密信不存在,可能已被查看或删除。",
"decryption_failed": "密钥错误,无法查看。",
"unsupported_type": "不支持的类型。"
},
"explanation": "点击下方按钮即可查看密信,阅后即焚。",
"show_note": "查看密信",
"warning_will_not_see_again": "你将<b>无法</b>再次查看该密信,请尽快复制到粘贴板。",
"download_all": "下载全部"
},
"file_upload": {
"selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中"
}
}

View File

@@ -0,0 +1,41 @@
{
"private": true,
"name": "@cryptgeon/web",
"scripts": {
"postinstall": "svelte-kit sync",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker --summary > licenses.csv",
"locale:download": "node scripts/locale.js",
"test:prepare": "pnpm run build"
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^9.8.0",
"@sveltejs/adapter-static": "^2.0.2",
"@sveltejs/kit": "^1.16.3",
"@types/dompurify": "^3.0.2",
"@types/file-saver": "^2.0.5",
"@zerodevx/svelte-toast": "^0.9.3",
"adm-zip": "^0.5.10",
"dotenv": "^16.0.3",
"svelte": "^3.59.1",
"svelte-check": "^3.3.2",
"svelte-intl-precompile": "^0.12.1",
"tslib": "^2.5.0",
"typescript": "^5.0.4",
"vite": "^4.3.5"
},
"dependencies": {
"@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^4.5.10",
"copy-to-clipboard": "^3.3.3",
"dompurify": "^3.0.3",
"file-saver": "^2.0.5",
"occulto": "^2.0.1",
"pretty-bytes": "^6.1.0",
"qrious": "^4.0.2"
}
}

View File

@@ -1,14 +1,19 @@
import dotenv from 'dotenv'
import { LokaliseApi } from '@lokalise/node-api'
import https from 'https'
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) throw new Error('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
if (!project_id) throw new Error('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
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) =>

View File

@@ -14,7 +14,9 @@
--ui-text-0: #fefefe;
--ui-text-1: #eee;
--ui-clr-primary: hsl(186, 65%, 55%);
--ui-clr-primary-alt: hsl(186, 85%, 35%);
--ui-clr-error: hsl(357, 77%, 51%);
--ui-clr-error-alt: hsl(357, 87%, 41%);
--ui-anim: all 150ms ease;
}
@@ -85,10 +87,12 @@ button {
font-size: inherit;
background: inherit;
color: inherit;
border: none;
padding-inline: initial;
}
*:disabled,
*[disabled='true'] {
.disabled {
opacity: 0.5;
}
@@ -122,3 +126,13 @@ fieldset {
.tr {
text-align: right;
}
hr {
border: none;
border-bottom: 2px solid var(--ui-bg-1);
margin: 1rem 0;
}
p {
margin: 0;
}

9
packages/frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}

View File

@@ -2,7 +2,6 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%

View File

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 325 B

View File

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 736 B

View File

Before

Width:  |  Height:  |  Size: 483 B

After

Width:  |  Height:  |  Size: 483 B

View File

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 732 B

View File

@@ -0,0 +1,8 @@
import { status as getStatus, type Status } from '@cryptgeon/shared'
import { writable } from 'svelte/store'
export const status = writable<null | Status>(null)
export async function init() {
status.set(await getStatus())
}

View File

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

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { t } from 'svelte-intl-precompile'
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
let hasCustomPassword = false
$: if (!hasCustomPassword) customPassword = null
</script>
<div class="flex col">
<div class="flex">
<TextInput
data-testid="field-views"
type="number"
label={$t('common.views', { values: { n: 0 } })}
bind:value={note.views}
disabled={timeExpiration}
max={$status?.max_views}
min={1}
validate={(v) =>
($status && v <= $status?.max_views && v > 0) ||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
/>
<Switch
data-testid="switch-advanced-toggle"
label={$t('common.mode')}
bind:value={timeExpiration}
color={false}
/>
<TextInput
data-testid="field-expiration"
type="number"
label={$t('common.minutes', { values: { n: 0 } })}
bind:value={note.expiration}
disabled={!timeExpiration}
max={$status?.max_expiration}
validate={(v) =>
($status && v < $status?.max_expiration) ||
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
/>
</div>
<div class="flex">
<Switch
data-testid="custom-password"
bind:value={hasCustomPassword}
label={$t('home.advanced.custom_password')}
/>
<TextInput
data-testid="password"
type="password"
bind:value={customPassword}
label={$t('common.password')}
disabled={!hasCustomPassword}
random
/>
</div>
<div>
{$t('home.advanced.explanation')}
</div>
</div>
<style>
.flex {
display: flex;
align-items: flex-end;
gap: 1rem;
width: 100%;
}
.col {
gap: 1.5rem;
flex-direction: column;
}
</style>

View File

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

View File

@@ -1,26 +1,27 @@
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import type { FileDTO } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from '@cryptgeon/shared'
export let label: string = ''
export let files: FileDTO[] = []
function fileToDTO(file: File): FileDTO {
async function fileToDTO(file: File): Promise<FileDTO> {
return {
name: file.name,
size: file.size,
type: file.type,
contents: file,
contents: new Uint8Array(await file.arrayBuffer()),
}
}
async function onInput(e: Event) {
const input = e.target as HTMLInputElement
if (input?.files?.length) {
files = [...files, ...Array.from(input.files).map(fileToDTO)]
const toAdd = await Promise.all(Array.from(input.files).map(fileToDTO))
files = [...files, ...toAdd]
}
}
@@ -34,7 +35,7 @@
<small>
{label}
</small>
<input type="file" on:change={onInput} multiple />
<input {...$$restProps} type="file" on:change={onInput} multiple />
<div class="box">
{#if files.length}
<div>

View File

Before

Width:  |  Height:  |  Size: 784 B

After

Width:  |  Height:  |  Size: 784 B

View File

@@ -1,7 +1,7 @@
<script lang="ts" context="module">
export type NoteResult = {
password: string
id: string
password?: string
}
</script>
@@ -10,9 +10,13 @@
import Button from '$lib/ui/Button.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import Canvas from './Canvas.svelte'
export let result: NoteResult
let url = `${window.location.origin}/note/${result.id}`
if (result.password) url += `#${result.password}`
function reset() {
window.location.reload()
}
@@ -22,10 +26,15 @@
type="text"
readonly
label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}"
value={url}
copy
data-testid="share-link"
/>
<br />
<div>
<Canvas value={url} />
</div>
<p>
{@html $t('home.new_note_notice')}
</p>
@@ -33,4 +42,9 @@
<Button on:click={reset}>{$t('home.new_note')}</Button>
<style>
div {
width: min(12rem, 100%);
margin-top: 1rem;
margin-bottom: 1rem;
}
</style>

View File

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

View File

@@ -4,43 +4,35 @@
export let color = true
</script>
<div {...$$restProps}>
<label class="switch">
<small>{label}</small>
<input type="checkbox" bind:checked={value} />
<span class:color class="slider" />
</label>
</div>
<label {...$$restProps}>
<small>{label}</small>
<input type="checkbox" bind:checked={value} />
<span class:color class="slider" />
</label>
<style>
div {
height: 3.75rem;
}
.switch {
label {
position: relative;
display: inline-block;
width: 4rem;
height: 2.5rem;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
label input {
display: none;
}
small {
display: block;
width: max-content;
}
.slider {
position: absolute;
display: block;
width: 4rem;
height: 2.5rem;
position: relative;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px solid var(--ui-bg-1);
background-color: var(--ui-bg-0);
transition: var(--ui-anim);
transform: translateY(1.2rem);
}
.slider:before {

View File

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

Some files were not shown because too many files have changed in this diff Show More