Compare commits

..

5 Commits

Author SHA1 Message Date
be0d523d90 actions maintenance 2023-04-27 09:36:33 +02:00
5817fd19b1 update deps 2023-04-27 09:36:21 +02:00
5fb6f65a13 update deps 2023-04-27 09:36:14 +02:00
420370acaf Merge remote-tracking branch 'origin/main' into CLI 2023-04-26 18:10:07 +02:00
4c25ca005e move to packages 2023-04-26 18:06:09 +02:00
83 changed files with 1692 additions and 3394 deletions

View File

@@ -7,27 +7,6 @@ on:
- 'v*.*.*' - 'v*.*.*'
jobs: 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: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -10,29 +10,25 @@ jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v2
# Node
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
cache: 'pnpm'
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
# Docker
- uses: docker/setup-qemu-action@v2 - uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2 - uses: docker/setup-buildx-action@v2
with: with:
install: true install: true
- name: Build docker image
run: npm run test:prepare
- name: Prepare - name: Prepare
run: | run: |
pnpm install --frozen-lockfile npm install playwright
pnpm exec playwright install --with-deps npx playwright install --with-deps
pnpm run test:prepare
- name: Run your tests - name: Run your tests
run: pnpm test run: npm test
- uses: actions/upload-artifact@v3 - uses: actions/upload-artifact@v3
with: with:
name: test-results name: test-results

2
.nvmrc
View File

@@ -1 +1 @@
v18.17.1 v18.16

View File

@@ -5,23 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.3.1] - 2023-06-23 ## [2.2.0] - 2023-01-14
### 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 ### Changed

View File

@@ -1,693 +0,0 @@
{
"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,19 +1,20 @@
# FRONTEND # FRONTEND
FROM node:18-alpine as client FROM node:16-alpine as client
WORKDIR /tmp WORKDIR /tmp
RUN npm install -g pnpm@8 RUN npm install -g pnpm@8
COPY . . COPY . .
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
# WORKDIR /tmp/packages/frontend
# RUN pnpm exec svelte-kit sync
RUN pnpm run build RUN pnpm run build
# BACKEND # BACKEND
FROM rust:1.71-alpine as backend FROM rust:1.69-alpine as backend
WORKDIR /tmp WORKDIR /tmp
RUN apk add libc-dev openssl-dev alpine-sdk RUN apk add libc-dev openssl-dev alpine-sdk
# COPY ./packages/backend/Cargo.* ./ COPY ./packages/backend/Cargo.* ./
# ENV CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse RUN cargo fetch
# RUN cargo fetch
COPY ./packages/backend ./ COPY ./packages/backend ./
RUN cargo build --release RUN cargo build --release
@@ -21,7 +22,6 @@ RUN cargo build --release
# RUNNER # RUNNER
FROM alpine FROM alpine
WORKDIR /app WORKDIR /app
RUN apk add --no-cache curl
COPY --from=backend /tmp/target/release/cryptgeon . COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/packages/frontend/build ./frontend COPY --from=client /tmp/packages/frontend/build ./frontend
ENV FRONTEND_PATH="./frontend" ENV FRONTEND_PATH="./frontend"

View File

@@ -14,12 +14,11 @@
<a href=""><img src="./.github/lokalise.png" height="50"> <a href=""><img src="./.github/lokalise.png" height="50">
<br/><br/> <br/><br/>
EN | [简体中文](README_zh-CN.md) | [ES](README_ES.md) EN | [简体中文](README_zh-CN.md)
## About? ## 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. > 🌍 If you want to translate the project feel free to reach out to me.
> >
@@ -27,21 +26,10 @@ It includes a server, a web page and a CLI client.
## Live Service / Demo ## Live Service / Demo
### Web
Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org) 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 ## Features
- send text or files
- server cannot decrypt contents due to client side encryption - server cannot decrypt contents due to client side encryption
- view or time constraints - view or time constraints
- in memory, no persistence - in memory, no persistence
@@ -69,7 +57,6 @@ of the notes even if it tried to.
| `MAX_VIEWS` | `100` | Maximal number of views. | | `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. | | `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. | | `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `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` | | `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable | | `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo | | `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
@@ -78,9 +65,7 @@ of the notes even if it tried to.
## Deployment ## 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 ### Docker
@@ -96,7 +81,7 @@ services:
image: redis:7-alpine image: redis:7-alpine
# Set a size limit. See link below on how to customise. # Set a size limit. See link below on how to customise.
# https://redis.io/docs/manual/eviction/ # https://redis.io/docs/manual/eviction/
# command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru command: redis-server --maxmemory 1gb --maxmemory-policy allkeys-lru
app: app:
image: cupcakearmy/cryptgeon:latest image: cupcakearmy/cryptgeon:latest
@@ -107,14 +92,6 @@ services:
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
ports: ports:
- 80:8000 - 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 ### NGINX Proxy
@@ -135,7 +112,6 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
### YouTube Guides ### 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) - 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) - German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
@@ -144,13 +120,14 @@ There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-sy
**Requirements** **Requirements**
- `pnpm`: `>=6` - `pnpm`: `>=6`
- `node`: `>=18` - `node`: `>=16`
- `rust`: edition `2021` - `rust`: edition `2021`
**Install** **Install**
```bash ```bash
pnpm install pnpm install
pnpm --prefix frontend install
# Also you need cargo watch if you don't already have it installed. # Also you need cargo watch if you don't already have it installed.
# https://lib.rs/crates/cargo-watch # https://lib.rs/crates/cargo-watch
@@ -170,19 +147,19 @@ Running `pnpm run dev` in the root folder will start the following things:
- redis docker container - redis docker container
- rust backend - rust backend
- client - client
- cli
You can see the app under [localhost:1234](http://localhost:1234). 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
Tests are end to end tests written with Playwright. Tests are end to end tests written with Playwright.
```sh ```sh
pnpm run test:prepare pnpm run test:prepare
docker compose up redis -d
pnpm run test:server
# In another terminal.
# Use the test or test:local script. The local version only runs in one browser for quicker development. # Use the test or test:local script. The local version only runs in one browser for quicker development.
pnpm run test:local pnpm run test:local
``` ```
@@ -191,9 +168,7 @@ pnpm run test:local
Please refer to the security section [here](./SECURITY.md). Please refer to the security section [here](./SECURITY.md).
--- ###### Attributions
_Attributions_
- Test data: - Test data:
- Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/) - Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)

View File

