Compare commits

..

18 Commits

Author SHA1 Message Date
0ac151f42c fix default import 2023-04-27 22:52:53 +02:00
86cdb6d5da cli 2023-04-27 19:01:32 +02:00
55b6e9ea51 don't use blob 2023-04-27 19:01:26 +02:00
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
luolongfei
9aaad5b910 update zh.json file. (#79) 2023-03-02 09:55:50 +01:00
dependabot[bot]
c246207420 Bump tokio from 1.24.1 to 1.25.0 in /packages/backend (#75)
Bumps [tokio](https://github.com/tokio-rs/tokio) from 1.24.1 to 1.25.0.
- [Release notes](https://github.com/tokio-rs/tokio/releases)
- [Commits](https://github.com/tokio-rs/tokio/compare/tokio-1.24.1...tokio-1.25.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-13 19:21:06 +01:00
7ee1b8370a update deps 2023-01-14 18:54:27 +01:00
e7750699cc chose: changelog 2023-01-14 18:40:45 +01:00
e14042ea28 chinese language code 2023-01-14 18:40:16 +01:00
6fb7518b6a moved to occulto 2023-01-13 21:24:27 +01:00
436ae2a7e5 move dev ports 2023-01-13 19:36:26 +01:00
fe5ce580ab locales 2023-01-04 19:45:25 +01:00
0f882da5d1 bump version 2023-01-04 19:45:18 +01:00
ad6f136dd0 qr code 2023-01-04 19:40:37 +01:00
61 changed files with 2754 additions and 1230 deletions

View File

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

View File

@@ -4,21 +4,20 @@ on:
workflow_dispatch:
push:
tags:
- "v*.*.*"
- 'v*.*.*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
with:
install: true
- name: Docker Labels
id: meta
uses: crazy-max/ghaction-docker-meta@v2
uses: docker/metadata-action@v4
with:
images: cupcakearmy/cryptgeon
tags: |
@@ -26,12 +25,12 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
uses: docker/build-push-action@v4
with:
platforms: linux/amd64,linux/arm64
push: true

View File

@@ -11,12 +11,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/setup-node@v3
with:
node-version: "16"
node-version-file: '.nvmrc'
- uses: docker/setup-qemu-action@v1
- uses: docker/setup-buildx-action@v1
- uses: docker/setup-qemu-action@v2
- uses: docker/setup-buildx-action@v2
with:
install: true
- name: Build docker image
@@ -29,8 +29,7 @@ jobs:
- name: Run your tests
run: npm test
- uses: actions/upload-artifact@v2
if: always()
- uses: actions/upload-artifact@v3
with:
name: test-results
path: test-results

16
.gitignore vendored
View File

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

1
.nvmrc Normal file
View File

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

View File

@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.2.0] - 2023-01-14
### Changed
- Default port is now 8000, not 5000.
- Moved to generic encryption library `occulto`.
### Fixed
- Bad chinese language code.
### Security
- Updated dependencies.
## [2.1.0] - 2023-01-04
### Added
- QR Code to more easily copy and share links.
## [2.0.7] - 2022-12-26
### Changed

View File

@@ -1,32 +1,29 @@
# FRONTEND
FROM node:16-alpine as client
WORKDIR /tmp
RUN npm install -g pnpm@7
COPY ./packages/frontend ./
RUN pnpm install
RUN pnpm exec svelte-kit sync
RUN npm install -g pnpm@8
COPY . .
RUN pnpm install --frozen-lockfile
# WORKDIR /tmp/packages/frontend
RUN pnpm run build
# BACKEND
FROM rust:1.64-alpine as backend
FROM rust:1.69-alpine as backend
WORKDIR /tmp
RUN apk add libc-dev openssl-dev alpine-sdk
COPY ./packages/backend/Cargo.* ./
# https://blog.rust-lang.org/2022/06/22/sparse-registry-testing.html
RUN rustup update nightly
ENV CARGO_UNSTABLE_SPARSE_REGISTRY=true
RUN cargo +nightly fetch
RUN cargo fetch
COPY ./packages/backend ./
RUN cargo +nightly build --release
RUN cargo build --release
# RUNNER
FROM alpine
WORKDIR /app
COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/build ./frontend
COPY --from=client /tmp/packages/frontend/build ./frontend
ENV FRONTEND_PATH="./frontend"
ENV REDIS="redis://redis/"
EXPOSE 5000
EXPOSE 8000
ENTRYPOINT [ "/app/cryptgeon" ]

View File

@@ -91,7 +91,7 @@ services:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
- 80:8000
```
### NGINX Proxy
@@ -138,9 +138,6 @@ cargo install cargo-watch
Make sure you have docker running.
> If you are on `macOS` you might need to disable AirPlay Receiver as it uses port 5000 (So stupid...)
> https://developer.apple.com/forums/thread/682332
```bash
pnpm run dev
```

View File

@@ -82,7 +82,7 @@ services:
environment:
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
- 80:8000
```
### NGINX 反向代理
@@ -148,9 +148,6 @@ cargo install cargo-watch
确保你的 Docker 正在运行
> 如果你用的是 `macOS` 的话你可能需要关闭 AirPlay 接收功能因为该功能需要占用 5000 端口...)
> https://developer.apple.com/forums/thread/682332
```bash
pnpm run dev
```

View File

@@ -5,12 +5,13 @@
},
{
"path": "packages/backend"
},
{
"path": "packages/frontend"
}
],
"settings": {
"cSpell.words": ["ciphertext", "cryptgeon"],
"i18n-ally.enabledFrameworks": ["svelte"],
"i18n-ally.keystyle": "nested",
"i18n-ally.localesPaths": ["packages/frontend/locales"]
"i18n-ally.localesPaths": ["packages/frontend/locales"],
"cSpell.words": ["cryptgeon"]
}
}

