Compare commits

..

22 Commits

Author SHA1 Message Date
b84df2866b build matrix 2022-11-02 13:36:56 +01:00
3d4fef7c23 try with matrix build 2022-11-02 13:34:41 +01:00
9d787008a4 also build docker when testing 2022-11-02 13:29:19 +01:00
687f26bb40 name the workflow 2022-11-02 13:26:57 +01:00
371a869800 use nightly cargo with sparse registry 2022-11-02 13:24:30 +01:00
321c303a8a changelog 2022-10-29 19:45:14 +02:00
2f176d84e9 wrong docker compose 2022-10-29 19:43:59 +02:00
67d4f09bd7 #62 (#63)
* #62 add theme options for title and favicon

* docs

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

* pin svelte kit version & parallel execution

* update svelte kit

* correct test result assets

* add timeout

* correct locale path

* simplify crypto

* fix for #58

* add verbosity flag

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

* add toasts and update deps

* changelog

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

* try playwrigth

* testing

* add pr support

* not on each commit

* add test ids

* make backend more configuratble

* 2.0.2

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

* try playwrigth

* testing

* add pr support

* not on each commit
2022-07-19 14:12:51 +02:00
a5d98b76bd 2.0.1 (#40)
* locale from lokalise

* version bump

* update dependencies

* show size with overhead

* use base64 instead of hex and refactor a bit

* changelog & readme

* size limit

* locale

* add sync for svelte

* refarcor create & add loading animation

* changelog
2022-07-19 10:27:23 +02:00
9590c9b567 2 (#38)
* use redis

* update frontend and switch sanitize library

* changelog

* theming

* docker image

* documentation

* changelog

* clear up limit sizes

* version bump

* version bump
2022-07-16 14:16:54 +02:00
106 changed files with 2998 additions and 2221 deletions

View File

@@ -1,15 +1,15 @@
*
!/backend/src
!/backend/Cargo.lock
!/backend/Cargo.toml
!/packages/backend/src
!/packages/backend/Cargo.lock
!/packages/backend/Cargo.toml
!/frontend/locales
!/frontend/src
!/frontend/static
!/frontend/.npmrc
!/frontend/package.json
!/frontend/pnpm-lock.yaml
!/frontend/svelte.config.js
!/frontend/tsconfig.json
!/frontend/vite.config.js
!/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

1
.gitattributes vendored
View File

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

View File

@@ -1,4 +1,4 @@
name: ci
name: Publish
on:
workflow_dispatch:
@@ -9,6 +9,9 @@ on:
jobs:
docker:
runs-on: ubuntu-latest
strategy:
matrix:
arch: ["linux/amd64", "linux/arm64"]
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
@@ -34,7 +37,7 @@ jobs:
id: docker_build
uses: docker/build-push-action@v2
with:
platforms: linux/amd64,linux/arm64
platforms: ${{ matrix.arch }}
push: true
tags: ${{ steps.meta.outputs.tags }}
- name: Image digest

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

@@ -0,0 +1,59 @@
name: Test
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
arch: ["linux/amd64", "linux/arm64"]
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
with:
install: true
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
platforms: ${{ matrix.arch }}
push: false
test:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "16"
- uses: pnpm/action-setup@v2
with:
version: 7
- uses: actions-rs/toolchain@v1
with:
toolchain: 1.64
- name: Prepare
run: |
pnpm install
pnpm run test:prepare
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run your tests
run: pnpm run test:run
- name: Upload test results
if: always()
uses: actions/upload-artifact@v2
with:
name: test-results
path: test-results

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ node_modules
/build
/functions
.env
General
test-results

View File

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

View File

@@ -5,16 +5,65 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.0.4] - 2022-10-29
### Added
- `THEME_PAGE_TITLE`
- `THEME_FAVICON`
## [2.0.3] - 2022-10-07
### Added
- Flag for verbosity.
### Fixed
- #58 Fixed bug in the max views frontend form.
## [2.0.2] - 2022-07-20
### Added
- Toasts for events.
- E2E Tests.
- Make backend more configurable
## [2.0.1] - 2022-07-18
### Added
- Max file size on the client now.
- Loading information.
### Changed
- Changed encoding from hex to base64.
- Chinese language code.
- Notable speed improvements for big files.
## [2.0.0] - 2022-07-16
### Added
- Theming for logo and description text.
### Changed
- Moved to redis.
- New html sanitizing library.
## [2.0.0-rc.0] - 2022-07-15
### Added
- Theming for logo and description text
- Theming for logo and description text.
### Changed
- Moved to redis
- New html sanitizing library
- Moved to redis.
- New html sanitizing library.
## [1.5.3] - 2022-06-07

View File

@@ -2,24 +2,31 @@
FROM node:16-alpine as client
WORKDIR /tmp
RUN npm install -g pnpm@7
COPY ./frontend ./
COPY ./packages/frontend ./
RUN pnpm install
RUN pnpm exec svelte-kit sync
RUN pnpm run build
# BACKEND
FROM rust:1.61-alpine as backend
FROM rust:1.64-alpine as backend
WORKDIR /tmp
RUN apk add libc-dev openssl-dev alpine-sdk
COPY ./backend ./
RUN cargo build --release
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
COPY ./packages/backend ./
RUN cargo +nightly build --release
# RUNNER
FROM alpine
WORKDIR /app
COPY --from=backend /tmp/target/release/cryptgeon .
COPY --from=client /tmp/build ./frontend/build
ENV REDIS=redis://redis/
COPY --from=client /tmp/build ./frontend
ENV FRONTEND_PATH="./frontend"
ENV REDIS="redis://redis/"
EXPOSE 5000
ENTRYPOINT [ "/app/cryptgeon" ]

View File

@@ -24,9 +24,9 @@ _cryptgeon_ is a secure, open source sharing note or file service inspired by [_
>
> Thanks to [Lokalise](https://lokalise.com/) for providing free access to their platform.
## Demo
## Live Service / Demo
Check out the demo and see for yourself https://cryptgeon.nicco.io.
Check out the live service / demo and see for yourself [cryptgeon.org](https://cryptgeon.org)
## Features
@@ -51,14 +51,17 @@ of the notes even if it tried to.
## Environment Variables
| Variable | Default | Description |
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. |
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). `512 MiB` is the maximum allowed |
| ------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REDIS` | `redis://redis/` | Redis URL to connect to. [According to format](https://docs.rs/redis/latest/redis/#connection-parameters) |
| `SIZE_LIMIT` | `1 KiB` | Max size for body. Accepted values according to [byte-unit](https://docs.rs/byte-unit/). <br> `512 MiB` is the maximum allowed. <br> The frontend will show that number including the ~35% encoding overhead. |
| `MAX_VIEWS` | `100` | Maximal number of views. |
| `MAX_EXPIRATION` | `360` | Maximal expiration in minutes. |
| `ALLOW_ADVANCED` | `true` | Allow custom configuration. If set to `false` all notes will be one view only. |
| `VERBOSITY` | `warn` | Verbosity level for the backend. [Possible values](https://docs.rs/env_logger/latest/env_logger/#enabling-logging) are: `error`, `warn`, `info`, `debug`, `trace` |
| `THEME_IMAGE` | `""` | Custom image for replacing the logo. Must be publicly reachable |
| `THEME_TEXT` | `""` | Custom text for replacing the description below the logo |
| `THEME_PAGE_TITLE` | `""` | Custom text the page title |
| `THEME_FAVICON` | `""` | Custom url for the favicon. Must be publicly reachable |
## Deployment
@@ -76,13 +79,17 @@ 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: 4M
# Size limit for a single note.
SIZE_LIMIT: 4 MiB
ports:
- 80:5000
```
@@ -93,39 +100,20 @@ See the [examples/nginx](https://github.com/cupcakearmy/cryptgeon/tree/main/exam
### Traefik 2
Assumptions:
See the [examples/traefik](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/traefik) folder.
- External proxy docker network `proxy`
- A certificate resolver `le`
- A https entrypoint `secure`
- Domain name `example.org`
### Scratch
```yaml
version: '3.8'
See the [examples/scratch](https://github.com/cupcakearmy/cryptgeon/tree/main/examples/scratch) folder. There you'll find a guide how to setup a server and install cryptgeon from scratch.
networks:
proxy:
external: true
### Synology
services:
redis:
image: redis:7-alpine
restart: unless-stopped
There is a [guide](https://mariushosting.com/how-to-install-cryptgeon-on-your-synology-nas/) you can follow.
app:
image: cupcakearmy/cryptgeon:latest
restart: unless-stopped
depends_on:
- redis
networks:
- default
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.cryptgeon.rule=Host(`example.org`)
- traefik.http.routers.cryptgeon.entrypoints=secure
- traefik.http.routers.cryptgeon.tls.certresolver=le
```
### YouTube Guides
- English by [DB Tech](https://www.youtube.com/watch?v=S0jx7wpOfNM) [Previous Video](https://www.youtube.com/watch?v=JhpIatD06vE)
- German by [ApfelCast](https://www.youtube.com/watch?v=84ZMbE9AkHg)
## Development
@@ -165,6 +153,29 @@ Running `pnpm run dev` in the root folder will start the following things:
You can see the app under [localhost:1234](http://localhost:1234).
## Tests
Tests are end to end tests written with Playwright.
```sh
pnpm run test:prepare
docker compose up redis -d
pnpm run test:server
# In another terminal.
# Use the test or test:local script. The local version only runs in one browser for quicker development.
pnpm run test:local
```
## Security
Please refer to the security section [here](./SECURITY.md).
###### Attributions
Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>
- Test data:
- Text for tests [Nietzsche Ipsum](https://nietzsche-ipsum.com/)
- [AES Paper](https://www.cs.miami.edu/home/burt/learning/Csc688.012/rijndael/rijndael_doc_V2.pdf)
- [Unsplash Pictures](https://unsplash.com/)
- Loading animation by [Nikhil Krishnan](https://codepen.io/nikhil8krishnan/pen/rVoXJa)
- Icons made by <a href="https://www.freepik.com" title="Freepik">freepik</a> from <a href="https://www.flaticon.com/" title="Flaticon">www.flaticon.com</a>

View File

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

18
SECURITY.md Normal file
View File

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

View File

@@ -1,18 +0,0 @@
use actix_web::web;
use byte_unit::Byte;
use mime;
lazy_static! {
pub static ref LIMIT: usize =
Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string()))
.unwrap()
.get_bytes() as usize;
}
pub fn init(cfg: &mut web::ServiceConfig) {
let json = web::JsonConfig::default().limit(*LIMIT);
let plain = web::PayloadConfig::default()
.limit(*LIMIT)
.mimetype(mime::STAR_STAR);
cfg.app_data(json).app_data(plain);
}

View File

@@ -10,10 +10,11 @@ services:
- 6379:6379
app:
build: .
# build: .
image: cupcakearmy/cryptgeon
depends_on:
- redis
environment:
SIZE_LIMIT: 128M
SIZE_LIMIT: 10 MiB
ports:
- 80:5000
- 1234:5000

View File

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

View File

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

1609
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +0,0 @@
export class Hex {
static encode(buffer: ArrayBuffer): string {
let s = ''
for (const i of new Uint8Array(buffer)) {
s += i.toString(16).padStart(2, '0')
}
return s
}
static decode(s: string): ArrayBuffer {
const size = s.length / 2
const buffer = new Uint8Array(size)
for (let i = 0; i < size; i++) {
const idx = i * 2
const segment = s.slice(idx, idx + 2)
buffer[i] = parseInt(segment, 16)
}
return buffer
}
}
const ALG = 'AES-GCM'
export function getRandomBytes(size = 16): Uint8Array {
return window.crypto.getRandomValues(new Uint8Array(size))
}
export function getKeyFromString(password: string) {
return window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveBits', 'deriveKey']
)
}
export async function getDerivedForKey(key: CryptoKey, salt: ArrayBuffer) {
const iterations = 100_000
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations,
hash: 'SHA-512',
},
key,
{ name: ALG, length: 256 },
true,
['encrypt', 'decrypt']
)
}
export async function encrypt(plaintext: string, key: CryptoKey) {
const salt = getRandomBytes(16)
const derived = await getDerivedForKey(key, salt)
const iv = getRandomBytes(16)
const encrypted = await window.crypto.subtle.encrypt(
{ name: ALG, iv },
derived,
new TextEncoder().encode(plaintext)
)
return [salt, iv, encrypted].map(Hex.encode).join(':')
}
export async function decrypt(ciphertext: string, key: CryptoKey) {
const [salt, iv, encrypted] = ciphertext.split(':').map(Hex.decode)
const derived = await getDerivedForKey(key, salt)
const plaintext = await window.crypto.subtle.decrypt({ name: ALG, iv }, derived, encrypted)
return new TextDecoder().decode(plaintext)
}

View File

@@ -1,13 +0,0 @@
export class Files {
static toString(f: File | Blob): Promise<string> {
const reader = new window.FileReader()
reader.readAsDataURL(f)
return new Promise((resolve) => {
reader.onloadend = () => resolve(reader.result as string)
})
}
static async fromString(s: string): Promise<Blob> {
return fetch(s).then((r) => r.blob())
}
}

View File

@@ -1,201 +0,0 @@
<script lang="ts">
import type { Note } from '$lib/api'
import { create,PayloadToLargeError } from '$lib/api'
import { encrypt,getKeyFromString,getRandomBytes,Hex } from '$lib/crypto'
import { status } from '$lib/stores/status'
import Button from '$lib/ui/Button.svelte'
import FileUpload from '$lib/ui/FileUpload.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import { t } from 'svelte-intl-precompile'
import { blur } from 'svelte/transition'
let note: Note = {
contents: '',
meta: { type: 'text' },
views: 1,
expiration: 60,
}
let result: { password: string; id: string } | null = null
let advanced = false
let file = false
let timeExpiration = false
let message = ''
let loading = false
let error: string | null = null
$: if (!advanced) {
note.views = 1
timeExpiration = false
}
$: {
message = $t('home.explanation', {
values: {
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
values: { n: (timeExpiration ? note.expiration : note.views) ?? '?' },
}),
},
})
}
$: note.meta.type = file ? 'file' : 'text'
$: if (!file) {
note.contents = ''
}
class EmptyContentError extends Error {}
async function submit() {
try {
error = null
loading = true
const password = Hex.encode(getRandomBytes(32))
const key = await getKeyFromString(password)
if (note.contents === '') throw new EmptyContentError()
const data: Note = {
contents: await encrypt(note.contents, key),
meta: note.meta,
}
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
else data.views = parseInt(note.views as any)
const response = await create(data)
result = {
password: password,
id: response.id,
}
} catch (e) {
if (e instanceof PayloadToLargeError) {
error = $t('home.errors.note_to_big')
} else if (e instanceof EmptyContentError) {
error = $t('home.errors.empty_content')
} else {
error = $t('home.errors.note_error')
}
} finally {
loading = false
}
}
function reset() {
window.location.reload()
}
</script>
{#if result}
<TextInput
type="text"
readonly
label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}"
copy
/>
<br />
<p>
{@html $t('home.new_note_notice')}
</p>
<br />
<Button on:click={reset}>{$t('home.new_note')}</Button>
{:else}
<p>
{@html $status?.theme_text || $t('home.intro')}
</p>
<form on:submit|preventDefault={submit}>
<fieldset disabled={loading}>
{#if file}
<FileUpload label={$t('common.file')} on:file={(f) => (note.contents = f.detail)} />
{:else}
<TextArea label={$t('common.note')} bind:value={note.contents} placeholder="..." />
{/if}
<div class="bottom">
<Switch class="file" label={$t('common.file')} bind:value={file} />
{#if $status?.allow_advanced}
<Switch label={$t('common.advanced')} bind:value={advanced} />
{/if}
<div class="grow" />
<div class="tr">
<small>{$t('common.max')}: <MaxSize /> </small>
<br />
<Button type="submit">{$t('common.create')}</Button>
</div>
</div>
{#if error}
<div class="error-text">{error}</div>
{/if}
<p>
<br />
{#if loading}
{$t('common.loading')}
{:else}
{message}
{/if}
</p>
{#if advanced}
<div transition:blur={{ duration: 250 }}>
<br />
<div class="fields">
<TextInput
type="number"
label={$t('common.views', { values: { n: 0 } })}
bind:value={note.views}
disabled={timeExpiration}
max={$status?.max_views}
validate={(v) =>
($status && v < $status?.max_views) ||
$t('home.errors.max', { values: { n: $status?.max_views ?? 0 } })}
/>
<div class="middle-switch">
<Switch label={$t('common.mode')} bind:value={timeExpiration} color={false} />
</div>
<TextInput
type="number"
label={$t('common.minutes', { values: { n: 0 } })}
bind:value={note.expiration}
disabled={!timeExpiration}
max={$status?.max_expiration}
validate={(v) =>
($status && v < $status?.max_expiration) ||
$t('home.errors.max', { values: { n: $status?.max_expiration ?? 0 } })}
/>
</div>
</div>
{/if}
</fieldset>
</form>
{/if}
<style>
.bottom {
display: flex;
align-items: flex-end;
margin-top: 0.5rem;
}
.bottom :global(.file) {
margin-right: 0.5rem;
}
.grow {
flex: 1;
}
.middle-switch {
margin: 0 1rem;
}
.error-text {
margin-top: 0.5rem;
}
.fields {
display: flex;
}
</style>

View File

@@ -1,83 +0,0 @@
<script context="module" lang="ts">
import type { Load } from '@sveltejs/kit'
export const load: Load = async ({ params }) => {
return {
props: params,
}
}
</script>
<script lang="ts">
import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile'
import type { NotePublic } from '$lib/api'
import { get, info } from '$lib/api'
import { decrypt, getKeyFromString } from '$lib/crypto'
import Button from '$lib/ui/Button.svelte'
import ShowNote from '$lib/ui/ShowNote.svelte'
export let id: string
let password: string
let note: NotePublic | null = null
let exists = false
let loading = true
let error = false
onMount(async () => {
try {
loading = true
error = false
password = window.location.hash.slice(1)
await info(id)
exists = true
} catch {
exists = false
} finally {
loading = false
}
})
async function show() {
try {
error = false
loading = true
const data = note || (await get(id)) // Don't get the content twice on wrong password.
const key = await getKeyFromString(password)
data.contents = await decrypt(data.contents, key)
note = data
} catch {
error = true
} finally {
loading = false
}
}
</script>
{#if !loading}
{#if !exists}
<p class="error-text">{$t('show.errors.not_found')}</p>
{:else if note && !error}
<ShowNote {note} />
{:else}
<form on:submit|preventDefault={show}>
<fieldset>
<p>{$t('show.explanation')}</p>
<Button type="submit">{$t('show.show_note')}</Button>
{#if error}
<br />
<p class="error-text">
{$t('show.errors.decryption_failed')}
<br />
</p>
{/if}
</fieldset>
</form>
{/if}
{/if}
{#if loading}
<p>{$t('common.loading')}</p>
{/if}

View File

@@ -1,12 +1,17 @@
{
"scripts": {
"dev:docker": "docker-compose up redis",
"dev:backend": "cd backend && cargo watch -x 'run --bin cryptgeon'",
"dev:front": "pnpm --prefix frontend run dev",
"dev:packages": "pnpm --parallel run dev",
"dev:proxy": "node proxy.mjs",
"dev": "run-p dev:*"
"dev": "run-p dev:*",
"test:run": "playwright test --project chrome firefox safari",
"test:local": "playwright test --project local",
"test:server": "pnpm --parallel run test:server",
"test:prepare": "pnpm --parallel run test:prepare"
},
"devDependencies": {
"@playwright/test": "^1.25.1",
"@types/node": "^16.11.57",
"http-proxy": "^1.18.1",
"npm-run-all": "^4.1.5"
}

View File

@@ -424,7 +424,7 @@ dependencies = [
[[package]]
name = "cryptgeon"
version = "2.0.0-rc.0"
version = "2.0.4"
dependencies = [
"actix-files",
"actix-web",

View File

@@ -1,6 +1,6 @@
[package]
name = "cryptgeon"
version = "2.0.0-rc.0"
version = "2.0.4"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2021"

View File

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

View File

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

View File

@@ -1,18 +1,23 @@
use byte_unit::Byte;
// General
// Internal
lazy_static! {
pub static ref VERSION: String = option_env!("CARGO_PKG_VERSION")
.unwrap_or("Unknown")
.to_string();
pub static ref FRONTEND_PATH: String =
std::env::var("FRONTEND_PATH").unwrap_or("../frontend/build".to_string());
pub static ref LISTEN_ADDR: String =
std::env::var("LISTEN_ADDR").unwrap_or("0.0.0.0:5000".to_string());
pub static ref VERBOSITY: String = std::env::var("VERBOSITY").unwrap_or("warn".to_string());
}
// CONFIG
lazy_static! {
pub static ref LIMIT: u32 =
pub static ref LIMIT: usize =
Byte::from_str(std::env::var("SIZE_LIMIT").unwrap_or("1 KiB".to_string()))
.unwrap()
.get_bytes() as u32;
.get_bytes() as usize;
pub static ref MAX_VIEWS: u32 = std::env::var("MAX_VIEWS")
.unwrap_or("100".to_string())
.parse()
@@ -37,4 +42,12 @@ lazy_static! {
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref THEME_PAGE_TITLE: String = std::env::var("THEME_PAGE_TITLE")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref THEME_FAVICON: String = std::env::var("THEME_FAVICON")
.unwrap_or("".to_string())
.parse()
.unwrap();
}

View File

@@ -18,10 +18,11 @@ mod store;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or("warning"));
env_logger::init_from_env(env_logger::Env::new().default_filter_or(config::VERBOSITY.as_str()));
return HttpServer::new(|| {
App::new()
.wrap(Logger::new("%a \"%r\" %s %b %T"))
.wrap(Logger::new("\"%r\" %s %b %T"))
.wrap(middleware::Compress::default())
.wrap(middleware::DefaultHeaders::default())
.configure(size::init)
@@ -29,7 +30,7 @@ async fn main() -> std::io::Result<()> {
.configure(client::init)
.default_service(web::to(client::index))
})
.bind("0.0.0.0:5000")?
.bind(config::LISTEN_ADDR.to_string())?
.run()
.await;
}

View File

@@ -0,0 +1,12 @@
use crate::config;
use actix_web::web;
use mime;
pub fn init(cfg: &mut web::ServiceConfig) {
let json = web::JsonConfig::default().limit(*config::LIMIT);
let plain = web::PayloadConfig::default()
.limit(*config::LIMIT)
.mimetype(mime::STAR_STAR);
// cfg.app_data(plain);
cfg.app_data(json).app_data(plain);
}

View File

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

View File

@@ -7,12 +7,14 @@ use crate::status::Status;
async fn get_status() -> impl Responder {
return HttpResponse::Ok().json(Status {
version: config::VERSION.to_string(),
max_size: *config::LIMIT,
max_size: *config::LIMIT as u32,
max_views: *config::MAX_VIEWS,
max_expiration: *config::MAX_EXPIRATION,
allow_advanced: *config::ALLOW_ADVANCED,
theme_image: config::THEME_IMAGE.to_string(),
theme_text: config::THEME_TEXT.to_string(),
theme_page_title: config::THEME_PAGE_TITLE.to_string(),
theme_favicon: config::THEME_FAVICON.to_string()
});
}

View File

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

View File

@@ -4,13 +4,18 @@
"file": "Datei",
"advanced": "erweitert",
"create": "erstellen",
"loading": "Läd...",
"loading": "läd",
"mode": "Modus",
"views": "{n, plural, =0 {Ansichten} =1 {1 Ansicht} other {# Ansichten}}",
"minutes": "{n, plural, =0 {Minuten} =1 {1 Minute} other {# Minuten}}",
"max": "max",
"share_link": "Link teilen",
"copy_clipboard": "in die Zwischenablage kopieren"
"copy_clipboard": "in die Zwischenablage kopieren",
"copied_to_clipboard": "in die Zwischenablage kopiert",
"encrypting": "verschlüsseln",
"decrypting": "entschlüsselt",
"uploading": "hochladen",
"downloading": "wird heruntergeladen"
},
"home": {
"intro": "Senden Sie ganz einfach <i>vollständig verschlüsselte</i>, sichere Notizen oder Dateien mit einem Klick. Erstellen Sie einfach eine Notiz und teilen Sie den Link.",
@@ -23,12 +28,15 @@
"max": "max: {n}",
"empty_content": "Notiz ist leer."
},
"copied_to_clipboard": "in die Zwischenablage kopiert 🔗"
"messages": {
"note_created": "notiz erstellt."
}
},
"show": {
"errors": {
"not_found": "wurde nicht gefunden oder wurde bereits gelöscht.",
"decryption_failed": "falsches Passwort. konnte nicht entziffert werden. wahrscheinlich ein defekter Link. Notiz wurde zerstört."
"decryption_failed": "falsches Passwort. konnte nicht entziffert werden. wahrscheinlich ein defekter Link. Notiz wurde zerstört.",
"unsupported_type": "nicht unterstützter Notiztyp."
},
"explanation": "Klicken Sie unten, um die Notiz anzuzeigen und zu löschen, wenn der Zähler sein Limit erreicht hat",
"show_note": "Notiz anzeigen",

View File

@@ -4,13 +4,18 @@
"file": "file",
"advanced": "advanced",
"create": "create",
"loading": "loading...",
"loading": "loading",
"mode": "mode",
"views": "{n, plural, =0 {views} =1 {1 view} other {# views}}",
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
"max": "max",
"share_link": "share link",
"copy_clipboard": "copy to clipboard"
"copy_clipboard": "copy to clipboard",
"copied_to_clipboard": "copied to clipboard",
"encrypting": "encrypting",
"decrypting": "decrypting",
"uploading": "uploading",
"downloading": "downloading"
},
"home": {
"intro": "Easily send <i>fully encrypted</i>, secure notes or files with one click. Just create a note and share the link.",
@@ -23,12 +28,15 @@
"max": "max: {n}",
"empty_content": "note is empty."
},
"copied_to_clipboard": "copied to clipboard 🔗"
"messages": {
"note_created": "note created."
}
},
"show": {
"errors": {
"not_found": "note was not found or was already deleted.",
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed."
"decryption_failed": "wrong password. could not decipher. probably a broken link. note was destroyed.",
"unsupported_type": "unsupported note type."
},
"explanation": "click below to show and delete the note if the counter has reached it's limit",
"show_note": "show note",

View File

@@ -4,13 +4,18 @@
"file": "archivo",
"advanced": "avanzado",
"create": "crear",
"loading": "cargando...",
"loading": "cargando",
"mode": "modo",
"views": "{n, plural, =0 {vistas} =1 {1 vista} other {# vistas}}",
"minutes": "{n, plural, =0 {minutos} =1 {1 minuto} other {# minutos}}",
"max": "max",
"share_link": "compartir enlace",
"copy_clipboard": "copiar al portapapeles"
"copy_clipboard": "copiar al portapapeles",
"copied_to_clipboard": "copiado al portapapeles",
"encrypting": "encriptando",
"decrypting": "descifrando",
"uploading": "cargando",
"downloading": "descargando"
},
"home": {
"intro": "Envía fácilmente notas o archivos <i>totalmente encriptados</i> y seguros con un solo clic. Solo tienes que crear una nota y compartir el enlace.",
@@ -23,12 +28,15 @@
"max": "max: {n}",
"empty_content": "la nota está vacía."
},
"copied_to_clipboard": "copiado al portapapeles 🔗"
"messages": {
"note_created": "nota creada."
}
},
"show": {
"errors": {
"not_found": "la nota no se encontró o ya fue borrada.",
"decryption_failed": "contraseña incorrecta. no se pudo descifrar. probablemente un enlace roto. la nota fue destruida."
"decryption_failed": "contraseña incorrecta. no se pudo descifrar. probablemente un enlace roto. la nota fue destruida.",
"unsupported_type": "tipo de nota no compatible."
},
"explanation": "pulse abajo para mostrar y borrar la nota si el contador ha llegado a su límite",
"show_note": "mostrar nota",

View File

@@ -4,13 +4,18 @@
"file": "fichier",
"advanced": "avancé",
"create": "créer",
"loading": "chargement...",
"loading": "chargement",
"mode": "mode",
"views": "{n, plural, =0 {vues} =1 {1 vue} other {# vues}}",
"minutes": "{n, plural, =0 {minutes} =1 {1 minute} other {# minutes}}",
"max": "max",
"share_link": "partager le lien",
"copy_clipboard": "copier dans le presse-papiers"
"copy_clipboard": "copier dans le presse-papiers",
"copied_to_clipboard": "copié dans le presse-papiers",
"encrypting": "cryptage",
"decrypting": "déchiffrer",
"uploading": "téléchargement",
"downloading": "téléchargement"
},
"home": {
"intro": "Envoyez facilement des notes ou des fichiers <i>entièrement cryptés</i> et sécurisés en un seul clic. Il suffit de créer une note et de partager le lien.",
@@ -23,12 +28,15 @@
"max": "max: {n}",
"empty_content": "La note est vide."
},
"copied_to_clipboard": "copié dans le presse-papiers 🔗"
"messages": {
"note_created": "note créée."
}
},
"show": {
"errors": {
"not_found": "La note n'a pas été trouvée ou a déjà été supprimée.",
"decryption_failed": "mauvais mot de passe. impossible à déchiffrer. probablement un lien brisé. la note a été détruite."
"decryption_failed": "mauvais mot de passe. impossible à déchiffrer. probablement un lien brisé. la note a été détruite.",
"unsupported_type": "type de note non supporté."
},
"explanation": "Cliquez ci-dessous pour afficher et supprimer la note si le compteur a atteint sa limite.",
"show_note": "note de présentation",

View File

@@ -4,13 +4,18 @@
"file": "file",
"advanced": "avanzato",
"create": "crea",
"loading": "carica...",
"loading": "carica",
"mode": "modalita",
"views": "{n, plural, =0 {viste} =1 {1 vista} other {# viste}}",
"minutes": "{n, plural, =0 {minuti} =1 {1 minuto} other {# minuti}}",
"max": "max",
"share_link": "condividi link",
"copy_clipboard": "copia negli appunti"
"copy_clipboard": "copia negli appunti",
"copied_to_clipboard": "copiato negli appunti",
"encrypting": "criptando",
"decrypting": "decifrando",
"uploading": "caricamento",
"downloading": "scaricando"
},
"home": {
"intro": "Invia facilmente note o file <i>completamente criptati</i> e sicuri con un solo clic. Basta creare una nota e condividere il link.",
@@ -23,12 +28,15 @@
"max": "max: {n}",
"empty_content": "la nota è vuota."
},
"copied_to_clipboard": "copiato negli appunti 🔗"
"messages": {
"note_created": "nota creata."
}
},
"show": {
"errors": {
"not_found": "non è stata trovata o è stata già cancellata.",
"decryption_failed": "password sbagliata. non ha potuto decifrare. probabilmente un link rotto. la nota è stata distrutta."
"decryption_failed": "password sbagliata. non ha potuto decifrare. probabilmente un link rotto. la nota è stata distrutta.",
"unsupported_type": "tipo di nota non supportato."
},
"explanation": "clicca sotto per mostrare e cancellare la nota se il contatore ha raggiunto il suo limite",
"show_note": "mostra la nota",

View File

@@ -4,13 +4,18 @@
"file": "上传文件",
"advanced": "高级设置",
"create": "创建",
"loading": "加载中...",
"loading": "加载中",
"mode": "模式",
"views": "{n, plural, =0 {可查看次数} =1 {1 次查看} other {# 次查看}}",
"minutes": "{n, plural, =0 {有效期(分钟)} =1 {1 分钟} other {# 分钟}}",
"max": "最大值",
"share_link": "分享链接",
"copy_clipboard": "复制到剪切版"
"copy_clipboard": "复制到剪切版",
"copied_to_clipboard": "已复制到剪切板",
"encrypting": "加密",
"decrypting": "解密",
"uploading": "上传",
"downloading": "下载"
},
"home": {
"intro": "一键轻松发送 <i>完全加密的</i> 密信或者文件。只需创建一个密信然后分享链接。",
@@ -23,12 +28,15 @@
"max": "最大文件大小: {n}",
"empty_content": "密信为空!"
},
"copied_to_clipboard": "已复制到剪切板 🔗"
"messages": {
"note_created": "注释创建。"
}
},
"show": {
"errors": {
"not_found": "该密信无法被找到或者它已经被删除了!",
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!"
"decryption_failed": "密钥错误!您可能不小心粘贴了一个不完整的链接或者正在尝试破解该密信!但无论如何,该密信已被销毁!",
"unsupported_type": "不支持的票据类型。"
},
"explanation": "点击下方的按钮可以查看密信,如果它到达了限制将会被删除",
"show_note": "查看密信",

View File

@@ -3,18 +3,20 @@
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "svelte-kit preview",
"preview": "vite preview --port 3000",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker --summary > licenses.csv",
"locale:download": "node scripts/locale.js"
"locale:download": "node scripts/locale.js",
"test:prepare": "pnpm run build"
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^7.3.1",
"@sveltejs/adapter-static": "^1.0.0-next.34",
"@sveltejs/kit": "^1.0.0-next.361",
"@sveltejs/adapter-static": "1.0.0-next.42",
"@sveltejs/kit": "1.0.0-next.480",
"@types/dompurify": "^2.3.3",
"@types/file-saver": "^2.0.5",
"@zerodevx/svelte-toast": "^0.7.2",
"adm-zip": "^0.5.9",
"dotenv": "^16.0.1",
"svelte": "^3.49.0",
@@ -23,12 +25,12 @@
"svelte-preprocess": "^4.10.7",
"tslib": "^2.4.0",
"typescript": "^4.7.4",
"vite": "^3.0.0"
"vite": "^3.0.2"
},
"dependencies": {
"@fontsource/fira-mono": "^4.5.8",
"copy-to-clipboard": "^3.3.1",
"dompurify": "^2.3.9",
"dompurify": "^2.3.10",
"file-saver": "^2.0.5",
"pretty-bytes": "^5.6.0"
}

View File

@@ -14,7 +14,9 @@
--ui-text-0: #fefefe;
--ui-text-1: #eee;
--ui-clr-primary: hsl(186, 65%, 55%);
--ui-clr-primary-alt: hsl(186, 85%, 35%);
--ui-clr-error: hsl(357, 77%, 51%);
--ui-clr-error-alt: hsl(357, 87%, 41%);
--ui-anim: all 150ms ease;
}

View File

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

View File

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

View File

@@ -11,6 +11,10 @@ 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
}

View File

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

View File

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 287 B

View File

Before

Width:  |  Height:  |  Size: 325 B

After

Width:  |  Height:  |  Size: 325 B

View File

Before

Width:  |  Height:  |  Size: 736 B

After

Width:  |  Height:  |  Size: 736 B

View File

Before

Width:  |  Height:  |  Size: 483 B

After

Width:  |  Height:  |  Size: 483 B

View File

Before

Width:  |  Height:  |  Size: 732 B

After

Width:  |  Height:  |  Size: 732 B

View File

@@ -9,6 +9,8 @@ export type Status = {
allow_advanced: boolean
theme_image: string
theme_text: string
theme_favicon: string
theme_page_title: string
}
export const status = writable<null | Status>(null)

View File

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

View File

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

View File

@@ -1,38 +1,32 @@
<script lang="ts">
import type { FileDTO } from '$lib/api'
import { Files } from '$lib/files'
import { createEventDispatcher } from 'svelte'
import { t } from 'svelte-intl-precompile'
import Button from './Button.svelte'
import MaxSize from './MaxSize.svelte'
import type { FileDTO } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
export let label: string = ''
let files: File[] = []
export let files: FileDTO[] = []
const dispatch = createEventDispatcher<{ file: string }>()
function fileToDTO(file: File): FileDTO {
return {
name: file.name,
size: file.size,
type: file.type,
contents: file,
}
}
async function onInput(e: Event) {
const input = e.target as HTMLInputElement
if (input?.files?.length) {
files = [...files, ...Array.from(input.files)]
const data: FileDTO[] = await Promise.all(
files.map(async (file) => ({
name: file.name,
type: file.type,
size: file.size,
contents: await Files.toString(file),
}))
)
dispatch('file', JSON.stringify(data))
} else {
dispatch('file', '')
files = [...files, ...Array.from(input.files).map(fileToDTO)]
}
}
function clear(e: Event) {
e.preventDefault()
files = []
dispatch('file', '')
}
</script>
@@ -40,7 +34,7 @@
<small>
{label}
</small>
<input type="file" on:change={onInput} multiple />
<input {...$$restProps} type="file" on:change={onInput} multiple />
<div class="box">
{#if files.length}
<div>
@@ -57,7 +51,9 @@
<div>
<b>{$t('file_upload.no_files_selected')}</b>
<br />
<small>{$t('common.max')}: <MaxSize /></small>
<small>
{$t('common.max')}: <MaxSize />
</small>
</div>
{/if}
</div>

View File

@@ -0,0 +1,41 @@
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 100 100"
xml:space="preserve"
>
<rect fill="none" stroke="currentColor" stroke-width="4" x="25" y="25" width="50" height="50">
<animateTransform
attributeName="transform"
dur="0.5s"
from="0 50 50"
to="180 50 50"
type="rotate"
id="strokeBox"
attributeType="XML"
begin="rectBox.end"
/>
</rect>
<rect x="27" y="27" fill="currentColor" width="46" height="50">
<animate
attributeName="height"
dur="1.3s"
attributeType="XML"
from="50"
to="0"
id="rectBox"
fill="freeze"
begin="0s;strokeBox.end"
/>
</rect>
</svg>
<style>
svg {
height: 2em;
position: relative;
top: 0.6em;
pointer-events: none;
}
</style>

After

Width:  |  Height:  |  Size: 784 B

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import { status } from '$lib/stores/status'
import prettyBytes from 'pretty-bytes'
import { _ } from 'svelte-intl-precompile'
import { status } from '$lib/stores/status'
// Due to encoding overhead (~35%) with base64
// https://en.wikipedia.org/wiki/Base64
const overhead = 1 / 1.35
</script>
<span>
{#if $status !== null}
{prettyBytes($status.max_size, { binary: true })}
{prettyBytes($status.max_size * overhead, { binary: true })}
{:else}
{$_('common.loading')}
{/if}

View File

@@ -0,0 +1,37 @@
<script lang="ts" context="module">
export type NoteResult = {
password: string
id: string
}
</script>
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import Button from '$lib/ui/Button.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
export let result: NoteResult
function reset() {
window.location.reload()
}
</script>
<TextInput
type="text"
readonly
label={$t('common.share_link')}
value="{window.location.origin}/note/{result.id}#{result.password}"
copy
data-testid="share-link"
/>
<br />
<p>
{@html $t('home.new_note_notice')}
</p>
<br />
<Button on:click={reset}>{$t('home.new_note')}</Button>
<style>
</style>

View File

@@ -1,20 +1,24 @@
<script lang="ts" context="module">
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
</script>
<script lang="ts">
import type { FileDTO, NotePublic } from '$lib/api'
import { Files } from '$lib/files'
import copy from 'copy-to-clipboard'
import DOMPurify from 'dompurify'
import { saveAs } from 'file-saver'
import prettyBytes from 'pretty-bytes'
import { t } from 'svelte-intl-precompile'
import Button from './Button.svelte'
export let note: NotePublic
import type { FileDTO, NotePublic } from '$lib/api'
import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils'
export let note: DecryptedNote
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
let files: FileDTO[] = []
$: if (note.meta.type === 'file') {
files = JSON.parse(note.contents) as FileDTO[]
files = note.contents
}
$: download = () => {
@@ -24,7 +28,7 @@
}
async function downloadFile(file: FileDTO) {
const f = new File([await Files.fromString(file.contents)], file.name, {
const f = new File([file.contents], file.name, {
type: file.type,
})
saveAs(f)
@@ -40,6 +44,7 @@
</script>
<p class="error-text">{@html $t('show.warning_will_not_see_again')}</p>
<div data-testid="result">
{#if note.meta.type === 'text'}
<div class="note">
{@html contentWithLinks(note.contents)}
@@ -54,6 +59,7 @@
{/each}
<Button on:click={download}>{$t('show.download_all')}</Button>
{/if}
</div>
<style>
.note {

View File

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

View File

@@ -0,0 +1,11 @@
import copyToClipboard from 'copy-to-clipboard'
import { t } from 'svelte-intl-precompile'
import { get } from 'svelte/store'
import { notify } from './toast'
export function copy(value: string) {
copyToClipboard(value)
const msg = get(t)('common.copied_to_clipboard')
notify.success(msg)
}

View File

@@ -0,0 +1,174 @@
<script lang="ts">
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'
import Button from '$lib/ui/Button.svelte'
import FileUpload from '$lib/ui/FileUpload.svelte'
import Loader from '$lib/ui/Loader.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte'
let note: Note = {
contents: '',
meta: { type: 'text' },
views: 1,
expiration: 60,
}
let files: FileDTO[]
let result: NoteResult | null = null
let advanced = false
let isFile = false
let timeExpiration = false
let description = ''
let loading: string | null = null
$: if (!advanced) {
note.views = 1
timeExpiration = false
}
$: {
description = $t('home.explanation', {
values: {
type: $t(timeExpiration ? 'common.minutes' : 'common.views', {
values: { n: (timeExpiration ? note.expiration : note.views) ?? '?' },
}),
},
})
}
$: note.meta.type = isFile ? 'file' : 'text'
$: if (!isFile) {
note.contents = ''
}
class EmptyContentError extends Error {}
async function submit() {
try {
loading = $t('common.encrypting')
const key = await Keys.generateKey()
const password = await Keys.export(key)
const data: Note = {
contents: '',
meta: note.meta,
}
if (isFile) {
if (files.length === 0) throw new EmptyContentError()
data.contents = await Adapters.Files.encrypt(files, key)
} else {
if (note.contents === '') throw new EmptyContentError()
data.contents = await Adapters.Text.encrypt(note.contents, key)
}
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
else data.views = parseInt(note.views as any)
loading = $t('common.uploading')
const response = await create(data)
result = {
password: password,
id: response.id,
}
notify.success($t('home.messages.note_created'))
} catch (e) {
if (e instanceof PayloadToLargeError) {
notify.error($t('home.errors.note_to_big'))
} else if (e instanceof EmptyContentError) {
notify.error($t('home.errors.empty_content'))
} else {
console.error(e)
notify.error($t('home.errors.note_error'))
}
} finally {
loading = null
}
}
</script>
{#if result}
<Result {result} />
{:else}
<p>
{@html $status?.theme_text || $t('home.intro')}
</p>
<form on:submit|preventDefault={submit}>
<fieldset disabled={loading !== null}>
{#if isFile}
<FileUpload data-testid="file-upload" label={$t('common.file')} bind:files />
{:else}
<TextArea
data-testid="text-field"
label={$t('common.note')}
bind:value={note.contents}
placeholder="..."
/>
{/if}
<div class="bottom">
<Switch
data-testid="switch-file"
class="file"
label={$t('common.file')}
bind:value={isFile}
/>
{#if $status?.allow_advanced}
<Switch
data-testid="switch-advanced"
label={$t('common.advanced')}
bind:value={advanced}
/>
{/if}
<div class="grow" />
<div class="tr">
<small>{$t('common.max')}: <MaxSize /> </small>
<br />
<Button type="submit">{$t('common.create')}</Button>
</div>
</div>
<p>
<br />
{#if loading}
{loading} <Loader />
{:else}
{description}
{/if}
</p>
{#if advanced}
<div transition:blur={{ duration: 250 }}>
<br />
<AdvancedParameters bind:note bind:timeExpiration />
</div>
{/if}
</fieldset>
</form>
{/if}
<style>
.bottom {
display: flex;
align-items: flex-end;
margin-top: 0.5rem;
}
.bottom :global(.file) {
margin-right: 0.5rem;
}
.grow {
flex: 1;
}
</style>

View File

@@ -1,17 +1,13 @@
<script lang="ts" context="module">
import { init, waitLocale, getLocaleFromNavigator } from 'svelte-intl-precompile'
// @ts-ignore
import { registerAll } from '$locales'
registerAll()
init({ initialLocale: getLocaleFromNavigator() ?? undefined, fallbackLocale: 'en' })
</script>
<script lang="ts">
import { init as initStores } from '$lib/stores/status'
import { SvelteToast } from '@zerodevx/svelte-toast'
import { onMount } from 'svelte'
import { waitLocale } from 'svelte-intl-precompile'
import '../app.css'
import { init as initStores, status } from '$lib/stores/status'
import Footer from '$lib/views/Footer.svelte'
import Header from '$lib/views/Header.svelte'
import { onMount } from 'svelte'
import '../app.css'
onMount(() => {
initStores()
@@ -19,7 +15,8 @@
</script>
<svelte:head>
<title>cryptgeon</title>
<title>{$status?.theme_page_title || 'cryptgeon'}</title>
<link rel="icon" href={$status?.theme_favicon || '/favicon.png'} />
</svelte:head>
{#await waitLocale() then _}
@@ -28,6 +25,8 @@
<slot />
</main>
<SvelteToast />
<Footer />
{/await}

View File

@@ -0,0 +1,5 @@
import { getLocaleFromNavigator, init } from 'svelte-intl-precompile'
// @ts-ignore
import { registerAll } from '$locales'
registerAll()
init({ initialLocale: getLocaleFromNavigator() ?? undefined, fallbackLocale: 'en' })

View File

@@ -1,10 +1,6 @@
<script context="module">
import { browser, dev } from '$app/env'
<script lang="ts">
import { status } from '$lib/stores/status'
import AboutParagraph from '$lib/ui/AboutParagraph.svelte'
export const hydrate = dev
export const router = browser
</script>
<svelte:head>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
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 type { PageData } from './$types'
export let data: PageData
let id = data.id
let password: string
let note: DecryptedNote | null = null
let exists = false
let loading: string | null = null
let error: string | null = null
onMount(async () => {
// Check if note exists
try {
loading = $t('common.loading')
password = window.location.hash.slice(1)
await info(id)
exists = true
} catch {
exists = false
} finally {
loading = null
}
})
/**
* Get the actual contents of the note and decrypt it.
*/
async function show() {
try {
error = null
loading = $t('common.downloading')
const data = await get(id)
loading = $t('common.decrypting')
const key = await Keys.import(password)
switch (data.meta.type) {
case 'text':
note = {
meta: { type: 'text' },
contents: await Adapters.Text.decrypt(data.contents, key),
}
break
case 'file':
note = {
meta: { type: 'file' },
contents: await Adapters.Files.decrypt(data.contents, key),
}
break
default:
error = $t('show.errors.unsupported_type')
return
}
} catch {
error = $t('show.errors.decryption_failed')
} finally {
loading = null
}
}
</script>
{#if !loading}
{#if !exists}
<p class="error-text">{$t('show.errors.not_found')}</p>
{:else if note && !error}
<ShowNote {note} />
{:else}
<form on:submit|preventDefault={show}>
<fieldset>
<p>{$t('show.explanation')}</p>
<Button data-testid="show-note-button" type="submit">{$t('show.show_note')}</Button>
{#if error}
<br />
<p class="error-text">
{error}
<br />
</p>
{/if}
</fieldset>
</form>
{/if}
{/if}
{#if loading}
<p class="loader">{loading} <Loader /></p>
{/if}
<style>
.loader {
text-align: center;
}
</style>

View File

@@ -0,0 +1,5 @@
import type { PageLoad } from './$types'
export const load: PageLoad = async ({ params }) => {
return params
}

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -3,6 +3,7 @@ import precompileIntl from 'svelte-intl-precompile/sveltekit-plugin'
/** @type {import('vite').UserConfig} */
const config = {
clearScreen: false,
server: {
port: 3000,
},

33
playwright.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { devices, type PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
use: {
video: 'retain-on-failure',
baseURL: 'http://localhost:1234',
actionTimeout: 60_000,
},
outputDir: './test-results',
testDir: './test',
timeout: 60_000,
testIgnore: ['file/too-big.spec.ts'],
webServer: {
command: 'pnpm run test:server',
port: 1234,
reuseExistingServer: true,
},
projects: [
{ name: 'chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'safari', use: { ...devices['Desktop Safari'] } },
{
name: 'local',
use: { ...devices['Desktop Chrome'] },
// testMatch: 'file/too-big.spec.ts',
},
],
}
export default config

1635
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- "packages/**"

BIN
test/assets/AES.pdf (Stored with Git LFS) Normal file

Binary file not shown.

BIN
test/assets/Pigeons.zip (Stored with Git LFS) Normal file

Binary file not shown.

BIN
test/assets/alfred-kenneally-UIu4RmMxnHU-unsplash.jpg (Stored with Git LFS) Normal file

Binary file not shown.

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

@@ -0,0 +1,5 @@
export default {
PDF: 'test/assets/AES.pdf',
Image: 'test/assets/alfred-kenneally-UIu4RmMxnHU-unsplash.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, 'alfred-kenneally', 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, 'alfred-kenneally', checksum)
})

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