@@ -1,200 +0,0 @@
<p align="center">
<img src="./design/Github.png" alt="logo">
</p>
<a href="https://discord.gg/nuby6RnxZt">
<img alt="discord" src="https://img.shields.io/discord/252403122348097536?style=for-the-badge" />
<img alt="docker pulls" src="https://img.shields.io/docker/pulls/cupcakearmy/cryptgeon?style=for-the-badge" />
<img alt="Docker image size badge" src="https://img.shields.io/docker/image-size/cupcakearmy/cryptgeon?style=for-the-badge" />
<img alt="Latest version" src="https://img.shields.io/github/v/release/cupcakearmy/cryptgeon?style=for-the-badge" />
</a>
<br/><br/>
<a href="https://www.producthunt.com/posts/cryptgeon?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cryptgeon" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=295189&theme=light" alt="Cryptgeon - Securely share self-destructing notes | Product Hunt" height="50" /></a>
<a href=""><img src="./.github/lokalise.png" height="50">
<br/><br/>
[EN](README.md) | [简体中文](README_zh-CN.md) | ES
## Acerca de
_cryptgeon_ es un servicio seguro y de código abierto para compartir notas o archivos inspirado en [_PrivNote_](https://privnote.com).
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
### Web
Prueba la demo y experimenta por ti mismo [cryptgeon.org](https://cryptgeon.org)
### CLI
```
npx cryptgeon send text "Esto es una nota secreta"
```
Puedes revisar la documentación sobre el CLI en este [readme](./packages/cli/README.md).
## Características
- 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
- en memoria, sin persistencia
- compatibilidad obligatoria con el modo oscuro
## ¿Cómo funciona?
Se genera una <code>id (256bit)</code> y una <code>llave 256(bit)</code> para cada nota. La
<code>id</code>
se usa para guardar y recuperar la nota. Después la nota es encriptada con la <code>llave</code> y con aes en modo gcm del lado del cliente y por último se envía al servidor. La información es almacenada en memoria y nunca persiste en el disco. El servidor nunca ve la llave de encriptación por lo que no puede desencriptar el contenido de las notas aunque lo intentara.
## Capturas de pantalla
![screenshot](./design/Screens.png)
## 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_. |
| `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. |
## Despliegue
> Se requiere `https` de lo contrario el navegador no soportará las funciones de encriptacó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).
```yaml
# docker-compose.yml
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
```
### NGINX Proxy
Ver la carpeta de [ejemplo/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/nginx). Hay un ejemplo con un proxy simple y otro con https. Es necesario que especifiques el nombre del servidor y los certificados.
### Traefik 2
Ver la carpeta de [ejemplo/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik).
### Scratch
Ver la carpeta de [ejemplo/scratch](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/scratch). Ahí encontrarás una guía de cómo configurar el servidor e instalar cryptgeon desde cero.
### Synology
Hay una [guía](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) (en inglés) que puedes seguir.
### Guías en Youtube
- En inglés, por [Webnestify](https://www.youtube.com/watch?v=XAyD42I7wyI)
- 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
**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
```
## Seguridad
Por favor dirigite a la sección de seguridad [aquí](./SECURITY.md).
---
_Atribuciones_
- Datos del Test:
- Texto para los 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 Imágenes](https://unsplash.com/)
- Animación de carga por [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa)
- Iconos hechos por <a href="https://www.freepik.com" title="Freepik">freepik</a> de <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>

View File

@@ -14,7 +14,7 @@
<a href=""><img src="./.github/lokalise.png" height="50"> <a href=""><img src="./.github/lokalise.png" height="50">
<br/> <br/>
[EN](README.md) | 简体中文 | [ES](README_ES.md) [EN](README.md) | 简体中文
## 关于本项目 ## 关于本项目

View File

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

View File

@@ -16,10 +16,3 @@ services:
- redis - redis
ports: ports:
- 1234:8000 - 1234:8000
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:8000/api/live/"]
interval: 1m
timeout: 3s
retries: 2
start_period: 5s

View File

@@ -16,11 +16,3 @@ services:
SIZE_LIMIT: 4 MiB SIZE_LIMIT: 4 MiB
ports: ports:
- 80:8000 - 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

@@ -1,21 +1,17 @@
{ {
"packageManager": "pnpm@8.6.3",
"scripts": { "scripts": {
"dev:docker": "docker-compose -f docker-compose.dev.yaml up redis", "dev:docker": "docker-compose -f docker-compose.dev.yaml up redis",
"dev:packages": "pnpm --parallel run dev", "dev:packages": "pnpm --parallel run dev",
"dev": "run-p 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": "playwright test --project chrome firefox safari",
"test:local": "playwright test --project chrome", "test:local": "playwright test --project local",
"test:server": "run-s docker:up", "test:server": "docker compose -f docker-compose.dev.yaml up",
"test:prepare": "run-p build docker:build", "test:prepare": "docker compose -f docker-compose.dev.yaml build",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build" "build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.33.0", "@playwright/test": "^1.32.3",
"@types/node": "^20.1.3", "@types/node": "^18.16.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5"
"shelljs": "^0.8.5"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,18 @@
[package] [package]
name = "cryptgeon" name = "cryptgeon"
version = "2.3.3" version = "2.2.0"
authors = ["cupcakearmy <hi@nicco.io>"] authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021" edition = "2021"
rust-version = "1.71"
[[bin]] [[bin]]
name = "cryptgeon" name = "cryptgeon"
path = "src/main.rs" path = "src/main.rs"
[registries.crates-io]
protocol = "sparse"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
actix-web = "4" actix-web = "4"
actix-files = "0.6" actix-files = "0.6"
@@ -21,5 +25,4 @@ byte-unit = "4"
dotenv = "0.15" dotenv = "0.15"
mime = "0.3" mime = "0.3"
env_logger = "0.9" env_logger = "0.9"
log = "0.4" redis = "0.21.5"
redis = "0.23"

View File

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

View File

@@ -30,10 +30,6 @@ lazy_static! {
.unwrap_or("true".to_string()) .unwrap_or("true".to_string())
.parse() .parse()
.unwrap(); .unwrap();
pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string())
.parse()
.unwrap();
} }
// THEME // THEME

View File

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

View File

@@ -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)
}

View File

@@ -1,10 +1,8 @@
use actix_web::{ use actix_web::{
middleware::{self, Logger}, middleware::{self, Logger},
web::{self}, web, App, HttpServer,
App, HttpServer,
}; };
use dotenv::dotenv; use dotenv::dotenv;
use log::error;
#[macro_use] #[macro_use]
extern crate lazy_static; extern crate lazy_static;
@@ -12,7 +10,6 @@ extern crate lazy_static;
mod api; mod api;
mod client; mod client;
mod config; mod config;
mod health;
mod note; mod note;
mod size; mod size;
mod status; mod status;
@@ -23,11 +20,6 @@ async fn main() -> std::io::Result<()> {
dotenv().ok(); dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str())); 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(|| { return HttpServer::new(|| {
App::new() App::new()
.wrap(Logger::new("\"%r\" %s %b %T")) .wrap(Logger::new("\"%r\" %s %b %T"))

View File

@@ -2,8 +2,6 @@ use bs62;
use ring::rand::SecureRandom; use ring::rand::SecureRandom;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct Note { pub struct Note {
pub meta: String, pub meta: String,
@@ -13,9 +11,7 @@ pub struct Note {
} }
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct NoteInfo { pub struct NoteInfo {}
pub meta: String,
}
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct NotePublic { pub struct NotePublic {
@@ -24,13 +20,8 @@ pub struct NotePublic {
} }
pub fn generate_id() -> String { pub fn generate_id() -> String {
let mut result = "".to_owned(); let mut id: [u8; 32] = [0; 32];
let mut id: [u8; 1] = [0; 1];
let sr = ring::rand::SystemRandom::new(); let sr = ring::rand::SystemRandom::new();
let _ = sr.fill(&mut id);
for _ in 0..*config::ID_LENGTH { return bs62::encode_data(&id);
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); let note = store::get(&p.id);
match note { match note {
Ok(Some(n)) => HttpResponse::Ok().json(NoteInfo { meta: n.meta }), Ok(Some(_)) => HttpResponse::Ok().json(NoteInfo {}),
Ok(None) => HttpResponse::NotFound().finish(), Ok(None) => HttpResponse::NotFound().finish(),
Err(e) => HttpResponse::InternalServerError().body(e.to_string()), Err(e) => HttpResponse::InternalServerError().body(e.to_string()),
} }

View File

@@ -19,14 +19,6 @@ fn get_connection() -> Result<redis::Connection, &'static str> {
.map_err(|_| "Unable to connect to redis") .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> { pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
let serialized = serde_json::to_string(&note.clone()).unwrap(); let serialized = serde_json::to_string(&note.clone()).unwrap();
let mut conn = get_connection()?; let mut conn = get_connection()?;

View File

@@ -1,54 +0,0 @@
# 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"
```

View File

@@ -1,43 +1,43 @@
{ {
"version": "2.3.3", "name": "@cryptgeon/cli",
"name": "cryptgeon",
"repository": {
"type": "git",
"url": "https://github.com/cupcakearmy/cryptgeon.git",
"directory": "packages/cli"
},
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"type": "module", "type": "module",
"engines": {
"node": ">=18"
},
"scripts": { "scripts": {
"dev": "./scripts/build.js --watch", "dev": "esbuild ./src/index.ts --bundle --platform=node --outfile=dist/index.cjs --watch",
"build": "./scripts/build.js", "build": "esbuild ./src/index.ts --bundle --platform=node --outfile=dist/index.cjs",
"package": "./scripts/package.js", "bin": "pkg ."
"bin": "run-s build package",
"prepublishOnly": "run-s build"
}, },
"main": "./dist/index.cjs",
"bin": { "bin": {
"cryptgeon": "./dist/index.cjs" "cryptgeon": "./dist/index.cjs"
}, },
"files": [ "files": [
"dist" "dist"
], ],
"pkg": {
"scripts": "dist/**/*.js",
"targets": [
"node18-macos-arm64",
"node18-macos-x64",
"node18-linux-arm64",
"node18-linux-x64",
"node18-win-arm64",
"node18-win-x64"
],
"outputPath": "bin"
},
"devDependencies": { "devDependencies": {
"@commander-js/extra-typings": "^11.0.0",
"@cryptgeon/shared": "workspace:*",
"@types/inquirer": "^9.0.3", "@types/inquirer": "^9.0.3",
"@types/mime": "^3.0.1", "@types/mime": "^3.0.1",
"@types/node": "^20.5.0", "esbuild": "^0.17.18",
"commander": "^11.0.0", "pkg": "^5.8.1",
"esbuild": "^0.19.2", "typescript": "^4.9.5"
"inquirer": "^9.2.10", },
"dependencies": {
"@commander-js/extra-typings": "^9.5.0",
"@cryptgeon/shared": "workspace:*",
"commander": "^9.5.0",
"inquirer": "^9.2.0",
"mime": "^3.0.0", "mime": "^3.0.0",
"occulto": "^2.0.1", "occulto": "^2.0.1",
"pkg": "^5.8.1", "pretty-bytes": "^6.1.0"
"pretty-bytes": "^6.1.1",
"typescript": "^5.1.6"
} }
} }

View File

@@ -1,17 +0,0 @@
#!/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)

View File

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

View File

@@ -2,39 +2,20 @@ import { Adapters, get, info, setBase } from '@cryptgeon/shared'
import inquirer from 'inquirer' import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises' import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path' import { basename, resolve } from 'node:path'
import { AES, Hex } from 'occulto' import { Hex } from 'occulto'
import pretty from 'pretty-bytes' import pretty from 'pretty-bytes'
import { exit } from './utils' import { exit } from './utils'
export async function download(url: URL, all: boolean, suggestedPassword?: string) { export async function download(url: URL) {
setBase(url.origin) setBase(url.origin)
const id = url.pathname.split('/')[2] const id = url.pathname.split('/')[2]
const preview = await info(id).catch(() => exit('Note does not exist or is expired')) 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 note = await get(id)
const password = url.hash.slice(1)
const key = Hex.decode(password)
const couldNotDecrypt = () => exit('Could not decrypt note. Probably an invalid password') const couldNotDecrypt = () => exit('Could not decrypt note. Probably an invalid password')
switch (note.meta.type) { switch (note.meta.type) {
case 'file': case 'file':
@@ -43,40 +24,36 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
exit('No files found in note') exit('No files found in note')
return return
} }
console.log(files)
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,
})),
},
])
let selected: typeof files const selected = files.filter((file) => names.includes(file.name))
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') if (!selected.length) exit('No files selected')
await Promise.all( await Promise.all(
selected.map(async (file) => { files.map(async (file) => {
let filename = resolve(file.name) let filename = resolve(file.name)
try { try {
// If exists -> prepend timestamp to not overwrite the current file // If exists -> prepend timestamp to not overwrite the current file
await access(filename, constants.R_OK) await access(filename, constants.R_OK)
filename = resolve(`${Date.now()}-${file.name}`) filename = resolve(`${Date.now()}-${file.name}`)
} catch {} } catch {}
await writeFile(filename, file.contents) await writeFile(filename, new Uint8Array(await file.contents.arrayBuffer()))
console.log(`Saved: ${basename(filename)}`) console.log(`Saved: ${basename(filename)}`)
}) })
) )
break break
case 'text': case 'text':
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(couldNotDecrypt) const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(couldNotDecrypt)

View File

@@ -2,12 +2,10 @@
import { Argument, Option, program } from '@commander-js/extra-typings' import { Argument, Option, program } from '@commander-js/extra-typings'
import { setBase, status } from '@cryptgeon/shared' import { setBase, status } from '@cryptgeon/shared'
import prettyBytes from 'pretty-bytes'
import { download } from './download.js' import { download } from './download.js'
import { parseFile, parseNumber } from './parsers.js' import { parseFile, parseNumber } from './parsers.js'
import { getStdin } from './stdin.js' import { uploadFiles, uploadText } from './upload.js'
import { upload } from './upload.js'
import { exit } from './utils.js' import { exit } from './utils.js'
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org' const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
@@ -15,17 +13,10 @@ const server = new Option('-s --server <url>', 'the cryptgeon server to use').de
const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile) const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile)
const text = new Argument('<text>', 'Text content of the note') const text = new Argument('<text>', 'Text content of the note')
const password = new Option('-p --password <string>', 'manually set a password') 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 url = new Argument('<url>', 'The url to open')
const views = new Option('-v --views <number>', 'Amount of views before getting destroyed').argParser(parseNumber) 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) 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 }) { async function checkConstrains(constrains: { views?: number; minutes?: number }) {
const { views, minutes } = constrains const { views, minutes } = constrains
if (views && minutes) exit('cannot set view and minutes constrains simultaneously') if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
@@ -38,38 +29,31 @@ async function checkConstrains(constrains: { views?: number; minutes?: number })
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`) exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
} }
program.name('cryptgeon').version(version).configureHelp({ showGlobalOptions: true }) program.name('cryptgeon').version('1.0.0').configureHelp({ showGlobalOptions: true })
program program
.command('info') .command('info')
.description('show information about the server')
.addOption(server) .addOption(server)
.action(async (options) => { .action(async (options) => {
setBase(options.server) setBase(options.server)
const response = await status() const response = await status()
const formatted = { for (const key of Object.keys(response)) {
...response, if (key.startsWith('theme_')) delete response[key as keyof typeof response]
max_size: prettyBytes(response.max_size),
} }
for (const key of Object.keys(formatted)) { console.table(response)
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
}
console.table(formatted)
}) })
const send = program.command('send').description('send a note') const send = program.command('send')
send send
.command('file') .command('file')
.addArgument(files) .addArgument(files)
.addOption(server) .addOption(server)
.addOption(views) .addOption(views)
.addOption(minutes) .addOption(minutes)
.addOption(password)
.action(async (files, options) => { .action(async (files, options) => {
setBase(options.server!) setBase(options.server!)
await checkConstrains(options) await checkConstrains(options)
options.password ||= await getStdin() await uploadFiles(files, { views: options.views, expiration: options.minutes })
await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
}) })
send send
.command('text') .command('text')
@@ -77,25 +61,19 @@ send
.addOption(server) .addOption(server)
.addOption(views) .addOption(views)
.addOption(minutes) .addOption(minutes)
.addOption(password)
.action(async (text, options) => { .action(async (text, options) => {
setBase(options.server!) setBase(options.server!)
await checkConstrains(options) await checkConstrains(options)
options.password ||= await getStdin() await uploadText(text, { views: options.views, expiration: options.minutes })
await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
}) })
program program
.command('open') .command('open')
.description('open a link with text or files inside')
.addArgument(url) .addArgument(url)
.addOption(password)
.addOption(all)
.action(async (note, options) => { .action(async (note, options) => {
try { try {
const url = new URL(note) const url = new URL(note)
options.password ||= await getStdin() await download(url)
await download(url, options.all, options.password)
} catch { } catch {
exit('Invalid URL') exit('Invalid URL')
} }

View File

@@ -1,10 +1,10 @@
import { InvalidArgumentError, InvalidOptionArgumentError } from '@commander-js/extra-typings' import { InvalidArgumentError, InvalidOptionArgumentError } from '@commander-js/extra-typings'
import { accessSync, constants } from 'node:fs' import { accessSync, constants } from 'node:fs'
import { resolve } from 'node:path' import path from 'node:path'
export function parseFile(value: string, before: string[] = []) { export function parseFile(value: string, before: string[] = []) {
try { try {
const file = resolve(value) const file = path.resolve(value)
accessSync(file, constants.R_OK) accessSync(file, constants.R_OK)
return [...before, file] return [...before, file]
} catch { } catch {

View File

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

@@ -1,51 +1,49 @@
import { Blob } from 'node:buffer'
import { readFile, stat } from 'node:fs/promises' import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path' import { basename } from 'node:path'
import { Adapters, BASE, create, FileDTO, Note, NoteMeta } from '@cryptgeon/shared' import { Adapters, BASE, create, FileDTO, Note } from '@cryptgeon/shared'
import mime from 'mime' import mime from 'mime'
import { AES, Hex, TypedArray } from 'occulto' import { AES, Hex, TypedArray } from 'occulto'
import { exit } from './utils.js' import { exit } from './utils.js'
type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string } type UploadOptions = Pick<Note, 'views' | 'expiration'>
export async function upload(input: string | string[], options: UploadOptions) { export async function upload(key: TypedArray, note: Note) {
try { 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) const result = await create(note)
let url = `${BASE}/note/${result.id}` const password = Hex.encode(key)
if (!derived) url += `#${Hex.encode(key)}` const url = `${BASE}/note/${result.id}#${password}`
console.log(`Note created:\n\n${url}`) console.log(`Note created under:\n\n${url}`)
} catch { } catch {
exit('Could not create note') exit('Could not create note')
} }
} }
export async function uploadFiles(paths: string[], options: UploadOptions) {
const key = await AES.generateKey()
const files: FileDTO[] = await Promise.all(
paths.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: new Blob([data]) as FileDTO['contents'],
type,
}
})
)
const contents = await Adapters.Files.encrypt(files, key)
await upload(key, { ...options, contents, meta: { type: 'file' } })
}
export async function uploadText(text: string, options: UploadOptions) {
const key = await AES.generateKey()
const contents = await Adapters.Text.encrypt(text, key)
await upload(key, { ...options, contents, meta: { type: 'text' } })
}

View File

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

View File

@@ -1,10 +1,110 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es2022", /* Visit https://aka.ms/tsconfig to read more about this file */
"module": "es2022",
"moduleResolution": "node", /* Projects */
"noEmit": true, // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
"strict": true, // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
"allowSyntheticDefaultImports": true // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
// "rootDir": "./src" /* Specify the root folder within your source files. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */,
// "paths": {
// "@shared/*": ["../shared/*"],
// "@shared": ["../shared"]
// } /* Specify a set of entries that re-map imports to additional lookup locations. */,
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [
// "../shared/types.ts",
// "node"
// ] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
} }
// "references": [{ "path": "../shared" }]
} }

View File

@@ -16,8 +16,7 @@
"decrypting": "entschlüsselt", "decrypting": "entschlüsselt",
"uploading": "hochladen", "uploading": "hochladen",
"downloading": "wird heruntergeladen", "downloading": "wird heruntergeladen",
"qr_code": "qr-code", "qr_code": "qr-code"
"password": "Passwort"
}, },
"home": { "home": {
"intro": "Senden Sie ganz einfach <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien mit einem Klick. Erstellen Sie einfach eine Notiz und teilen Sie den Link.", "intro": "Senden Sie ganz einfach <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien mit einem Klick. Erstellen Sie einfach eine Notiz und teilen Sie den Link.",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "notiz erstellt." "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": { "show": {

View File

@@ -16,26 +16,21 @@
"decrypting": "decrypting", "decrypting": "decrypting",
"uploading": "uploading", "uploading": "uploading",
"downloading": "downloading", "downloading": "downloading",
"qr_code": "qr code", "qr_code": "qr code"
"password": "password"
}, },
"home": { "home": {
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.", "intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
"explanation": "the note will expire and be destroyed after {type}.", "explanation": "the note will expire and be destroyed after {type}.",
"new_note": "new note", "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, but just be warned.)", "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": { "errors": {
"note_to_big": "could not create note. note is too big.", "note_to_big": "could not create note. note is to big",
"note_error": "could not create note. please try again.", "note_error": "could not create note. please try again.",
"max": "max: {n}", "max": "max: {n}",
"empty_content": "note is empty." "empty_content": "note is empty."
}, },
"messages": { "messages": {
"note_created": "note created." "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": { "show": {
@@ -44,7 +39,7 @@
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.", "decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.",
"unsupported_type": "unsupported note type." "unsupported_type": "unsupported note type."
}, },
"explanation": "click below to show and delete the note if the counter has reached its limit.", "explanation": "click below to show and delete the note if the counter has reached it's limit",
"show_note": "show note", "show_note": "show note",
"warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.", "warning_will_not_see_again": "you will <b>not</b> get the chance to see the note again.",
"download_all": "download all" "download_all": "download all"

View File

@@ -16,8 +16,7 @@
"decrypting": "descifrando", "decrypting": "descifrando",
"uploading": "cargando", "uploading": "cargando",
"downloading": "descargando", "downloading": "descargando",
"qr_code": "código qr", "qr_code": "código qr"
"password": "contraseña"
}, },
"home": { "home": {
"intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.", "intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "nota creada." "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": { "show": {
@@ -46,7 +41,7 @@
}, },
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite", "explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
"show_note": "mostrar nota", "show_note": "mostrar nota",
"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" "download_all": "descargar todo"
}, },
"file_upload": { "file_upload": {

View File

@@ -16,8 +16,7 @@
"decrypting": "déchiffrer", "decrypting": "déchiffrer",
"uploading": "téléchargement", "uploading": "téléchargement",
"downloading": "téléchargement", "downloading": "téléchargement",
"qr_code": "code qr", "qr_code": "code qr"
"password": "mot de passe"
}, },
"home": { "home": {
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement cryptés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.", "intro": "Envoyez facilement des notes ou des fichiers <i>entièrement cryptés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "note créée." "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": { "show": {

View File

@@ -16,8 +16,7 @@
"decrypting": "decifrando", "decrypting": "decifrando",
"uploading": "caricamento", "uploading": "caricamento",
"downloading": "scaricando", "downloading": "scaricando",
"qr_code": "codice qr", "qr_code": "codice qr"
"password": "password"
}, },
"home": { "home": {
"intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.", "intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "nota creata." "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": { "show": {
@@ -46,7 +41,7 @@
}, },
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite", "explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
"show_note": "mostra la nota", "show_note": "mostra la nota",
"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" "download_all": "scarica tutti"
}, },
"file_upload": { "file_upload": {

View File

@@ -1,56 +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": "クリップボードにコピーする",
"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

@@ -1,56 +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": "скопировать в буфер обмена",
"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 />не гарантируется, что заметка будет сохранена, так как все хранится в оперативной памяти, если она заполнится, самые старые заметки будут удалены..<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

@@ -16,8 +16,7 @@
"decrypting": "解密", "decrypting": "解密",
"uploading": "上传", "uploading": "上传",
"downloading": "下载", "downloading": "下载",
"qr_code": "二维码", "qr_code": "二维码"
"password": "密码"
}, },
"home": { "home": {
"intro": "飞鸽传书,一键传输完全加密的密信或文件,阅后即焚。", "intro": "飞鸽传书,一键传输完全加密的密信或文件,阅后即焚。",
@@ -32,10 +31,6 @@
}, },
"messages": { "messages": {
"note_created": "密信创建成功。" "note_created": "密信创建成功。"
},
"advanced": {
"explanation": "默认情况下,每个笔记都使用安全生成的密码。但是,您也可以选择您自己的密码,该密码未包含在链接中。",
"custom_password": "自定义密码"
} }
}, },
"show": { "show": {
@@ -53,4 +48,4 @@
"selected_files": "已选中的文件", "selected_files": "已选中的文件",
"no_files_selected": "没有文件被选中" "no_files_selected": "没有文件被选中"
} }
} }

View File

@@ -2,7 +2,6 @@
"private": true, "private": true,
"name": "@cryptgeon/web", "name": "@cryptgeon/web",
"scripts": { "scripts": {
"postinstall": "svelte-kit sync",
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
@@ -13,29 +12,30 @@
}, },
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@lokalise/node-api": "^10.0.0", "@lokalise/node-api": "^9.8.0",
"@sveltejs/adapter-static": "^2.0.3", "@sveltejs/adapter-static": "^1.0.6",
"@sveltejs/kit": "^1.22.6", "@sveltejs/kit": "^1.15.8",
"@types/dompurify": "^3.0.2", "@types/dompurify": "^2.4.0",
"@types/file-saver": "^2.0.5", "@types/file-saver": "^2.0.5",
"@zerodevx/svelte-toast": "^0.9.5", "@zerodevx/svelte-toast": "^0.7.2",
"adm-zip": "^0.5.10", "adm-zip": "^0.5.10",
"dotenv": "^16.3.1", "dotenv": "^16.0.3",
"svelte": "^4.2.0", "svelte": "^3.58.0",
"svelte-check": "^3.5.0", "svelte-check": "^2.10.3",
"svelte-intl-precompile": "^0.12.3", "svelte-intl-precompile": "^0.10.1",
"tslib": "^2.6.1", "svelte-preprocess": "^4.10.7",
"typescript": "^5.1.6", "tslib": "^2.5.0",
"vite": "^4.4.9" "typescript": "^4.9.5",
"vite": "^4.3.3"
}, },
"dependencies": { "dependencies": {
"@cryptgeon/shared": "workspace:*", "@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^5.0.8", "@fontsource/fira-mono": "^4.5.10",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"dompurify": "^3.0.5", "dompurify": "^2.4.5",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"occulto": "^2.0.1", "occulto": "^2.0.1",
"pretty-bytes": "^6.1.1", "pretty-bytes": "^6.1.0",
"qrious": "^4.0.2" "qrious": "^4.0.2"
} }
} }