View File

@@ -10,10 +10,9 @@ services:
- 6379:6379
app:
image: cupcakearmy/cryptgeon:test
build: .
env_file: .dev.env
depends_on:
- redis
ports:
- 1234:5000
- 1234:8000

View File

@@ -15,4 +15,4 @@ services:
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
- 80:8000

View File

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

View File

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

View File

@@ -1,18 +1,17 @@
{
"scripts": {
"dev:docker": "docker-compose up redis",
"dev:docker": "docker-compose -f docker-compose.dev.yaml up redis",
"dev:packages": "pnpm --parallel run dev",
"dev:proxy": "node proxy.mjs",
"dev": "run-p dev:*",
"test": "playwright test --project chrome firefox safari",
"test:local": "playwright test --project local",
"test:server": "docker compose -f docker-compose.dev.yaml up",
"test:prepare": "docker compose -f docker-compose.dev.yaml build"
"test:prepare": "docker compose -f docker-compose.dev.yaml build",
"build": "pnpm run --recursive --filter=!@cryptgeon/backend build"
},
"devDependencies": {
"@playwright/test": "^1.29.1",
"@types/node": "^16.18.10",
"http-proxy": "^1.18.1",
"@playwright/test": "^1.32.3",
"@types/node": "^18.16.1",
"npm-run-all": "^4.1.5"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "cryptgeon"
version = "2.0.7"
version = "2.2.0"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021"
@@ -8,6 +8,9 @@ edition = "2021"
name = "cryptgeon"
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]

View File

@@ -1,6 +1,6 @@
{
"name": "backend",
"private": true,
"name": "@cryptgeon/backend",
"scripts": {
"dev": "cargo watch -x 'run --bin cryptgeon'",
"build": "cargo build --release",

View File

@@ -8,7 +8,7 @@ lazy_static! {
pub static ref FRONTEND_PATH: String =
std::env::var("FRONTEND_PATH").unwrap_or("../frontend/build".to_string());
pub static ref LISTEN_ADDR: String =
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:5000".to_string());
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:8000".to_string());
pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string());
}

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

@@ -0,0 +1,45 @@
{
"name": "@cryptgeon/cli",
"type": "module",
"scripts": {
"dev": "esbuild ./src/index.ts --bundle --platform=node --outfile=dist/index.cjs --watch",
"build": "esbuild ./src/index.ts --bundle --platform=node --outfile=dist/index.cjs",
"package": "pkg .",
"bin": "run-s build package"
},
"bin": {
"cryptgeon": "./dist/index.cjs"
},
"files": [
"dist"
],
"pkg": {
"scripts": [],
"targets": [
"node18-macos-arm64",
"node18-macos-x64",
"node18-linux-arm64",
"node18-linux-x64",
"node18-win-arm64",
"node18-win-x64"
],
"outputPath": "bin"
},
"devDependencies": {
"@types/inquirer": "^9.0.3",
"@types/mime": "^3.0.1",
"@types/node": "^18.16.1",
"esbuild": "^0.17.18",
"pkg": "^5.8.1",
"typescript": "^4.9.5"
},
"dependencies": {
"@commander-js/extra-typings": "^9.5.0",
"@cryptgeon/shared": "workspace:*",
"commander": "^9.5.0",
"inquirer": "^9.2.0",
"mime": "^3.0.0",
"occulto": "^2.0.1",
"pretty-bytes": "^6.1.0"
}
}

View File

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

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

@@ -0,0 +1,87 @@
#!/usr/bin/env node
import { Argument, Option, program } from '@commander-js/extra-typings'
import { setBase, status } from '@cryptgeon/shared'
import prettyBytes from 'pretty-bytes'
import { download } from './download.js'
import { parseFile, parseNumber } from './parsers.js'
import { uploadFiles, uploadText } from './upload.js'
import { exit } from './utils.js'
const defaultServer = process.env['CRYPTGEON_SERVER'] || 'https://cryptgeon.org'
const server = new Option('-s --server <url>', 'the cryptgeon server to use').default(defaultServer)
const files = new Argument('<file...>', 'Files to be sent').argParser(parseFile)
const text = new Argument('<text>', 'Text content of the note')
const password = new Option('-p --password <string>', 'manually set a password')
const url = new Argument('<url>', 'The url to open')
const views = new Option('-v --views <number>', 'Amount of views before getting destroyed').argParser(parseNumber)
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
async function checkConstrains(constrains: { views?: number; minutes?: number }) {
const { views, minutes } = constrains
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
if (!views && !minutes) constrains.views = 1
const response = await status()
if (views && views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${views} given.`)
if (minutes && minutes > response.max_expiration)
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${minutes} given.`)
}
program.name('cryptgeon').version('1.0.0').configureHelp({ showGlobalOptions: true })
program
.command('info')
.addOption(server)
.action(async (options) => {
setBase(options.server)
const response = await status()
const formatted = {
...response,
max_size: prettyBytes(response.max_size),
}
for (const key of Object.keys(formatted)) {
if (key.startsWith('theme_')) delete formatted[key as keyof typeof formatted]
}
console.table(formatted)
})
const send = program.command('send')
send
.command('file')
.addArgument(files)
.addOption(server)
.addOption(views)
.addOption(minutes)
.action(async (files, options) => {
setBase(options.server!)
await checkConstrains(options)
await uploadFiles(files, { views: options.views, expiration: options.minutes })
})
send
.command('text')
.addArgument(text)
.addOption(server)
.addOption(views)
.addOption(minutes)
.action(async (text, options) => {
setBase(options.server!)
await checkConstrains(options)
await uploadText(text, { views: options.views, expiration: options.minutes })
})
program
.command('open')
.addArgument(url)
.action(async (note, options) => {
try {
const url = new URL(note)
await download(url)
} catch {
exit('Invalid URL')
}
})
program.parse()

View File

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

View File

@@ -0,0 +1,49 @@
import { Blob } from 'node:buffer'
import { readFile, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { Adapters, BASE, create, FileDTO, Note } from '@cryptgeon/shared'
import * as mime from 'mime'
import { AES, Hex, TypedArray } from 'occulto'
import { exit } from './utils.js'
type UploadOptions = Pick<Note, 'views' | 'expiration'>
export async function upload(key: TypedArray, note: Note) {
try {
const result = await create(note)
const password = Hex.encode(key)
const url = `${BASE}/note/${result.id}#${password}`
console.log(`Note created under:\n\n${url}`)
} catch {
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: data,
type,
} satisfies FileDTO
})
)
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

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

View File

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

View File

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

View File

@@ -15,7 +15,8 @@
"encrypting": "verschlüsseln",
"decrypting": "entschlüsselt",
"uploading": "hochladen",
"downloading": "wird heruntergeladen"
"downloading": "wird heruntergeladen",
"qr_code": "qr-code"
},
"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.",