View File

@@ -5,15 +5,10 @@ import https from 'https'
dotenv.config() dotenv.config()
function exit(msg) {
console.error(msg)
process.exit(1)
}
const apiKey = process.env.LOKALISE_API_KEY const apiKey = process.env.LOKALISE_API_KEY
const project_id = process.env.LOKALISE_PROJECT const project_id = process.env.LOKALISE_PROJECT
if (!apiKey) exit('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"') if (!apiKey) throw new Error('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"') if (!project_id) throw new Error('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
const client = new LokaliseApi({ apiKey }) const client = new LokaliseApi({ apiKey })
const WGet = (url) => const WGet = (url) =>

View File

@@ -92,7 +92,7 @@ button {
} }
*:disabled, *:disabled,
.disabled { *[disabled='true'] {
opacity: 0.5; opacity: 0.5;
} }
@@ -126,13 +126,3 @@ fieldset {
.tr { .tr {
text-align: right; text-align: right;
} }
hr {
border: none;
border-bottom: 2px solid var(--ui-bg-1);
margin: 1rem 0;
}
p {
margin: 0;
}

View File

@@ -8,75 +8,48 @@
export let note: Note export let note: Note
export let timeExpiration = false export let timeExpiration = false
export let customPassword: string | null = null
let hasCustomPassword = false
$: if (!hasCustomPassword) customPassword = null
</script> </script>
<div class="flex col"> <div class="fields">
<div class="flex"> <TextInput
<TextInput data-testid="field-views"
data-testid="field-views" type="number"
type="number" label={$t('common.views', { values: { n: 0 } })}
label={$t('common.views', { values: { n: 0 } })} bind:value={note.views}
bind:value={note.views} disabled={timeExpiration}
disabled={timeExpiration} max={$status?.max_views}
max={$status?.max_views} min={1}
min={1} validate={(v) =>
validate={(v) => ($status && v <= $status?.max_views && v > 0) ||
($status && v <= $status?.max_views && v > 0) || $t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })} />
/> <div class="middle-switch">
<Switch <Switch
data-testid="switch-advanced-toggle" data-testid="switch-advanced-toggle"
label={$t('common.mode')} label={$t('common.mode')}
bind:value={timeExpiration} bind:value={timeExpiration}
color={false} 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>
<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>
<style> <style>
.flex { .middle-switch {
display: flex; margin: 0 1rem;
align-items: flex-end;
gap: 1rem;
width: 100%;
} }
.col { .fields {
gap: 1.5rem; display: flex;
flex-direction: column;
} }
</style> </style>