View File

@@ -15,7 +15,8 @@
"encrypting": "encrypting",
"decrypting": "decrypting",
"uploading": "uploading",
"downloading": "downloading"
"downloading": "downloading",
"qr_code": "qr code"
},
"home": {
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",

View File

@@ -15,7 +15,8 @@
"encrypting": "encriptando",
"decrypting": "descifrando",
"uploading": "cargando",
"downloading": "descargando"
"downloading": "descargando",
"qr_code": "código qr"
},
"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.",

View File

@@ -15,7 +15,8 @@
"encrypting": "cryptage",
"decrypting": "déchiffrer",
"uploading": "téléchargement",
"downloading": "téléchargement"
"downloading": "téléchargement",
"qr_code": "code qr"
},
"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.",

View File

@@ -15,7 +15,8 @@
"encrypting": "criptando",
"decrypting": "decifrando",
"uploading": "caricamento",
"downloading": "scaricando"
"downloading": "scaricando",
"qr_code": "codice qr"
},
"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.",

View File

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

View File

@@ -1,9 +1,11 @@
{
"private": true,
"name": "@cryptgeon/web",
"scripts": {
"postinstall": "svelte-kit sync",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview --port 3000",
"preview": "vite preview",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker --summary > licenses.csv",
"locale:download": "node scripts/locale.js",
@@ -11,27 +13,30 @@
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^9.3.0",
"@sveltejs/adapter-static": "^1.0.0",
"@sveltejs/kit": "^1.0.1",
"@types/dompurify": "^2.4.0",
"@lokalise/node-api": "^9.8.0",
"@sveltejs/adapter-static": "^1.0.6",
"@sveltejs/kit": "^1.15.9",
"@types/dompurify": "^3.0.2",
"@types/file-saver": "^2.0.5",
"@zerodevx/svelte-toast": "^0.7.2",
"adm-zip": "^0.5.10",
"dotenv": "^16.0.3",
"svelte": "^3.55.0",
"svelte": "^3.58.0",
"svelte-check": "^2.10.3",
"svelte-intl-precompile": "^0.10.1",
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.1",
"typescript": "^4.9.4",
"vite": "^4.0.3"
"tslib": "^2.5.0",
"typescript": "^4.9.5",
"vite": "^4.3.3"
},
"dependencies": {
"@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^4.5.10",
"copy-to-clipboard": "^3.3.3",
"dompurify": "^2.4.1",
"dompurify": "^3.0.2",
"file-saver": "^2.0.5",
"pretty-bytes": "^6.0.0"
"occulto": "^2.0.1",
"pretty-bytes": "^6.1.0",
"qrious": "^4.0.2"
}
}

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

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

View File

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

View File

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

View File

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

View File

@@ -1,89 +0,0 @@
export class Hex {
static encode(buffer: ArrayBuffer): string {
let s = ''
for (const i of new Uint8Array(buffer)) {
s += i.toString(16).padStart(2, '0')
}
return s
}
static decode(s: string): ArrayBuffer {
const size = s.length / 2
const buffer = new Uint8Array(size)
for (let i = 0; i < size; i++) {
const idx = i * 2
const segment = s.slice(idx, idx + 2)
buffer[i] = parseInt(segment, 16)
}
return buffer
}
}
export class ArrayBufferUtils {
static async toString(buffer: ArrayBuffer): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(new Blob([buffer]))
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
static async fromString(s: string): Promise<ArrayBuffer> {
return fetch(s)
.then((r) => r.blob())
.then((b) => b.arrayBuffer())
}
}
export class Keys {
public static async generateKey(size: 128 | 192 | 256 = 256): Promise<CryptoKey> {
const key = await window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: size,
},
true,
['encrypt', 'decrypt']
)
return key
}
public static async export(key: CryptoKey): Promise<string> {
return Hex.encode(await window.crypto.subtle.exportKey('raw', key))
}
public static async import(key: string): Promise<CryptoKey> {
return window.crypto.subtle.importKey('raw', Hex.decode(key), { name: 'AES-GCM' }, true, [
'encrypt',
'decrypt',
])
}
}
export class Crypto {
private static ALG = 'AES-GCM'
private static DELIMITER = ':::'
public static getRandomBytes(size: number): Uint8Array {
return window.crypto.getRandomValues(new Uint8Array(size))
}
public static async encrypt(plaintext: ArrayBuffer, key: CryptoKey): Promise<string> {
const iv = this.getRandomBytes(12) // AES-GCM needs a 96bit IV
const encrypted: ArrayBuffer = await window.crypto.subtle.encrypt(
{ name: this.ALG, iv },
key,
plaintext
)
const data = [Hex.encode(iv), await ArrayBufferUtils.toString(encrypted)].join(this.DELIMITER)
return data
}
public static async decrypt(ciphertext: string, key: CryptoKey): Promise<ArrayBuffer> {
const splitted = ciphertext.split(this.DELIMITER)
const iv = Hex.decode(splitted[0])
const encrypted = await ArrayBufferUtils.fromString(splitted[1])
const plaintext = await window.crypto.subtle.decrypt({ name: this.ALG, iv }, key, encrypted)
return plaintext
}
}