View File

@@ -8,20 +8,19 @@
export let label: string = '' export let label: string = ''
export let files: FileDTO[] = [] export let files: FileDTO[] = []
async function fileToDTO(file: File): Promise<FileDTO> { function fileToDTO(file: File): FileDTO {
return { return {
name: file.name, name: file.name,
size: file.size, size: file.size,
type: file.type, type: file.type,
contents: new Uint8Array(await file.arrayBuffer()), contents: file,
} }
} }
async function onInput(e: Event) { async function onInput(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
if (input?.files?.length) { if (input?.files?.length) {
const toAdd = await Promise.all(Array.from(input.files).map(fileToDTO)) files = [...files, ...Array.from(input.files).map(fileToDTO)]
files = [...files, ...toAdd]
} }
} }

View File

@@ -1,7 +1,7 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
export type NoteResult = { export type NoteResult = {
password: string
id: string id: string
password?: string
} }
</script> </script>
@@ -14,8 +14,7 @@
export let result: NoteResult export let result: NoteResult
let url = `${window.location.origin}/note/${result.id}` $: url = `${window.location.origin}/note/${result.id}#${result.password}`
if (result.password) url += `#${result.password}`
function reset() { function reset() {
window.location.reload() window.location.reload()

View File

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

View File

@@ -30,7 +30,7 @@
</script> </script>
<label> <label>
<small class:disabled={$$restProps.disabled}> <small disabled={$$restProps.disabled}>
{label} {label}
{#if valid !== true} {#if valid !== true}
<span class="error-text">{valid}</span> <span class="error-text">{valid}</span>
@@ -54,7 +54,6 @@
label { label {
position: relative; position: relative;
display: block; display: block;
width: 100%;
} }
label > small { label > small {

View File

@@ -14,7 +14,7 @@
import Switch from '$lib/ui/Switch.svelte' import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte' import TextArea from '$lib/ui/TextArea.svelte'
import type { FileDTO, Note } from '@cryptgeon/shared' import type { FileDTO, Note } from '@cryptgeon/shared'
import { Adapters, PayloadToLargeError, create } from '@cryptgeon/shared' import { Adapters, create, PayloadToLargeError } from '@cryptgeon/shared'
let note: Note = { let note: Note = {
contents: '', contents: '',
@@ -27,7 +27,6 @@
let advanced = false let advanced = false
let isFile = false let isFile = false
let timeExpiration = false let timeExpiration = false
let customPassword: string | null = null
let description = '' let description = ''
let loading: string | null = null let loading: string | null = null
@@ -58,14 +57,13 @@
try { try {
loading = $t('common.encrypting') loading = $t('common.encrypting')
const derived = customPassword && (await AES.derive(customPassword)) const key = await AES.generateKey()
const key = derived ? derived[0] : await AES.generateKey() const password = Hex.encode(key)
const data: Note = { const data: Note = {
contents: '', contents: '',
meta: note.meta, meta: note.meta,
} }
if (derived) data.meta.derivation = derived[1]
if (isFile) { if (isFile) {
if (files.length === 0) throw new EmptyContentError() if (files.length === 0) throw new EmptyContentError()
data.contents = await Adapters.Files.encrypt(files, key) data.contents = await Adapters.Files.encrypt(files, key)
@@ -79,8 +77,8 @@
loading = $t('common.uploading') loading = $t('common.uploading')
const response = await create(data) const response = await create(data)
result = { result = {
password: password,
id: response.id, id: response.id,
password: customPassword ? undefined : Hex.encode(key),
} }
notify.success($t('home.messages.note_created')) notify.success($t('home.messages.note_created'))
} catch (e) { } catch (e) {
@@ -149,9 +147,9 @@
</p> </p>
{#if advanced} {#if advanced}
<div transition:blur|global={{ duration: 250 }}> <div transition:blur={{ duration: 250 }}>
<hr /> <br />
<AdvancedParameters bind:note bind:timeExpiration bind:customPassword /> <AdvancedParameters bind:note bind:timeExpiration />
</div> </div>
{/if} {/if}
</fieldset> </fieldset>

View File

@@ -23,7 +23,6 @@
right: 0; right: 0;
width: 100%; width: 100%;
background-color: var(--ui-bg-0-85); background-color: var(--ui-bg-0-85);
backdrop-filter: blur(2px);
} }
a { a {

View File

@@ -75,9 +75,6 @@
<style> <style>
section { section {
width: 100%; width: 100%;
display: flex;
flex-direction: column;
gap: 2rem;
} }
ul { ul {

View File

@@ -1,35 +1,30 @@
<script lang="ts"> <script lang="ts">
import { AES, Hex } from 'occulto' import { Hex } from 'occulto'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile' import { t } from 'svelte-intl-precompile'
import Button from '$lib/ui/Button.svelte' import Button from '$lib/ui/Button.svelte'
import Loader from '$lib/ui/Loader.svelte' import Loader from '$lib/ui/Loader.svelte'
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte' import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
import TextInput from '$lib/ui/TextInput.svelte' import { Adapters, get, info } from '@cryptgeon/shared'
import { Adapters, get, info, type NoteMeta } from '@cryptgeon/shared'
import type { PageData } from './$types' import type { PageData } from './$types'
export let data: PageData export let data: PageData
let id = data.id let id = data.id
let password: string | null = null let password: string
let note: DecryptedNote | null = null let note: DecryptedNote | null = null
let exists = false let exists = false
let meta: NoteMeta | null = null
let loading: string | null = null let loading: string | null = null
let error: string | null = null let error: string | null = null
$: valid = !!password?.length
onMount(async () => { onMount(async () => {
// Check if note exists // Check if note exists
try { try {
loading = $t('common.loading') loading = $t('common.loading')
password = window.location.hash.slice(1) password = window.location.hash.slice(1)
const note = await info(id) await info(id)
meta = note.meta
exists = true exists = true
} catch { } catch {
exists = false exists = false
@@ -43,18 +38,11 @@
*/ */
async function show() { async function show() {
try { try {
if (!valid) {
error = $t('show.errors.no_password')
return
}
// Load note
error = null error = null
loading = $t('common.downloading') loading = $t('common.downloading')
const data = await get(id) const data = await get(id)
loading = $t('common.decrypting') loading = $t('common.decrypting')
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation)) const key = Hex.decode(password)
const key = derived ? derived[0] : Hex.decode(password!)
switch (data.meta.type) { switch (data.meta.type) {
case 'text': case 'text':
note = { note = {
@@ -89,18 +77,9 @@
<form on:submit|preventDefault={show}> <form on:submit|preventDefault={show}>
<fieldset> <fieldset>
<p>{$t('show.explanation')}</p> <p>{$t('show.explanation')}</p>
{#if meta?.derivation} <Button data-testid="show-note-button" type="submit">{$t('show.show_note')}</Button>
<TextInput
data-testid="show-note-password"
type="password"
bind:value={password}
label={$t('common.password')}
/>
{/if}
<Button disabled={!valid} data-testid="show-note-button" type="submit"
>{$t('show.show_note')}</Button
>
{#if error} {#if error}
<br />
<p class="error-text"> <p class="error-text">
{error} {error}
<br /> <br />
@@ -118,10 +97,4 @@
.loader { .loader {
text-align: center; text-align: center;
} }
fieldset {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style> </style>

View File

@@ -1,9 +1,9 @@
import adapter from '@sveltejs/adapter-static' import adapter from '@sveltejs/adapter-static'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin' import preprocess from 'svelte-preprocess'
import { vitePreprocess } from '@sveltejs/kit/vite'
export default { export default {
preprocess: vitePreprocess([precompileIntl('locales')]), preprocess: preprocess(),
kit: { kit: {
adapter: adapter({ adapter: adapter({
fallback: 'index.html', fallback: 'index.html',

View File

@@ -10,7 +10,7 @@ proxy.on('error', function (err, req, res) {
const server = http.createServer(function (req, res) { const server = http.createServer(function (req, res) {
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001' const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
proxy.web(req, res, { target, proxyTimeout: 250, timeout: 250 }) proxy.web(req, res, { target })
}) })
server.listen(1234) server.listen(1234)
console.log('Proxy on http://localhost:1234') console.log('Proxy on http://localhost:1234')

View File

@@ -14,7 +14,7 @@
"build": "tsc" "build": "tsc"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.1.6" "typescript": "^4.9.5"
}, },
"dependencies": { "dependencies": {
"occulto": "^2.0.1" "occulto": "^2.0.1"

View File

@@ -15,15 +15,14 @@ class CryptTextAdapter implements CryptAdapter<string> {
} }
} }
class CryptBlobAdapter implements CryptAdapter<TypedArray> { class CryptBlobAdapter implements CryptAdapter<Blob> {
async encrypt(plaintext: TypedArray, key: TypedArray) { async encrypt(plaintext: Blob, key: TypedArray) {
return await AES.encrypt(plaintext, key) return await AES.encrypt(new Uint8Array(await plaintext.arrayBuffer()), key)
} }
async decrypt(ciphertext: string, key: TypedArray) { async decrypt(ciphertext: string, key: TypedArray) {
return await AES.decrypt(ciphertext, key) const plaintext = await AES.decrypt(ciphertext, key)
// const plaintext = await AES.decrypt(ciphertext, key) return new Blob([plaintext], { type: 'application/octet-stream' })
// return new Blob([plaintext], { type: 'application/octet-stream' })
} }
} }

View File

@@ -1,9 +1,4 @@
import type { KeyData, TypedArray } from 'occulto' export type NoteMeta = { type: 'text' | 'file' }
export type NoteMeta = {
type: 'text' | 'file'
derivation?: KeyData
}
export type Note = { export type Note = {
contents: string contents: string
@@ -11,12 +6,12 @@ export type Note = {
views?: number views?: number
expiration?: number expiration?: number
} }
export type NoteInfo = Pick<Note, 'meta'> export type NoteInfo = {}
export type NotePublic = Pick<Note, 'contents' | 'meta'> export type NotePublic = Pick<Note, 'contents' | 'meta'>
export type NoteCreate = Omit<Note, 'meta'> & { meta: string } export type NoteCreate = Omit<Note, 'meta'> & { meta: string }
export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & { export type FileDTO = Pick<File, 'name' | 'size' | 'type'> & {
contents: TypedArray contents: Blob
} }
export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & { export type EncryptedFileDTO = Omit<FileDTO, 'contents'> & {
@@ -74,12 +69,10 @@ export async function get(id: string): Promise<NotePublic> {
method: 'delete', method: 'delete',
}) })
const { contents, meta } = data const { contents, meta } = data
const note = { return {
contents, contents,
meta: JSON.parse(meta), meta: JSON.parse(meta) as NoteMeta,
} satisfies NotePublic }
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
} }
export async function info(id: string): Promise<NoteInfo> { export async function info(id: string): Promise<NoteInfo> {
@@ -87,12 +80,7 @@ export async function info(id: string): Promise<NoteInfo> {
url: `notes/${id}`, url: `notes/${id}`,
method: 'get', method: 'get',
}) })
const { meta } = data return data
const note = {
meta: JSON.parse(meta),
} satisfies NoteInfo
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
} }
export type Status = { export type Status = {

View File

@@ -1,12 +1,103 @@
{ {
"compilerOptions": { "compilerOptions": {
"incremental": true, /* Visit https://aka.ms/tsconfig to read more about this file */
"composite": true,
"target": "es2022", /* Projects */
"module": "es2022", "incremental": true /* Save .tsbuildinfo files to allow for incremental compilation of projects. */,
"rootDir": "./src", "composite": true /* Enable constraints that allow a TypeScript project to be used with project references. */,
"moduleResolution": "node", // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
"outDir": "./dist", // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
"strict": true // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "es2022" /* Specify what module code is generated. */,
"rootDir": "./src" /* Specify the root folder within your source files. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
} }
} }

View File

@@ -21,10 +21,10 @@ const config: PlaywrightTestConfig = {
{ name: 'chrome', use: { ...devices['Desktop Chrome'] } }, { name: 'chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'safari', use: { ...devices['Desktop Safari'] } }, { name: 'safari', use: { ...devices['Desktop Safari'] } },
{
{ name: 'cli', use: { ...devices['Desktop Chrome'] }, grep: [/@cli/] }, name: 'local',
{ name: 'web', use: { ...devices['Desktop Chrome'] }, grep: [/@web/] }, use: { ...devices['Desktop Chrome'] },
{ name: 'cross', use: { ...devices['Desktop Chrome'] }, grep: [/@cross/] }, },
], ],
} }

1926
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
import { test } from '@playwright/test'
import { basename } from 'node:path'
import { Files, getFileChecksum, rm, tmpFile } from '../../files'
import { CLI, getLinkFromCLI } from '../../utils'
test.describe('file @cli', () => {
test('simple', async ({ page }) => {
const file = await tmpFile(Files.Image)
const checksum = await getFileChecksum(file)
const note = await CLI('send', 'file', file)
const link = getLinkFromCLI(note.stdout)
await rm(file)
await CLI('open', link, '--all')
const c = await getFileChecksum(basename(file))
await rm(basename(file))
test.expect(checksum).toBe(c)
})
test('simple with password', async ({ page }) => {
const file = await tmpFile(Files.Image)
const password = 'password'
const checksum = await getFileChecksum(file)
const note = await CLI('send', 'file', file, '--password', password)
const link = getLinkFromCLI(note.stdout)
await rm(file)
await CLI('open', link, '--all', '--password', password)
const c = await getFileChecksum(basename(file))
await rm(basename(file))
test.expect(checksum).toBe(c)
})
})

View File

@@ -1,23 +0,0 @@
import { test } from '@playwright/test'
import { CLI, getLinkFromCLI } from '../../utils'
test.describe('text @cli', () => {
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const note = await CLI('send', 'text', text)
const link = getLinkFromCLI(note.stdout)
const retrieved = await CLI('open', link)
test.expect(retrieved.stdout.trim()).toBe(text)
})
test('simple with password', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying.`
const password = 'password'
const note = await CLI('send', 'text', text, '--password', password)
const link = getLinkFromCLI(note.stdout)
const retrieved = await CLI('open', link, '--password', password)
test.expect(retrieved.stdout.trim()).toBe(text)
})
})

View File

@@ -1,53 +0,0 @@
import { test } from '@playwright/test'
import { CLI, checkLinkForDownload, checkLinkForText, createNote, getLinkFromCLI } from '../../utils'
import { Files, getFileChecksum, rm, tmpFile } from '../../files'
import { basename } from 'path'
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const password = 'password'
test.describe('text @cross', () => {
test('cli to web', async ({ page }) => {
const file = await tmpFile(Files.Image)
const checksum = await getFileChecksum(file)
const note = await CLI('send', 'file', file)
const link = getLinkFromCLI(note.stdout)
await rm(file)
await checkLinkForDownload(page, { link, text: basename(file), checksum })
})
test('cli to web with password', async ({ page }) => {
const file = await tmpFile(Files.Image)
const checksum = await getFileChecksum(file)
const note = await CLI('send', 'file', file, '--password', password)
const link = getLinkFromCLI(note.stdout)
await rm(file)
await checkLinkForDownload(page, { link, text: basename(file), checksum, password })
})
test('web to cli', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
const filename = basename(files[0])
await CLI('open', link, '--all')
const c = await getFileChecksum(filename)
await rm(basename(filename))
test.expect(checksum).toBe(c)
})
test('web to cli with password', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files, password })
const filename = basename(files[0])
await CLI('open', link, '--all', '--password', password)
const c = await getFileChecksum(filename)
await rm(basename(filename))
test.expect(checksum).toBe(c)
})
})

View File

@@ -1,32 +0,0 @@
import { test } from '@playwright/test'
import { CLI, checkLinkForText, createNote, getLinkFromCLI } from '../../utils'
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const password = 'password'
test.describe('text @cross', () => {
test('cli to web', async ({ page }) => {
const note = await CLI('send', 'text', text)
const link = getLinkFromCLI(note.stdout)
await checkLinkForText(page, { link, text })
})
test('web to cli', async ({ page }) => {
const link = await createNote(page, { text })
const retrieved = await CLI('open', link)
test.expect(retrieved.stdout.trim()).toBe(text)
})
test('cli to web with password', async ({ page }) => {
const note = await CLI('send', 'text', text, '--password', password)
const link = getLinkFromCLI(note.stdout)
await checkLinkForText(page, { link, text, password })
})
test('web to cli with password', async ({ page }) => {
const link = await createNote(page, { text, password })
const retrieved = await CLI('open', link, '--password', password)
test.expect(retrieved.stdout.trim()).toBe(text)
})
})

5
test/file/files.ts Normal file
View File

@@ -0,0 +1,5 @@
export default {
PDF: 'test/assets/AES.pdf',
Image: 'test/assets/image.jpg',
Zip: 'test/assets/Pigeons.zip',
}

View File

@@ -0,0 +1,11 @@
import { test } from '@playwright/test'
import { checkLinkForDownload, createNote, getFileChecksum } from '../utils'
import Files from './files'
test('multiple', async ({ page }) => {
const files = [Files.PDF, Files.Image]
const checksums = await Promise.all(files.map(getFileChecksum))
const link = await createNote(page, { files, views: 2 })
await checkLinkForDownload(page, link, 'image.jpg', checksums[1])
await checkLinkForDownload(page, link, 'AES.pdf', checksums[0])
})

24
test/file/simple.spec.ts Normal file
View File

@@ -0,0 +1,24 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForDownload, checkLinkForText, createNote, getFileChecksum } from '../utils'
import Files from './files'
test('simple pdf', async ({ page }) => {
const files = [Files.PDF]
const link = await createNote(page, { files })
await checkLinkForText(page, link, 'AES.pdf')
await checkLinkDoesNotExist(page, link)
})
test('pdf content', async ({ page }) => {
const files = [Files.PDF]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, link, 'AES.pdf', checksum)
})
test('image content', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, link, 'image.jpg', checksum)
})

View File

@@ -0,0 +1,8 @@
import { test } from '@playwright/test'
import { createNote } from '../utils'
import Files from './files'
test.skip('to big zip', async ({ page }) => {
const files = [Files.Zip]
const link = await createNote(page, { files, error: 'note is to big' })
})

View File

@@ -1,25 +0,0 @@
import { createHash } from 'crypto'
import { cp as cpFN, rm as rmFN } from 'fs'
import { readFile } from 'fs/promises'
import { promisify } from 'util'
export const cp = promisify(cpFN)
export const rm = promisify(rmFN)
export const Files = {
PDF: 'test/assets/AES.pdf',
Image: 'test/assets/image.jpg',
Zip: 'test/assets/Pigeons.zip',
}
export async function getFileChecksum(file: string) {
const buffer = await readFile(file)
const hash = createHash('sha3-256').update(buffer).digest('hex')
return hash
}
export async function tmpFile(file: string) {
const name = `./tmp/${Math.random().toString(36).substring(7)}`
await cp(file, name)
return name
}

37
test/text.ts Normal file
View File

@@ -0,0 +1,37 @@
import { expect, test, type Page } from '@playwright/test'
async function createNote(page: Page, text: string): Promise<string> {
await page.goto('/')
await page.locator('textarea').click()
await page.locator('textarea').fill(text)
await page.locator('button:has-text("create")').click()
await page.locator('[data-testid="share-link"]').click()
const shareLink = await page.locator('[data-testid="share-link"]').inputValue()
return shareLink
}
async function checkLinkForText(page: Page, link: string, text: string) {
await page.goto(link)
await page.locator('[data-testid="show-note-button"]').click()
expect(await page.locator('[data-testid="result"] >> .note').innerText()).toBe(text)
}
async function checkLinkDoesNotExist(page: Page, link: string) {
await page.goto('/') // Required due to firefox: https://github.com/microsoft/playwright/issues/15781
await page.goto(link)
await expect(page.locator('main')).toContainText('note was not found or was already deleted')
}
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const shareLink = await createNote(page, text)
await checkLinkForText(page, shareLink, text)
})
test('only shown once', async ({ page }) => {
const text = `Christian victorious reason suicide dead. Right ultimate gains god hope truth burying selfish society joy. Ultimate.`
const shareLink = await createNote(page, text)
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})

View File

@@ -0,0 +1,14 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../utils'
test('1 minute', async ({ page }) => {
const text = `Virtues value ascetic revaluation sea dead strong burying.`
const minutes = 1
const timeout = minutes * 60_000
test.setTimeout(timeout * 2)
const shareLink = await createNote(page, { text, expiration: minutes })
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await page.waitForTimeout(timeout)
await checkLinkDoesNotExist(page, shareLink)
})

8
test/text/simple.spec.ts Normal file
View File

@@ -0,0 +1,8 @@
import { test } from '@playwright/test'
import { checkLinkForText, createNote } from '../utils'
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of decieve play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const shareLink = await createNote(page, { text })
await checkLinkForText(page, shareLink, text)
})

18
test/text/views.spec.ts Normal file
View File

@@ -0,0 +1,18 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../utils'
test('only shown once', async ({ page }) => {
const text = `Christian victorious reason suicide dead. Right ultimate gains god hope truth burying selfish society joy. Ultimate.`
const shareLink = await createNote(page, { text })
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})
test('view 3 times', async ({ page }) => {
const text = `Justice holiest overcome fearful strong ultimate holiest christianity.`
const shareLink = await createNote(page, { text, views: 3 })
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await checkLinkForText(page, shareLink, text)
await checkLinkDoesNotExist(page, shareLink)
})

View File

@@ -1,25 +1,15 @@
import { expect, type Page } from '@playwright/test' import { expect, type Page } from '@playwright/test'
import { execFile } from 'node:child_process' import { createHash } from 'crypto'
import { promisify } from 'node:util' import { readFile } from 'fs/promises'
import { getFileChecksum } from './files'
const exec = promisify(execFile) type CreatePage = { text?: string; files?: string[]; views?: number; expiration?: number; error?: string }
type CreatePage = {
text?: string
files?: string[]
views?: number
expiration?: number
error?: string
password?: string
}
export async function createNote(page: Page, options: CreatePage): Promise<string> { export async function createNote(page: Page, options: CreatePage): Promise<string> {
await page.goto('/') await page.goto('/')
if (options.text) { if (options.text) {
await page.getByTestId('text-field').fill(options.text) await page.locator('[data-testid="text-field"]').fill(options.text)
} else if (options.files) { } else if (options.files) {
await page.getByTestId('switch-file').click() await page.locator('[data-testid="switch-file"]').click()
const [fileChooser] = await Promise.all([ const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'), page.waitForEvent('filechooser'),
@@ -28,16 +18,13 @@ export async function createNote(page: Page, options: CreatePage): Promise<strin
await fileChooser.setFiles(options.files) await fileChooser.setFiles(options.files)
} }
if (options.views || options.expiration || options.password) await page.getByTestId('switch-advanced').click()
if (options.views) { if (options.views) {
await page.getByTestId('field-views').fill(options.views.toString()) await page.locator('[data-testid="switch-advanced"]').click()
await page.locator('[data-testid="field-views"]').fill(options.views.toString())
} else if (options.expiration) { } else if (options.expiration) {
await page.getByTestId('switch-advanced-toggle').click() await page.locator('[data-testid="switch-advanced"]').click()
await page.getByTestId('field-expiration').fill(options.expiration.toString()) await page.locator('[data-testid="switch-advanced-toggle"]').click()
} await page.locator('[data-testid="field-expiration"]').fill(options.expiration.toString())
if (options.password) {
await page.getByTestId('custom-password').click()
await page.getByTestId('password').fill(options.password)
} }
await page.locator('button:has-text("create")').click() await page.locator('button:has-text("create")').click()
@@ -46,39 +33,29 @@ export async function createNote(page: Page, options: CreatePage): Promise<strin
await expect(page.locator('.error-text')).toContainText(options.error, { timeout: 60_000 }) await expect(page.locator('.error-text')).toContainText(options.error, { timeout: 60_000 })
} }
// Return share link const shareLink = await page.locator('[data-testid="share-link"]').inputValue()
return await page.getByTestId('share-link').inputValue() return shareLink
} }
type CheckLinkBase = { export async function checkLinkForDownload(page: Page, link: string, text: string, checksum: string) {
link: string
text: string
password?: string
}
export async function checkLinkForDownload(page: Page, options: CheckLinkBase & { checksum: string }) {
await page.goto('/') await page.goto('/')
await page.goto(options.link) await page.goto(link)
if (options.password) await page.getByTestId('show-note-password').fill(options.password) await page.locator('[data-testid="show-note-button"]').click()
await page.getByTestId('show-note-button').click()
const [download] = await Promise.all([ const [download] = await Promise.all([
page.waitForEvent('download'), page.waitForEvent('download'),
page.getByTestId(`result`).locator(`text=${options.text}`).click(), page.locator(`[data-testid="result"] >> text=${text}`).click(),
]) ])
const path = await download.path() const path = await download.path()
if (!path) throw new Error('Download failed') if (!path) throw new Error('Download failed')
const cs = await getFileChecksum(path) const cs = await getFileChecksum(path)
await expect(cs).toBe(options.checksum) await expect(cs).toBe(checksum)
} }
export async function checkLinkForText(page: Page, link: string, text: string) {
export async function checkLinkForText(page: Page, options: CheckLinkBase) {
await page.goto('/') await page.goto('/')
await page.goto(options.link) await page.goto(link)
if (options.password) await page.getByTestId('show-note-password').fill(options.password) await page.locator('[data-testid="show-note-button"]').click()
await page.getByTestId('show-note-button').click() await expect(await page.locator('[data-testid="result"] >> .note').innerText()).toContain(text)
const text = await page.getByTestId('result').locator('.note').innerText()
await expect(text).toContain(options.text)
} }
export async function checkLinkDoesNotExist(page: Page, link: string) { export async function checkLinkDoesNotExist(page: Page, link: string) {
@@ -87,17 +64,8 @@ export async function checkLinkDoesNotExist(page: Page, link: string) {
await expect(page.locator('main')).toContainText('note was not found or was already deleted') await expect(page.locator('main')).toContainText('note was not found or was already deleted')
} }
export async function CLI(...args: string[]) { export async function getFileChecksum(file: string) {
return await exec('./packages/cli/dist/index.cjs', args, { const buffer = await readFile(file)
env: { const hash = createHash('sha3-256').update(buffer).digest('hex')
...process.env, return hash
CRYPTGEON_SERVER: 'http://localhost:1234',
},
})
}
export function getLinkFromCLI(output: string): string {
const match = output.match(/(https?:\/\/[^\s]+)/)
if (!match) throw new Error('No link found in CLI output')
return match[0]
} }

View File

@@ -1,13 +0,0 @@
import { test } from '@playwright/test'
import { Files, getFileChecksum } from '../../files'
import { checkLinkForDownload, createNote } from '../../utils'
test.describe('@web', () => {
test('multiple', async ({ page }) => {
const files = [Files.PDF, Files.Image]
const checksums = await Promise.all(files.map(getFileChecksum))
const link = await createNote(page, { files, views: 2 })
await checkLinkForDownload(page, { link, text: 'image.jpg', checksum: checksums[1] })
await checkLinkForDownload(page, { link, text: 'AES.pdf', checksum: checksums[0] })
})
})

View File

@@ -1,34 +0,0 @@
import { test } from '@playwright/test'
import { Files, getFileChecksum } from '../../files'
import { checkLinkDoesNotExist, checkLinkForDownload, checkLinkForText, createNote } from '../../utils'
test.describe('@web', () => {
test('simple pdf', async ({ page }) => {
const files = [Files.PDF]
const link = await createNote(page, { files })
await checkLinkForText(page, { link, text: 'AES.pdf' })
await checkLinkDoesNotExist(page, link)
})
test('pdf content', async ({ page }) => {
const files = [Files.PDF]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, { link, text: 'AES.pdf', checksum })
})
test('image content', async ({ page }) => {
const files = [Files.Image]
const checksum = await getFileChecksum(files[0])
const link = await createNote(page, { files })
await checkLinkForDownload(page, { link, text: 'image.jpg', checksum })
})
test('simple pdf with password', async ({ page }) => {
const files = [Files.PDF]
const password = 'password'
const link = await createNote(page, { files, password })
await checkLinkForText(page, { link, text: 'AES.pdf', password })
await checkLinkDoesNotExist(page, link)
})
})

View File

@@ -1,10 +0,0 @@
import { test } from '@playwright/test'
import { createNote } from '../../utils'
import { Files } from '../../files'
test.describe('@web', () => {
test.skip('to big zip', async ({ page }) => {
const files = [Files.Zip]
const link = await createNote(page, { files, error: 'note is to big' })
})
})

View File

@@ -1,16 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../../utils'
test.describe('@web', () => {
test('1 minute', async ({ page }) => {
const text = `Virtues value ascetic revaluation sea dead strong burying.`
const minutes = 1
const timeout = minutes * 60_000
test.setTimeout(timeout * 2)
const link = await createNote(page, { text, expiration: minutes })
await checkLinkForText(page, { link, text })
await checkLinkForText(page, { link, text })
await page.waitForTimeout(timeout)
await checkLinkDoesNotExist(page, link)
})
})

View File

@@ -1,17 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkForText, createNote } from '../../utils'
test.describe('@web', () => {
test('simple', async ({ page }) => {
const text = `Endless prejudice endless play derive joy eternal-return selfish burying. Of deceive play pinnacle faith disgust. Spirit reason salvation burying strong of joy ascetic selfish against merciful sea truth. Ubermensch moral prejudice derive chaos mountains ubermensch justice philosophy justice ultimate joy ultimate transvaluation. Virtues convictions war ascetic eternal-return spirit. Ubermensch transvaluation noble revaluation sexuality intentions salvation endless decrepit hope noble fearful. Justice ideal ultimate snare god joy evil sexuality insofar gains oneself ideal.`
const link = await createNote(page, { text })
await checkLinkForText(page, { link, text })
})
test('simple with password', async ({ page }) => {
const text = 'Foo bar'
const password = '123'
const shareLink = await createNote(page, { text, password })
await checkLinkForText(page, { link: shareLink, text, password })
})
})

View File

@@ -1,20 +0,0 @@
import { test } from '@playwright/test'
import { checkLinkDoesNotExist, checkLinkForText, createNote } from '../../utils'
test.describe('@web', () => {
test('only shown once', async ({ page }) => {
const text = `Christian victorious reason suicide dead. Right ultimate gains god hope truth burying selfish society joy. Ultimate.`
const link = await createNote(page, { text })
await checkLinkForText(page, { link, text })
await checkLinkDoesNotExist(page, link)
})
test('view 3 times', async ({ page }) => {
const text = `Justice holiest overcome fearful strong ultimate holiest christianity.`
const link = await createNote(page, { text, views: 3 })
await checkLinkForText(page, { link, text })
await checkLinkForText(page, { link, text })
await checkLinkForText(page, { link, text })
await checkLinkDoesNotExist(page, link)
})
})

View File

@@ -1,20 +0,0 @@
#!/usr/bin/env node
import shelljs from 'shelljs'
import { execSync } from 'node:child_process'
const VERSION = process.argv[2]
// https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
const semver =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm
if (!semver.test(VERSION)) {
console.error('Invalid version number')
process.exit(1)
}
// CLI
shelljs.sed('-i', /"version": ".*"/, `"version": "${process.argv[2]}"`, './packages/cli/package.json')
// Backend
shelljs.sed('-i', /^version = ".*"$/m, `version = "${process.argv[2]}"`, './packages/backend/Cargo.toml')
execSync('cargo check -p cryptgeon', { cwd: './packages/backend' })