View File

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

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import type { Note } from '$lib/api'
import { status } from '$lib/stores/status'
import Switch from '$lib/ui/Switch.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import type { Note } from '@cryptgeon/shared'
export let note: Note
export let timeExpiration = false

View File

@@ -0,0 +1,42 @@
<script lang="ts">
// @ts-ignore
import QR from 'qrious'
import { t } from 'svelte-intl-precompile'
import { getCSSVariable } from '$lib/utils'
export let value: string
let canvas: HTMLCanvasElement
$: {
new QR({
value,
level: 'Q',
size: 800,
background: getCSSVariable('--ui-bg-0'),
foreground: getCSSVariable('--ui-text-0'),
element: canvas,
})
}
</script>
<small>{$t('common.qr_code')}</small>
<div>
<canvas bind:this={canvas} />
</div>
<style>
div {
padding: 0.5rem;
width: fit-content;
border: 2px solid var(--ui-bg-1);
background-color: var(--ui-bg-0);
margin-top: 0.125rem;
}
canvas {
width: 100%;
height: auto;
}
</style>

View File

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

View File

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

View File

@@ -8,9 +8,9 @@
import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile'
import type { FileDTO, NotePublic } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils'
import type { FileDTO, NotePublic } from '@cryptgeon/shared'
export let note: DecryptedNote

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { Crypto, Hex } from '$lib/crypto'
import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils'
import { getRandomBytes, Hex } from 'occulto'
export let label: string = ''
export let value: any
@@ -23,8 +23,9 @@
function toggle() {
hidden = !hidden
}
function randomFN() {
value = Hex.encode(Crypto.getRandomBytes(32))
async function randomFN() {
value = Hex.encode(await getRandomBytes(32))
}
</script>

View File

@@ -1,4 +1,4 @@
import copyToClipboard from 'copy-to-clipboard'
import * as copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile'
import { get } from 'svelte/store'
@@ -9,3 +9,8 @@ export function copy(value: string) {
const msg = get(t)('common.copied_to_clipboard')
notify.success(msg)
}
export function getCSSVariable(variable: string): string {
if (typeof window === 'undefined') return ''
return window.getComputedStyle(window.document.body).getPropertyValue(variable)
}

View File

@@ -1,11 +1,8 @@
<script lang="ts">
import { AES, Hex } from 'occulto'
import { t } from 'svelte-intl-precompile'
import { blur } from 'svelte/transition'
import { Adapters } from '$lib/adapters'
import type { FileDTO, Note } from '$lib/api'
import { create, PayloadToLargeError } from '$lib/api'
import { Keys } from '$lib/crypto'
import { status } from '$lib/stores/status'
import { notify } from '$lib/toast'
import AdvancedParameters from '$lib/ui/AdvancedParameters.svelte'
@@ -16,6 +13,8 @@
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte'
import type { FileDTO, Note } from '@cryptgeon/shared'
import { Adapters, create, PayloadToLargeError } from '@cryptgeon/shared'
let note: Note = {
contents: '',
@@ -58,8 +57,8 @@
try {
loading = $t('common.encrypting')
const key = await Keys.generateKey()
const password = await Keys.export(key)
const key = await AES.generateKey()
const password = Hex.encode(key)
const data: Note = {
contents: '',

View File

@@ -1,13 +1,12 @@
<script lang="ts">
import { Hex } from 'occulto'
import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile'
import { Adapters } from '$lib/adapters'
import { get, info } from '$lib/api'
import { Keys } from '$lib/crypto'
import Button from '$lib/ui/Button.svelte'
import Loader from '$lib/ui/Loader.svelte'
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
import { Adapters, get, info } from '@cryptgeon/shared'
import type { PageData } from './$types'
export let data: PageData
@@ -43,7 +42,7 @@
loading = $t('common.downloading')
const data = await get(id)
loading = $t('common.decrypting')
const key = await Keys.import(password)
const key = Hex.decode(password)
switch (data.meta.type) {
case 'text':
note = {

View File

@@ -1,12 +1,13 @@
import { sveltekit } from '@sveltejs/kit/vite'
import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
const port = 8001
/** @type {import('vite').UserConfig} */
const config = {
clearScreen: false,
server: {
port: 3000,
},
server: { port },
preview: { port },
plugins: [sveltekit(), precompileIntl('locales')],
}

View File

@@ -0,0 +1,12 @@
{
"private": true,
"name": "@cryptgeon/proxy",
"type": "module",
"main": "./proxy.js",
"scripts": {
"dev": "node ."
},
"dependencies": {
"http-proxy": "^1.18.1"
}
}

View File

@@ -3,12 +3,13 @@ import httpProxy from 'http-proxy'
const proxy = httpProxy.createProxyServer()
proxy.on('error', function (err, req, res) {
console.error(err)
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('500 Internal Server Error')
})
const server = http.createServer(function (req, res) {
const target = req.url.startsWith('/api/') ? 'http://localhost:5000' : 'http://localhost:3000'
const target = req.url.startsWith('/api/') ? 'http://127.0.0.1:8000' : 'http://localhost:8001'
proxy.web(req, res, { target })
})
server.listen(1234)

View File

@@ -0,0 +1,22 @@
{
"private": true,
"name": "@cryptgeon/shared",
"type": "module",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"dev": "tsc -w",
"build": "tsc"
},
"devDependencies": {
"typescript": "^4.9.5"
},
"dependencies": {
"occulto": "^2.0.1"
}
}

View File

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

106
packages/shared/src/api.ts Normal file
View File

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

View File

@@ -0,0 +1,2 @@
export * from './adapters.js'
export * from './api.js'

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"incremental": true,
"composite": true,
"target": "es2022",
"module": "es2022",
"rootDir": "./src",
"moduleResolution": "node",
"outDir": "./dist",
"strict": true
}
}

View File

@@ -10,7 +10,6 @@ const config: PlaywrightTestConfig = {
outputDir: './test-results',
testDir: './test',
timeout: 60_000,
testIgnore: ['file/too-big.spec.ts'],
webServer: {
command: 'docker compose -f docker-compose.dev.yaml up',
@@ -25,7 +24,6 @@ const config: PlaywrightTestConfig = {
{
name: 'local',
use: { ...devices['Desktop Chrome'] },
// testMatch: 'file/too-big.spec.ts',
},
],
}

2302
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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