Merge branch 'main' into fix/spa-fallback-status-code

This commit is contained in:
2026-09-21 21:16:57 +02:00
committed by GitHub
72 changed files with 3959 additions and 4342 deletions
+21 -1
View File
@@ -252,7 +252,7 @@ dependencies = [
[[package]]
name = "cryptgeon"
version = "2.9.3"
version = "3.0.0"
dependencies = [
"axum",
"bs62",
@@ -261,6 +261,7 @@ dependencies = [
"lazy_static",
"redis",
"ring",
"rmp-serde",
"serde",
"serde_json",
"tokio",
@@ -1004,6 +1005,25 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rmp"
version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c"
dependencies = [
"num-traits",
]
[[package]]
name = "rmp-serde"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155"
dependencies = [
"rmp",
"serde",
]
[[package]]
name = "rustix"
version = "1.1.4"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "cryptgeon"
version = "2.9.3"
version = "3.0.0"
authors = ["cupcakearmy <hi@nicco.io>"]
edition = "2024"
rust-version = "1.95"
@@ -20,8 +20,9 @@ redis = { version = "1", features = ["tls-native-tls"] }
# Utility
serde_json = "1"
rmp-serde = "1"
lazy_static = "1"
ring = "0.17"
bs62 = "0.1"
byte-unit = "4"
dotenv = "0.15"
dotenv = "0.15"
+6 -2
View File
@@ -34,7 +34,7 @@ pub static ref ID_LENGTH: u32 = std::env::var("ID_LENGTH")
.unwrap_or("32".to_string())
.parse()
.unwrap();
pub static ref REDIS_PREFIX: String = std::env::var("REDIS_PREFIX")
pub static ref CACHE_PREFIX: String = std::env::var("CACHE_PREFIX")
.unwrap_or("".to_string())
.parse()
.unwrap();
@@ -50,6 +50,10 @@ pub static ref IMPRINT_HTML: String = std::env::var("IMPRINT_HTML")
.unwrap_or("".to_string())
.parse()
.unwrap();
pub static ref EXTRA_SIZE_LIMIT: usize = std::env::var("EXTRA_SIZE_LIMIT")
.unwrap_or("512".to_string())
.parse()
.unwrap();
}
// THEME
@@ -78,4 +82,4 @@ lazy_static! {
.unwrap_or("true".to_string())
.parse()
.unwrap();
}
}
-16
View File
@@ -1,16 +0,0 @@
use axum::{body::Body, extract::Request, http::HeaderValue, middleware::Next, response::Response};
const CUSTOM_HEADER_NAME: &str = "Content-Security-Policy";
const CUSTOM_HEADER_VALUE: &str = "default-src 'self'; script-src 'report-sample' 'self'; style-src 'report-sample' 'self'; object-src 'none'; base-uri 'self'; connect-src 'self' data:; font-src 'self'; frame-src 'self'; img-src 'self'; manifest-src 'self'; media-src 'self'; worker-src 'none';";
lazy_static! {
static ref HEADER_VALUE: HeaderValue = HeaderValue::from_static(CUSTOM_HEADER_VALUE);
}
pub async fn add_csp_header(request: Request<Body>, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.append(CUSTOM_HEADER_NAME, HEADER_VALUE.clone());
response
}
+2 -2
View File
@@ -2,9 +2,9 @@ use crate::store;
use axum::http::StatusCode;
pub async fn report_health() -> (StatusCode,) {
if store::can_reach_redis() {
if store::can_reach_cache() {
return (StatusCode::OK,);
} else {
return (StatusCode::SERVICE_UNAVAILABLE,);
}
}
}
-10
View File
@@ -1,10 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)]
pub struct SharedState {
pub locks: LockMap,
}
pub type LockMap = Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>;
+11 -22
View File
@@ -1,13 +1,9 @@
use std::{collections::HashMap, sync::Arc};
use axum::{
Router, ServiceExt,
extract::{DefaultBodyLimit, Request},
routing::{delete, get, post},
};
use dotenv::dotenv;
use lock::SharedState;
use tokio::sync::Mutex;
use tower::Layer;
use tower_http::{
compression::CompressionLayer,
@@ -19,9 +15,7 @@ use tower_http::{
extern crate lazy_static;
mod config;
mod csp;
mod health;
mod lock;
mod note;
mod status;
mod store;
@@ -30,26 +24,23 @@ mod store;
async fn main() {
dotenv().ok();
let shared_state = SharedState {
locks: Arc::new(Mutex::new(HashMap::new())),
};
if !store::can_reach_redis() {
println!("cannot reach redis");
panic!("cannot reach redis");
if !store::can_reach_cache() {
println!("cannot reach cache");
panic!("cannot reach cache");
}
let notes_routes = Router::new()
.route("/", post(note::create))
.route("/{id}", delete(note::delete))
.route("/{id}", delete(note::view))
.route("/{id}", get(note::preview));
let health_routes = Router::new().route("/live", get(health::report_health));
let health_routes = Router::new().route("/healthz", get(health::report_health));
let status_routes = Router::new().route("/status", get(status::get_status));
let api_routes = Router::new()
let v3_routes = Router::new()
.nest("/notes", notes_routes)
.merge(health_routes)
.merge(status_routes);
let api_routes = Router::new().nest("/v3", v3_routes);
let index = format!("{}{}", config::FRONTEND_PATH.to_string(), "/index.html");
// SPA fallback: serve `index.html` for client side routes.
// `fallback` instead of `not_found_service`, as the latter forces a `404` status code,
@@ -58,9 +49,8 @@ async fn main() {
ServeDir::new(config::FRONTEND_PATH.to_string()).fallback(ServeFile::new(index));
let app = Router::new()
.nest("/api", api_routes)
.merge(health_routes)
.fallback_service(serve_dir)
// Disabled for now, as svelte inlines scripts
// .layer(middleware::from_fn(csp::add_csp_header))
.layer(DefaultBodyLimit::max(*config::LIMIT))
.layer(
CompressionLayer::new()
@@ -68,8 +58,7 @@ async fn main() {
.deflate(true)
.gzip(true)
.zstd(true),
)
.with_state(shared_state);
);
let app = NormalizePathLayer::trim_trailing_slash().layer(app);
@@ -80,4 +69,4 @@ async fn main() {
axum::serve(listener, ServiceExt::<Request>::into_make_service(app))
.await
.unwrap();
}
}
+1 -1
View File
@@ -2,4 +2,4 @@ mod model;
mod routes;
pub use model::*;
pub use routes::*;
pub use routes::*;
+25 -12
View File
@@ -5,22 +5,35 @@ use serde::{Deserialize, Serialize};
use crate::config;
#[derive(Serialize, Deserialize, Clone)]
pub struct Note {
pub meta: String,
pub contents: String,
pub struct NoteMeta {
#[serde(skip_serializing_if = "Option::is_none")]
pub views: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expiration: Option<u32>,
#[serde(default)]
pub extra: Vec<u8>,
}
#[derive(Serialize)]
pub struct NoteInfo {
pub meta: String,
#[derive(Serialize, Deserialize, Clone)]
pub struct CreateRequest {
pub meta: NoteMeta,
pub data: Vec<u8>,
}
#[derive(Serialize)]
pub struct NotePublic {
pub meta: String,
pub contents: String,
#[derive(Serialize, Deserialize)]
pub struct CreateResponse {
pub id: String,
}
#[derive(Serialize, Deserialize)]
pub struct MetaResponse {
pub meta: NoteMeta,
}
#[derive(Serialize, Deserialize)]
pub struct NoteResponse {
pub meta: NoteMeta,
pub data: Vec<u8>,
}
pub fn generate_id() -> String {
@@ -32,5 +45,5 @@ pub fn generate_id() -> String {
let _ = sr.fill(&mut id);
result.push_str(&bs62::encode_data(&id));
}
return result;
}
result
}
+102 -114
View File
@@ -2,155 +2,143 @@ use axum::{
extract::Path,
http::StatusCode,
response::{IntoResponse, Response},
Json,
body::Bytes,
};
use serde::{Deserialize, Serialize};
use std::{sync::Arc, time::SystemTime};
use tokio::sync::Mutex;
use serde::Deserialize;
use std::time::SystemTime;
use crate::note::{generate_id, Note, NoteInfo};
use crate::note::{CreateRequest, generate_id};
use crate::store;
use crate::{config, lock::SharedState};
use crate::config;
use super::NotePublic;
use super::{CreateResponse, MetaResponse, NoteResponse, NoteMeta};
pub fn now() -> u32 {
pub fn now() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs() as u32
.as_secs()
}
#[derive(Deserialize)]
pub struct OneNoteParams {
pub struct NoteParams {
id: String,
}
pub async fn preview(Path(OneNoteParams { id }): Path<OneNoteParams>) -> Response {
let note = store::get(&id);
pub async fn create(body: Bytes) -> Response {
let req: CreateRequest = match rmp_serde::from_slice(&body) {
Ok(r) => r,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid msgpack").into_response(),
};
match note {
Ok(Some(n)) => (StatusCode::OK, Json(NoteInfo { meta: n.meta })).into_response(),
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
if req.meta.views.is_none() && req.meta.expiration.is_none() {
return (StatusCode::BAD_REQUEST, "At least views or expiration must be set").into_response();
}
}
#[derive(Serialize, Deserialize)]
struct CreateResponse {
id: String,
}
pub async fn create(Json(mut n): Json<Note>) -> Response {
// let mut n = note.into_inner();
let id = generate_id();
// let bad_req = HttpResponse::BadRequest().finish();
if n.views == None && n.expiration == None {
return (
StatusCode::BAD_REQUEST,
"At least views or expiration must be set",
)
.into_response();
if req.meta.extra.len() > *config::EXTRA_SIZE_LIMIT {
return (StatusCode::BAD_REQUEST, "Extra data too large").into_response();
}
let mut meta = req.meta;
if !*config::ALLOW_ADVANCED {
n.views = Some(1);
n.expiration = None;
meta.views = Some(1);
meta.expiration = None;
}
match n.views {
match meta.views {
Some(v) => {
if v > *config::MAX_VIEWS || v < 1 {
return (StatusCode::BAD_REQUEST, "Invalid views").into_response();
}
n.expiration = None; // views overrides expiration
}
_ => {}
None => {}
}
match n.expiration {
let expiration_ts = match meta.expiration {
Some(e) => {
if e > *config::MAX_EXPIRATION || e < 1 {
return (StatusCode::BAD_REQUEST, "Invalid expiration").into_response();
}
let expiration = now() + (e * 60);
n.expiration = Some(expiration);
Some(now() + (e as u64 * 60))
}
None => None,
};
let id = generate_id();
let views = meta.views.map(|v| v as i64);
match store::set(&id, &req.data, views, expiration_ts, &meta.extra) {
Ok(_) => {
let resp = CreateResponse { id };
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
(StatusCode::OK, Bytes::from(bytes)).into_response()
}
_ => {}
}
match store::set(&id.clone(), &n.clone()) {
Ok(_) => (StatusCode::OK, Json(CreateResponse { id })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn delete(
Path(OneNoteParams { id }): Path<OneNoteParams>,
state: axum::extract::State<SharedState>,
) -> Response {
let mut locks_map = state.locks.lock().await;
let lock = locks_map
.entry(id.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
drop(locks_map);
let _guard = lock.lock().await;
let note = store::get(&id);
match note {
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
pub async fn preview(Path(NoteParams { id }): Path<NoteParams>) -> Response {
match store::get_meta(&id) {
Ok(Some((views, expiration, extra))) => {
let meta = NoteMeta {
views: views.map(|v| v as u32),
expiration: expiration.map(|e| e as u32),
extra,
};
let resp = MetaResponse { meta };
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
(StatusCode::OK, Bytes::from(bytes)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND).into_response(),
Ok(Some(note)) => {
let mut changed = note.clone();
if changed.views == None && changed.expiration == None {
return (StatusCode::BAD_REQUEST).into_response();
}
match changed.views {
Some(v) => {
changed.views = Some(v - 1);
let id = id.clone();
if v <= 1 {
match store::del(&id) {
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response();
}
_ => {}
}
} else {
match store::set(&id, &changed.clone()) {
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response();
}
_ => {}
}
}
}
_ => {}
}
let n = now();
match changed.expiration {
Some(e) => {
if e < n {
match store::del(&id.clone()) {
Ok(_) => return (StatusCode::BAD_REQUEST).into_response(),
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
.into_response()
}
}
}
}
_ => {}
}
return (
StatusCode::OK,
Json(NotePublic {
contents: changed.contents,
meta: changed.meta,
}),
)
.into_response();
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub async fn view(Path(NoteParams { id }): Path<NoteParams>) -> Response {
let (views, expiration, extra) = match store::get_meta(&id) {
Ok(Some(v)) => v,
_ => return (StatusCode::NOT_FOUND).into_response(),
};
let has_views = views.is_some();
if has_views {
let remaining = match store::decrement_views(&id) {
Ok(r) => r,
Err(_) => return (StatusCode::NOT_FOUND).into_response(),
};
let data = match store::get_data(&id) {
Ok(Some(d)) => d,
_ => return (StatusCode::NOT_FOUND).into_response(),
};
if remaining <= 0 {
let _ = store::del(&id);
}
let meta = NoteMeta {
views: Some(if remaining > 0 { remaining as u32 } else { 0 }),
expiration: expiration.map(|e| e as u32),
extra,
};
let resp = NoteResponse { meta, data };
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
(StatusCode::OK, Bytes::from(bytes)).into_response()
} else {
let data = match store::get_data(&id) {
Ok(Some(d)) => d,
_ => return (StatusCode::NOT_FOUND).into_response(),
};
let meta = NoteMeta {
views: None,
expiration: expiration.map(|e| e as u32),
extra,
};
let resp = NoteResponse { meta, data };
let bytes = rmp_serde::to_vec_named(&resp).unwrap();
(StatusCode::OK, Bytes::from(bytes)).into_response()
}
}
+59 -47
View File
@@ -1,71 +1,83 @@
use redis;
use redis::Commands;
use crate::config;
use crate::note::now;
use crate::note::Note;
lazy_static! {
static ref REDIS_CLIENT: String = std::env::var("REDIS")
static ref CACHE_URL: String = std::env::var("CACHE")
.unwrap_or("redis://127.0.0.1/".to_string())
.parse()
.unwrap();
}
fn prefixed(id: &String) -> String {
format!("{}{}", config::REDIS_PREFIX.as_str(), id)
fn prefixed(id: &str) -> String {
format!("{}{}", config::CACHE_PREFIX.as_str(), id)
}
fn get_connection() -> Result<redis::Connection, &'static str> {
fn conn() -> Result<redis::Connection, &'static str> {
let client =
redis::Client::open(REDIS_CLIENT.to_string()).map_err(|_| "Unable to connect to redis")?;
client
.get_connection()
.map_err(|_| "Unable to connect to redis")
redis::Client::open(CACHE_URL.to_string()).map_err(|_| "Unable to connect to cache")?;
client.get_connection().map_err(|_| "Unable to connect to cache")
}
pub fn can_reach_redis() -> bool {
let conn = get_connection();
return match conn {
Ok(_) => true,
Err(_) => false,
};
pub fn can_reach_cache() -> bool {
conn().is_ok()
}
pub fn set(id: &String, note: &Note) -> Result<(), &'static str> {
pub fn set(id: &str, data: &[u8], views: Option<i64>, expiration: Option<u64>, extra: &[u8]) -> Result<(), &'static str> {
let key = prefixed(id);
let serialized = serde_json::to_string(&note.clone()).unwrap();
let mut conn = get_connection()?;
let mut c = conn()?;
conn.set::<_, _, ()>(key.as_str(), serialized)
.map_err(|_| "Unable to set note in redis")?;
match note.expiration {
Some(e) => {
let seconds = e - now();
conn.expire::<_, ()>(key.as_str(), seconds as i64)
.map_err(|_| "Unable to set expiration on note")?
}
None => {}
};
Ok(())
}
c.hset::<_, _, _, ()>(&key, "data", data).map_err(|_| "Unable to set note")?;
c.hset::<_, _, _, ()>(&key, "extra", extra).map_err(|_| "Unable to set note")?;
pub fn get(id: &String) -> Result<Option<Note>, &'static str> {
let key = prefixed(id);
let mut conn = get_connection()?;
let value: Option<String> = conn.get(key.as_str()).map_err(|_| "Could not load note in redis")?;
match value {
None => return Ok(None),
Some(s) => {
let deserialize: Note = serde_json::from_str(&s).unwrap();
return Ok(Some(deserialize));
}
if let Some(v) = views {
c.hset::<_, _, _, ()>(&key, "views", v).map_err(|_| "Unable to set note")?;
}
if let Some(e) = expiration {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let ttl = e.saturating_sub(now);
c.expire::<_, ()>(&key, ttl as i64).map_err(|_| "Unable to set expiration")?;
}
}
pub fn del(id: &String) -> Result<(), &'static str> {
let key = prefixed(id);
let mut conn = get_connection()?;
conn.del::<_, ()>(key.as_str()).map_err(|_| "Unable to delete note in redis")?;
Ok(())
}
pub fn get_meta(id: &str) -> Result<Option<(Option<i64>, Option<u64>, Vec<u8>)>, &'static str> {
let key = prefixed(id);
let mut c = conn()?;
let exists: bool = c.exists::<_, bool>(&key).map_err(|_| "Cache error")?;
if !exists {
return Ok(None);
}
let views: Option<i64> = c.hget::<_, _, Option<i64>>(&key, "views").map_err(|_| "Cache error")?;
let expiration: Option<u64> = c.hget::<_, _, Option<u64>>(&key, "expiration").map_err(|_| "Cache error")?;
let extra: Vec<u8> = c.hget::<_, _, Vec<u8>>(&key, "extra").unwrap_or_default();
Ok(Some((views, expiration, extra)))
}
pub fn get_data(id: &str) -> Result<Option<Vec<u8>>, &'static str> {
let key = prefixed(id);
let mut c = conn()?;
let data: Option<Vec<u8>> = c.hget::<_, _, Option<Vec<u8>>>(&key, "data").map_err(|_| "Cache error")?;
Ok(data)
}
pub fn decrement_views(id: &str) -> Result<i64, &'static str> {
let key = prefixed(id);
let mut c = conn()?;
let result: i64 = c.hincr::<_, _, _, i64>(&key, "views", -1).map_err(|_| "Cache error")?;
Ok(result)
}
pub fn del(id: &str) -> Result<(), &'static str> {
let key = prefixed(id);
let mut c = conn()?;
c.del::<_, ()>(&key).map_err(|_| "Unable to delete note")?;
Ok(())
}
-15
View File
@@ -1,15 +0,0 @@
import { build } from 'tsup'
import pkg from './package.json' with { type: 'json' }
const watch = process.argv.slice(2)[0] === '--watch'
await build({
entry: ['src/index.ts', 'src/cli.ts', 'src/shared/shared.ts'],
dts: true,
minify: true,
format: ['esm', 'cjs'],
target: 'es2020',
clean: true,
define: { VERSION: `"${pkg.version}"` },
watch,
})
+20 -24
View File
@@ -1,6 +1,6 @@
{
"name": "cryptgeon",
"version": "2.9.3",
"version": "3.0.0",
"homepage": "https://github.com/cupcakearmy/cryptgeon",
"repository": {
"type": "git",
@@ -9,39 +9,35 @@
},
"type": "module",
"exports": {
".": "./dist/index.js",
"./shared": {
"import": "./dist/shared/shared.js",
"types": "./dist/shared/shared.d.ts"
}
".": "./dist/index.mjs"
},
"types": "./dist/index.d.ts",
"types": "./dist/index.d.mts",
"bin": {
"cryptgeon": "./dist/cli.cjs"
"cryptgeon": "./dist/cli.mjs"
},
"files": [
"dist"
],
"scripts": {
"bin": "run-s build package",
"build": "tsc && node build.js",
"dev": "node build.js --watch",
"prepublishOnly": "run-s build"
"build": "vp pack",
"dev": "vp pack --watch",
"prepublishOnly": "pnpm run build"
},
"devDependencies": {
"@commander-js/extra-typings": "^12.1.0",
"@types/inquirer": "^9.0.9",
"@types/mime": "^4.0.0",
"@types/node": "^20.19.41",
"commander": "^12.1.0",
"inquirer": "^9.3.8",
"@commander-js/extra-typings": "^15.0.0",
"@cryptgeon/shared": "workspace:*",
"@msgpack/msgpack": "^3.1.3",
"@tsconfig/strictest": "catalog:",
"@types/inquirer": "^9.0.10",
"@types/node": "^22.20.1",
"commander": "^15.0.0",
"inquirer": "^14.2.1",
"mime": "^4.1.0",
"occulto": "^2.0.6",
"pretty-bytes": "^6.1.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3"
"pretty-bytes": "^7.1.3",
"typescript": "catalog:",
"vite-plus": "catalog:"
},
"engines": {
"node": ">=18"
"node": ">=22"
}
}
}
+33 -46
View File
@@ -1,51 +1,42 @@
import inquirer from 'inquirer'
import { access, constants, writeFile } from 'node:fs/promises'
import { basename, resolve } from 'node:path'
import { AES, Hex } from 'occulto'
import { decode } from '@msgpack/msgpack'
import pretty from 'pretty-bytes'
import { Adapters } from '../shared/adapters.js'
import { API } from '../shared/api.js'
import { deriveKey, setServer, info, get, unpackContent } from '@cryptgeon/shared'
export async function download(url: URL, all: boolean, suggestedPassword?: string) {
API.setOptions({ server: url.origin })
setServer(url.origin)
const id = url.pathname.split('/')[2]
const preview = await API.info(id).catch(() => {
throw new Error('Note does not exist or is expired')
})
if (!id) throw new Error('Invalid URL')
const meta = await info(id)
if (!meta) throw new Error('Note does not exist or is expired')
// Password
let password: string
const derivation = preview?.meta.derivation
if (derivation) {
let key: Uint8Array
if (meta.extra && meta.extra.length > 0) {
if (suggestedPassword) {
password = suggestedPassword
const derivation = decode(meta.extra) as any
key = deriveKey(suggestedPassword, new Uint8Array(derivation.salt))
} else {
const response = await inquirer.prompt([
{
type: 'password',
message: 'Note password',
name: 'password',
},
{ type: 'password', message: 'Note password', name: 'password' },
])
password = response.password
const derivation = decode(meta.extra) as any
key = deriveKey(response.password, new Uint8Array(derivation.salt))
}
} else {
password = url.hash.slice(1)
const hex = url.hash.slice(1)
key = new Uint8Array(Buffer.from(hex, 'hex'))
}
const key = derivation ? (await AES.derive(password, derivation))[0] : Hex.decode(password)
const note = await API.get(id)
const note = await get(id)
if (!note) throw new Error('Could not load note')
const couldNotDecrypt = new Error('Could not decrypt note. Probably an invalid password')
switch (note.meta.type) {
case 'file':
const files = await Adapters.Files.decrypt(note.contents, key).catch(() => {
throw couldNotDecrypt
})
if (!files) {
throw new Error('No files found in note')
}
const content = unpackContent(note.data, key)
switch (content.type) {
case 'files':
const files: { name: string; data: Uint8Array }[] = content.data
let selected: typeof files
if (all) {
selected = files
@@ -55,36 +46,32 @@ export async function download(url: URL, all: boolean, suggestedPassword?: strin
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 })}`,
choices: files.map((f) => ({
value: f.name,
name: `${f.name} - ${pretty(f.data.length, { binary: true })}`,
checked: true,
})),
},
])
selected = files.filter((file) => names.includes(file.name))
selected = files.filter((f) => names.includes(f.name))
}
if (!selected.length) throw new Error('No files selected')
await Promise.all(
selected.map(async (file) => {
let filename = resolve(file.name)
selected.map(async (f) => {
let filename = resolve(f.name)
try {
// If exists -> prepend timestamp to not overwrite the current file
await access(filename, constants.R_OK)
filename = resolve(`${Date.now()}-${file.name}`)
filename = resolve(`${Date.now()}-${f.name}`)
} catch {}
await writeFile(filename, file.contents)
await writeFile(filename, f.data)
console.log(`Saved: ${basename(filename)}`)
})
)
break
case 'text':
const plaintext = await Adapters.Text.decrypt(note.contents, key).catch(() => {
throw couldNotDecrypt
})
console.log(plaintext)
console.log(content.data)
break
default:
throw new Error('Unknown content type')
}
}
}
+27 -35
View File
@@ -1,46 +1,38 @@
import { readFile, stat } from 'node:fs/promises'
import { readFile } from 'node:fs/promises'
import { basename } from 'node:path'
import mime from 'mime'
import { AES, Hex } from 'occulto'
import { Adapters } from '../shared/adapters.js'
import { API, FileDTO, Note, NoteMeta } from '../shared/api.js'
import { getServer, create, packContent, type FileDTO } from '@cryptgeon/shared'
export type UploadOptions = Pick<Note, 'views' | 'expiration'> & { password?: string }
export type UploadOptions = { views?: number; expiration?: number; password?: string }
export async function upload(input: string | string[], options: UploadOptions): Promise<string> {
const { password, ...noteOptions } = options
const derived = options.password ? await AES.derive(options.password) : undefined
const key = derived ? derived[0] : await AES.generateKey()
let contents: string
let type: NoteMeta['type']
if (typeof input === 'string') {
contents = await Adapters.Text.encrypt(input, key)
type = 'text'
} else {
const files: FileDTO[] = await Promise.all(
input.map(async (path) => {
const data = new Uint8Array(await readFile(path))
const stats = await stat(path)
const extension = path.substring(path.indexOf('.') + 1)
const type = mime.getType(extension) ?? 'application/octet-stream'
return {
name: basename(path),
size: stats.size,
contents: data,
type,
} satisfies FileDTO
})
)
contents = await Adapters.Files.encrypt(files, key)
type = 'file'
}
const payload = packContent(
typeof input === 'string'
? { type: 'text', text: input }
: { type: 'files', files: await fileDTOSfromPaths(input) },
password
)
// Create the actual note and upload it.
const note: Note = { ...noteOptions, contents, meta: { type, derivation: derived?.[1] } }
const result = await API.create(note)
let url = `${API.getOptions().server}/note/${result.id}`
if (!derived) url += `#${Hex.encode(key)}`
const result = await create({ meta: { ...noteOptions, extra: payload.extra }, data: payload.data })
let url = `${getServer()}/note/${result.id}`
if (!password) url += `#${Buffer.from(payload.key).toString('hex')}`
return url
}
async function fileDTOSfromPaths(paths: string[]): Promise<FileDTO[]> {
return Promise.all(
paths.map(async (path) => {
const extension = path.substring(path.indexOf('.') + 1)
const data = new Uint8Array(await readFile(path))
return {
name: basename(path),
mime: mime.getType(extension) ?? 'application/octet-stream',
size: data.length,
data,
}
})
)
}
+22 -16
View File
@@ -5,7 +5,7 @@ import prettyBytes from 'pretty-bytes'
import { download } from './actions/download.js'
import { upload } from './actions/upload.js'
import { API } from './shared/api.js'
import { setServer, status } from '@cryptgeon/shared'
import { parseFile, parseNumber } from './utils/parsers.js'
import { getStdin } from './utils/stdin.js'
import { checkConstrains, exit } from './utils/utils.js'
@@ -21,7 +21,8 @@ const views = new Option('-v --views <number>', 'Amount of views before getting
const minutes = new Option('-m --minutes <number>', 'Minutes before the note expires').argParser(parseNumber)
// Node 18 guard
parseInt(process.version.slice(1).split(',')[0]) < 18 && exit('Node 18 or higher is required')
const major = Number(process.version.slice(1).split('.')[0])
if (!Number.isFinite(major) || major < 18) exit('Node 18 or higher is required')
// @ts-ignore
const version: string = VERSION
@@ -33,15 +34,12 @@ program
.description('show information about the server')
.addOption(server)
.action(async (options) => {
API.setOptions({ server: options.server })
const response = await API.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]
}
setServer(options.server!)
const response = await status()
const formatted = Object.fromEntries(
Object.entries({ ...response, max_size: prettyBytes(response.max_size) })
.filter(([key]) => !key.startsWith('theme_'))
)
console.table(formatted)
})
@@ -54,11 +52,15 @@ send
.addOption(minutes)
.addOption(password)
.action(async (files, options) => {
API.setOptions({ server: options.server })
setServer(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
try {
const url = await upload(files, { views: options.views, expiration: options.minutes, password: options.password })
const url = await upload(files, {
...(options.views !== undefined ? { views: options.views } : {}),
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
password: options.password,
})
console.log(`Note created:\n\n${url}`)
} catch {
exit('Could not create note')
@@ -72,11 +74,15 @@ send
.addOption(minutes)
.addOption(password)
.action(async (text, options) => {
API.setOptions({ server: options.server })
setServer(options.server!)
await checkConstrains(options)
options.password ||= await getStdin()
try {
const url = await upload(text, { views: options.views, expiration: options.minutes, password: options.password })
const url = await upload(text, {
...(options.views !== undefined ? { views: options.views } : {}),
...(options.minutes !== undefined ? { expiration: options.minutes } : {}),
password: options.password,
})
console.log(`Note created:\n\n${url}`)
} catch {
exit('Could not create note')
@@ -103,4 +109,4 @@ program
}
})
program.parse()
program.parse()
+1 -3
View File
@@ -1,4 +1,2 @@
export * from './actions/download.js'
export * from './actions/upload.js'
export * from './shared/adapters.js'
export * from './shared/api.js'
export * from './actions/upload.js'
-61
View File
@@ -1,61 +0,0 @@
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(),
}
-141
View File
@@ -1,141 +0,0 @@
import type { KeyData, TypedArray } from 'occulto'
export type NoteMeta = {
type: 'text' | 'file'
derivation?: KeyData
}
export type Note = {
contents: string
meta: NoteMeta
views?: number
expiration?: number
}
export type NoteInfo = Pick<Note, 'meta'>
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 ClientOptions = {
server: string
}
type CallOptions = {
url: string
method: string
body?: any
}
export class PayloadToLargeError extends Error {}
export let client: ClientOptions = {
server: '',
}
function setOptions(options: Partial<ClientOptions>) {
client = { ...client, ...options }
}
function getOptions(): ClientOptions {
return client
}
async function call(options: CallOptions) {
const url = client.server + '/api/' + options.url
const response = await fetch(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()
}
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 }
}
async function get(id: string): Promise<NotePublic> {
const data = await call({
url: `notes/${id}`,
method: 'delete',
})
const { contents, meta } = data
const note = {
contents,
meta: JSON.parse(meta),
} satisfies NotePublic
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
}
async function info(id: string): Promise<NoteInfo> {
const data = await call({
url: `notes/${id}`,
method: 'get',
})
const { meta } = data
const note = {
meta: JSON.parse(meta),
} satisfies NoteInfo
if (note.meta.derivation) note.meta.derivation.salt = new Uint8Array(Object.values(note.meta.derivation.salt))
return note
}
export type Status = {
version: string
max_size: number
max_views: number
max_expiration: number
allow_advanced: boolean
allow_files: boolean
imprint_url: string
imprint_html: string
theme_image: string
theme_text: string
theme_favicon: string
theme_page_title: string
theme_new_note_notice: boolean
theme_home_link: boolean
}
async function status() {
const data = await call({
url: 'status/',
method: 'get',
})
return data as Status
}
export const API = {
setOptions,
getOptions,
create,
get,
info,
status,
}
-2
View File
@@ -1,2 +0,0 @@
export * from './adapters.js'
export * from './api.js'
+8 -10
View File
@@ -1,5 +1,5 @@
import { exit as exitNode } from 'node:process'
import { API } from '../shared/api.js'
import { status } from '@cryptgeon/shared'
export function exit(message: string) {
console.error(message)
@@ -7,13 +7,11 @@ export function exit(message: string) {
}
export async function checkConstrains(constrains: { views?: number; minutes?: number }) {
const { views, minutes } = constrains
if (views && minutes) exit('cannot set view and minutes constrains simultaneously')
if (!views && !minutes) constrains.views = 1
if (!constrains.views && !constrains.minutes) constrains.views = 1
const response = await API.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.`)
}
const response = await status()
if (constrains.views && constrains.views > response.max_views)
exit(`Only a maximum of ${response.max_views} views allowed. ${constrains.views} given.`)
if (constrains.minutes && constrains.minutes > response.max_expiration)
exit(`Only a maximum of ${response.max_expiration} minutes allowed. ${constrains.minutes} given.`)
}
+6 -5
View File
@@ -1,13 +1,14 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"target": "esnext",
"module": "esnext",
"moduleResolution": "Bundler",
"declaration": true,
"emitDeclarationOnly": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"allowSyntheticDefaultImports": true
}
}
},
"exclude": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from "vite-plus";
import pkg from "./package.json" with { type: "json" };
export default defineConfig({
pack: {
entry: ["src/index.ts", "src/cli.ts"],
dts: true,
minify: true,
format: ["esm"],
target: "es2023",
deps: { alwaysBundle: ["**"] },
define: { VERSION: JSON.stringify(pkg.version) },
},
});
+9 -13
View File
@@ -8,31 +8,27 @@
"preview": "vite preview",
"check": "svelte-check --tsconfig tsconfig.json",
"licenses": "license-checker-rseidelsohn --summary > licenses.csv",
"locale:download": "node scripts/locale.js",
"test:prepare": "pnpm run build"
},
"type": "module",
"devDependencies": {
"@lokalise/node-api": "^13.2.1",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.61.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0",
"@zerodevx/svelte-toast": "^0.9.6",
"adm-zip": "^0.5.17",
"dotenv": "^17.4.2",
"license-checker-rseidelsohn": "^5.0.1",
"svelte": "^5.55.9",
"svelte-check": "^4.4.8",
"svelte": "^5.57.0",
"svelte-check": "^4.7.6",
"svelte-intl-precompile": "^0.12.3",
"tslib": "^2.8.1",
"typescript": "^6.0.3",
"vite": "^8.0.14"
"vite": "^8.2.2"
},
"dependencies": {
"@fontsource/fira-mono": "^5.2.7",
"cryptgeon": "workspace:*",
"occulto": "^2.0.6",
"pretty-bytes": "^7.1.0",
"@cryptgeon/shared": "workspace:*",
"@fontsource/fira-mono": "^5.3.0",
"comlink": "^4.4.2",
"pretty-bytes": "^7.1.3",
"uqr": "^0.1.3"
}
}
-59
View File
@@ -1,59 +0,0 @@
import { LokaliseApi } from '@lokalise/node-api'
import AdmZip from 'adm-zip'
import dotenv from 'dotenv'
import https from 'https'
dotenv.config()
function exit(msg) {
console.error(msg)
process.exit(1)
}
const apiKey = process.env.LOKALISE_API_KEY
const project_id = process.env.LOKALISE_PROJECT
if (!apiKey) exit('No API Key set for Lokalize! Set with "LOKALISE_API_KEY"')
if (!project_id) exit('No project id set for Lokalize! Set with "LOKALISE_PROJECT"')
const client = new LokaliseApi({ apiKey })
const WGet = (url) =>
new Promise((done) => {
https
.get(url, (res) => {
const data = []
res
.on('data', (chunk) => {
data.push(chunk)
})
.on('end', () => {
let buffer = Buffer.concat(data)
done(buffer)
})
})
.on('error', (err) => {
console.log('download error:', err)
})
})
async function download() {
// For details see: https://app.lokalise.com/api2docs/curl/#transition-download-files-post
const download = await client.files().download(project_id, {
format: 'json',
indentation: 'tab',
json_unescaped_slashes: true,
original_filenames: false,
bundle_structure: '%LANG_ISO%.%FORMAT%',
export_sort: 'first_added',
export_empty_as: 'skip',
add_newline_eof: true,
replace_breaks: false,
})
const buffered = await WGet(download.bundle_url)
const zip = new AdmZip(buffered)
zip.extractAllTo('./locales', true)
}
download().catch((e) => {
console.error(e)
process.exit(1)
})
+21 -4
View File
@@ -1,8 +1,25 @@
import { API, type Status } from 'cryptgeon/shared'
import { status as apiStatus } from '@cryptgeon/shared'
import { writable } from 'svelte/store'
export const status = writable<null | Status>(null)
export type StatusInfo = {
version: string
max_size: number
max_views: number
max_expiration: number
allow_advanced: boolean
allow_files: boolean
imprint_url: string
imprint_html: string
theme_image: string
theme_text: string
theme_page_title: string
theme_favicon: string
theme_new_note_notice: boolean
theme_home_link: boolean
}
export const status = writable<null | StatusInfo>(null)
export async function init() {
status.set(await API.status())
}
status.set((await apiStatus()) as StatusInfo)
}
@@ -4,10 +4,9 @@
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'
interface Props {
note: Note
note: { views: number; expiration: number }
timeExpiration?: boolean
customPassword?: string | null
}
@@ -3,7 +3,7 @@
import Button from '$lib/ui/Button.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import type { FileDTO } from 'cryptgeon/shared'
import type { FileDTO } from '@cryptgeon/shared'
interface Props {
label?: string
@@ -16,9 +16,9 @@
async function fileToDTO(file: File): Promise<FileDTO> {
return {
name: file.name,
mime: file.type,
size: file.size,
type: file.type,
contents: new Uint8Array(await file.arrayBuffer()),
data: new Uint8Array(await file.arrayBuffer()),
}
}
+2 -4
View File
@@ -4,14 +4,12 @@
import { status } from '$lib/stores/status'
// Due to encoding overhead (~35%) with base64
// https://en.wikipedia.org/wiki/Base64
const overhead = 1 / 1.35
// Payload is raw bytes (msgpack + cipher), no base64 padding overhead.
</script>
<span>
{#if $status !== null}
{prettyBytes($status.max_size * overhead, { binary: true })}
{prettyBytes($status.max_size, { binary: true })}
{:else}
{$_('common.loading')}
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { t } from 'svelte-intl-precompile'
import Button from '$lib/ui/Button.svelte'
import type { FileDTO } from 'cryptgeon/shared'
import type { FileDTO } from '@cryptgeon/shared'
interface Props {
files: FileDTO[]
@@ -12,7 +12,7 @@
let previewUrls: string[] = $state([])
$effect(() => {
const urls = files.map((f) => URL.createObjectURL(new Blob([f.contents], { type: f.type })))
const urls = files.map((f) => URL.createObjectURL(new Blob([f.data.slice(0)], { type: f.mime })))
previewUrls = urls
return () => {
for (const url of urls) URL.revokeObjectURL(url)
@@ -36,12 +36,12 @@
<div class="files-grid">
{#each files as entry, index}
<div class="file-preview">
{#if isImage(entry.type)}
{#if isImage(entry.mime)}
<img src={previewUrls[index]} class="preview-img" alt={entry.name} />
{:else}
<div class="file-icon">
<div class="file-extension">
{entry.name.split('.').pop()?.toUpperCase() || entry.type}
{entry.name.split('.').pop()?.toUpperCase() || entry.mime}
</div>
</div>
{/if}
+14 -12
View File
@@ -1,5 +1,8 @@
<script lang="ts" module>
export type DecryptedNote = Omit<NotePublic, 'contents'> & { contents: any }
export type DecryptedNote = {
meta: { type: 'text' | 'file' }
contents: any
}
function saveAs(file: File) {
const url = window.URL.createObjectURL(file)
@@ -20,7 +23,7 @@
import Button from '$lib/ui/Button.svelte'
import { copy } from '$lib/utils'
import type { FileDTO, NotePublic } from 'cryptgeon/shared'
import type { FileDTO } from '@cryptgeon/shared'
interface Props {
note: DecryptedNote
@@ -31,10 +34,9 @@
const RE_URL = /[A-Za-z]+:\/\/([A-Z a-z0-9\-._~:\/?#\[\]@!$&'()*+,;%=])+/g
let files: FileDTO[] = $state([])
async function downloadFile(file: FileDTO) {
// @ts-ignore
const f = new File([file.contents], file.name, {
type: file.type,
async function downloadFile(file: FileDTO) {
const f = new File([file.data.slice(0)], file.name, {
type: file.mime,
})
saveAs(f)
}
@@ -44,11 +46,11 @@
files = note.contents
}
})
let download = $derived(() => {
function downloadAll() {
for (const file of files) {
downloadFile(file)
}
})
}
let links = $derived(typeof note.contents === 'string' ? note.contents.match(RE_URL) : [])
</script>
@@ -78,19 +80,19 @@
<button onclick={() => downloadFile(file)}>
<b>↓ {file.name}</b>
</button>
<small> {file.type} - {prettyBytes(file.size)}</small>
<small> {file.mime} - {prettyBytes(file.size ?? file.data.length)}</small>
</div>
{#if file.type.startsWith('image/')}
{#if file.mime.startsWith('image/')}
{#key file.name}
<img
src={URL.createObjectURL(new File([file.contents], file.name, { type: file.type }))}
src={URL.createObjectURL(new File([file.data.slice(0)], file.name, { type: file.mime }))}
alt={file.name}
class="preview"
/>
{/key}
{/if}
{/each}
<Button onclick={download}>{$t('show.download_all')}</Button>
<Button onclick={downloadAll}>{$t('show.download_all')}</Button>
{/if}
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import Icon from '$lib/ui/Icon.svelte'
import { copy as copyFN } from '$lib/utils'
import { getRandomBytes, Hex } from 'occulto'
import { randomBytes, bytesToHex } from '@cryptgeon/shared'
import type { HTMLInputAttributes } from 'svelte/elements'
interface Props {
@@ -35,7 +35,7 @@
}
async function randomFN() {
value = Hex.encode(await getRandomBytes(32))
value = bytesToHex(randomBytes(32))
}
</script>
+46 -42
View File
@@ -1,7 +1,15 @@
<script lang="ts">
import { AES, Hex } from 'occulto'
import {
create as apiCreate,
bytesToHex,
packContent,
type FileDTO,
type NoteInput,
type ServerNote,
} from '@cryptgeon/shared'
import { t } from 'svelte-intl-precompile'
import { blur } from 'svelte/transition'
import { transfer } from 'comlink'
import { status } from '$lib/stores/status'
import { notify } from '$lib/toast'
@@ -10,18 +18,14 @@
import FileUpload from '$lib/ui/FileUpload.svelte'
import Loader from '$lib/ui/Loader.svelte'
import MaxSize from '$lib/ui/MaxSize.svelte'
import PastedFilesPreview from '$lib/ui/PastedFilesPreview.svelte'
import Result, { type NoteResult } from '$lib/ui/NoteResult.svelte'
import PastedFilesPreview from '$lib/ui/PastedFilesPreview.svelte'
import Switch from '$lib/ui/Switch.svelte'
import TextArea from '$lib/ui/TextArea.svelte'
import { Adapters, API, PayloadToLargeError, type FileDTO, type Note } from 'cryptgeon/shared'
import { createWorker } from '$lib/worker'
import { onMount } from 'svelte'
let note: Note = $state({
contents: '',
meta: { type: 'text' },
views: 1,
expiration: 60,
})
let note: { views: number; expiration: number } = $state({ views: 1, expiration: 60 })
let files: FileDTO[] = $state([])
let result: NoteResult | null = $state(null)
let advanced = $state(false)
@@ -31,6 +35,7 @@
let description = $state('')
let loading: string | null = $state(null)
let isPasting = $state(false)
let textContent = $state('')
$effect(() => {
if (!advanced) {
@@ -50,14 +55,10 @@
})
$effect(() => {
note.meta.type = isFile ? 'file' : 'text'
if (!isFile) textContent = ''
})
$effect(() => {
if (!isFile) {
note.contents = ''
}
})
const worker = createWorker()
async function handlePaste(e: ClipboardEvent) {
const data = e.clipboardData
@@ -98,11 +99,12 @@
const name =
file.name || `pasted-file-${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`
const renamed = new File([file], name, { type: file.type })
const data = new Uint8Array(await renamed.arrayBuffer())
return {
name: renamed.name,
mime: renamed.type,
size: renamed.size,
type: renamed.type,
contents: new Uint8Array(await renamed.arrayBuffer()),
data,
}
})
)
@@ -122,40 +124,42 @@
try {
loading = $t('common.encrypting')
const derived = customPassword && (await AES.derive(customPassword))
const key = derived ? derived[0] : await AES.generateKey()
const data: Note = {
contents: '',
meta: note.meta,
}
if (derived) data.meta.derivation = derived[1]
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)
} else if (textContent === '') {
throw new EmptyContentError()
}
const noteInput: NoteInput = isFile
? transfer(
{
type: 'files',
files: $state.snapshot(files),
},
files.map((f) => f.data.buffer)
)
: { type: 'text', text: textContent }
const payload = await worker.pack(noteInput, customPassword || undefined)
const serverNote: ServerNote = {
meta: {
...(timeExpiration
? { expiration: parseInt(note.expiration as any) }
: { views: parseInt(note.views as any) }),
extra: payload.extra,
},
data: payload.data,
}
if (timeExpiration) data.expiration = parseInt(note.expiration as any)
else data.views = parseInt(note.views as any)
loading = $t('common.uploading')
const response = await API.create(data)
const response = await apiCreate(serverNote)
result = {
id: response.id,
password: customPassword ? undefined : Hex.encode(key),
password: customPassword ? undefined : bytesToHex(payload.key),
}
notify.success($t('home.messages.note_created'))
} catch (e) {
if (e instanceof PayloadToLargeError) {
notify.error($t('home.errors.note_too_big'))
} else if (e instanceof EmptyContentError) {
notify.error($t('home.errors.empty_content'))
} else {
console.error(e)
notify.error($t('home.errors.note_error'))
}
console.error(e)
notify.error($t('home.errors.note_error'))
} finally {
loading = null
}
@@ -183,7 +187,7 @@
<TextArea
data-testid="text-field"
label={$t('common.note')}
bind:value={note.contents}
bind:value={textContent}
placeholder="..."
/>
{/if}
+14
View File
@@ -0,0 +1,14 @@
import { wrap } from 'comlink'
import CryptWorker from './worker?worker'
import type { packContent, unpackContent } from '@cryptgeon/shared'
export function createWorker() {
const worker = new CryptWorker()
return wrap<WorkerConract>(worker)
}
export type WorkerConract = {
pack: typeof packContent
unpack: typeof unpackContent
}
@@ -0,0 +1,15 @@
import type { WorkerConract } from '$lib/worker'
import { packContent, unpackContent } from '@cryptgeon/shared'
import { expose, transfer } from 'comlink'
const contract: WorkerConract = {
pack(input, password) {
const content = packContent(input, password)
return transfer(content, [content.data.buffer, content.extra.buffer, content.key.buffer])
},
unpack(data, key) {
return unpackContent(data, key)
},
}
expose(contract)
@@ -45,18 +45,7 @@
</span>
</AboutParagraph>
<AboutParagraph title="translations">
<span
>translations are managed on <a
href="https://lokalise.com/"
target="_blank"
rel="noopener noreferrer">Lokalise</a
>, which granted an open source license to use the paid version. If you are interested in
helping translating don't hesitate to contact me!
</span>
</AboutParagraph>
<AboutParagraph title="attribution">
<AboutParagraph title="attribution">
<span>
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>
@@ -1,5 +1,13 @@
<script lang="ts">
import { AES, Hex } from 'occulto'
import {
deriveKey,
hexToBytes,
decode,
info,
get as apiGet,
unpackContent,
type FileDTO,
} from '@cryptgeon/shared'
import { onMount } from 'svelte'
import { t } from 'svelte-intl-precompile'
@@ -7,8 +15,8 @@
import Loader from '$lib/ui/Loader.svelte'
import ShowNote, { type DecryptedNote } from '$lib/ui/ShowNote.svelte'
import TextInput from '$lib/ui/TextInput.svelte'
import { Adapters, API, type NoteMeta } from 'cryptgeon/shared'
import type { PageData } from './$types'
import { createWorker } from '$lib/worker'
interface Props {
data: PageData
@@ -20,7 +28,7 @@
let password: string | null = $state<string | null>(null)
let note: DecryptedNote | null = $state(null)
let exists = $state(false)
let meta: NoteMeta | null = $state(null)
let hasExtra = $state(false)
let loading: string | null = $state(null)
let error: string | null = $state(null)
@@ -28,13 +36,16 @@
let valid = $derived(!!password?.length)
onMount(async () => {
// Check if note exists
try {
loading = $t('common.loading')
password = window.location.hash.slice(1)
const note = await API.info(id)
meta = note.meta
exists = true
const meta = await info(id)
if (meta) {
hasExtra = !!meta.extra?.length
exists = true
} else {
exists = false
}
} catch {
exists = false
} finally {
@@ -42,9 +53,8 @@
}
})
/**
* Get the actual contents of the note and decrypt it.
*/
const worker = createWorker()
async function show(e: SubmitEvent) {
e.preventDefault()
try {
@@ -53,24 +63,40 @@
return
}
// Load note
error = null
loading = $t('common.downloading')
const data = await API.get(id)
const serverNote = await apiGet(id)
if (!serverNote) {
error = $t('show.errors.not_found')
return
}
loading = $t('common.decrypting')
const derived = meta?.derivation && (await AES.derive(password!, meta.derivation))
const key = derived ? derived[0] : Hex.decode(password!)
switch (data.meta.type) {
let key: Uint8Array
if (hasExtra && serverNote.meta.extra && serverNote.meta.extra.length > 0) {
const derivation = decode(serverNote.meta.extra) as any
key = deriveKey(password!, new Uint8Array(derivation.salt))
} else {
key = hexToBytes(password!)
}
const content = await worker.unpack(serverNote.data, key)
switch (content.type) {
case 'text':
note = {
meta: { type: 'text' },
contents: await Adapters.Text.decrypt(data.contents, key),
contents: content.data,
}
break
case 'file':
case 'files':
const files = (content.data as any[]).map((f: any) => ({
...f,
data: f.data instanceof Uint8Array ? f.data : new Uint8Array(f.data as any),
}))
note = {
meta: { type: 'file' },
contents: await Adapters.Files.decrypt(data.contents, key),
contents: files,
}
break
default:
@@ -94,7 +120,7 @@
<form onsubmit={show}>
<fieldset>
<p>{$t('show.explanation')}</p>
{#if meta?.derivation}
{#if hasExtra}
<TextInput
data-testid="show-note-password"
type="password"
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@cryptgeon/shared",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@msgpack/msgpack": "^3.1.3",
"@noble/ciphers": "^2.4.0",
"@noble/hashes": "^2.4.0",
"lz4js": "^0.2.0"
},
"devDependencies": {
"@tsconfig/strictest": "catalog:",
"@types/lz4js": "^0.2.2",
"typescript": "catalog:",
"vitest": "^4.1.11"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest'
import { encode, decode } from '@msgpack/msgpack'
import { setServer, getServer, create, info, get, status } from './api'
const server = 'http://example.test'
const created = encode({ id: 'abc123' })
const metaOut = encode({ meta: { views: 3, extra: Buffer.from('040506','hex') } })
const dataOut = encode({ meta: { views: 0 },data: Buffer.from('090909','hex') })
function mockFetch(body: Uint8Array) {
return vi.fn().mockResolvedValue({
ok: true,
status: 200,
arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
json: async () => ({}),
})
}
function copyBuffer(buf: Uint8Array) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)
}
describe('api client', () => {
it('setServer trims trailing slashes', () => {
setServer('http://x.test///')
expect(getServer()).toBe('http://x.test')
})
it('create POSTs msgpack note and returns id', async () => {
setServer(server)
const fetchMock = mockFetch(created)
vi.stubGlobal('fetch', fetchMock)
const note = { meta: { views: 5 },data: Buffer.from('010203','hex') }
const result = await create(note)
const url = fetchMock.mock.calls[0]![0]!
const init = fetchMock.mock.calls[0]![1]!
expect(url).toBe(server + '/api/v3/notes')
expect(init.method).toBe('POST')
expect(init.headers).toEqual({ 'content-type': 'application/msgpack' })
const sent = decode(new Uint8Array(copyBuffer(init.body))) as { meta?: { views?: number } }
expect(sent?.meta?.views).toBe(5)
expect(result).toEqual({ id: 'abc123' })
vi.unstubAllGlobals()
})
it('info GETs meta', async () => {
setServer(server)
const fetchMock = mockFetch(metaOut)
vi.stubGlobal('fetch', fetchMock)
const result = await info('id1')
expect(result?.views).toBe(3)
vi.unstubAllGlobals()
})
it('get DELETEs and parses data', async () => {
setServer(server)
const fetchMock = mockFetch(dataOut)
vi.stubGlobal('fetch', fetchMock)
const result = await get('id2')
expect(result?.meta?.views).toBe(0)
const init = fetchMock.mock.calls[0]![1]!
expect(init.method).toBe('DELETE')
vi.unstubAllGlobals()
})
it('status GETs JSON config', async () => {
setServer(server)
const fetchMock = mockFetch(new Uint8Array())
vi.stubGlobal('fetch', fetchMock)
await status()
const url = fetchMock.mock.calls[0]![0]!
expect(url).toBe(server + '/api/v3/status')
vi.unstubAllGlobals()
})
})
+58
View File
@@ -0,0 +1,58 @@
import { encode, decode } from "@msgpack/msgpack";
import type { ServerNote, Status } from "./types.js";
let server = "";
export function setServer(url: string) {
server = url.replace(/\/+$/, "");
}
export function getServer() {
return server;
}
function api(path: string) {
return `${server}/api/v3/${path}`;
}
export async function create(note: ServerNote): Promise<{ id: string }> {
const res = await fetch(api("notes"), {
method: "POST",
headers: { "content-type": "application/msgpack" },
body: encode(note),
});
if (!res.ok) throw new Error("create failed");
const buf = await res.arrayBuffer();
const data = decode(new Uint8Array(buf)) as any;
if (typeof data?.id !== "string") throw new Error("invalid response");
return { id: data.id };
}
export async function info(id: string): Promise<ServerNote["meta"] | null> {
const res = await fetch(api(`notes/${id}`));
if (!res.ok) return null;
const buf = await res.arrayBuffer();
const data = decode(new Uint8Array(buf)) as any;
const meta = data?.meta as ServerNote["meta"] | undefined;
if (!meta) return null;
if (meta.extra && !(meta.extra instanceof Uint8Array)) meta.extra = new Uint8Array(meta.extra as any);
return meta;
}
export async function get(id: string): Promise<ServerNote | null> {
const res = await fetch(api(`notes/${id}`), { method: "DELETE" });
if (!res.ok) return null;
const buf = await res.arrayBuffer();
const data = decode(new Uint8Array(buf)) as any;
const meta = data.meta as ServerNote["meta"];
if (meta?.extra && !(meta.extra instanceof Uint8Array)) meta.extra = new Uint8Array(meta.extra as any);
const d = data.data;
return { meta, data: d instanceof Uint8Array ? d : new Uint8Array(d) } satisfies ServerNote;
}
export async function status(): Promise<Status> {
const res = await fetch(api("status"));
if (!res.ok) throw new Error("status failed");
return res.json();
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { compress, decompress, utf8ToBytes } from "./index";
describe("compression", () => {
it("round-trips small text", () => {
const data = utf8ToBytes("hello world");
const compressed = compress(data);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
it("round-trips highly compressible data", () => {
const data = utf8ToBytes("a".repeat(10_000));
const compressed = compress(data);
expect(compressed.length).toBeLessThan(data.length);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
it("round-trips arbitrary bytes", () => {
const data = new Uint8Array([0, 128, 255, 1, 2, 3, 200, 100]);
const compressed = compress(data);
const decompressed = decompress(compressed);
expect(decompressed).toEqual(data);
});
});
+9
View File
@@ -0,0 +1,9 @@
import LZ4 from "lz4js";
export function compress(data: Uint8Array): Uint8Array {
return LZ4.compress(data);
}
export function decompress(data: Uint8Array): Uint8Array {
return LZ4.decompress(data);
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { deriveKey, encrypt, decrypt, generateKey, utf8ToBytes, randomBytes } from "./crypto";
describe("crypto", () => {
it("encrypts and decrypts with generated key", () => {
const data = utf8ToBytes("hello world");
const key = generateKey();
const enc = encrypt(data, key);
const dec = decrypt(enc, key);
expect(dec).toEqual(data);
});
it("encrypts and decrypts with derived key", () => {
const data = utf8ToBytes("secret message");
const salt = randomBytes(16);
const key = deriveKey("password123", salt);
const enc = encrypt(data, key);
const dec = decrypt(enc, key);
expect(dec).toEqual(data);
});
it("derived key has same length as generated", () => {
const salt = randomBytes(16);
expect(deriveKey("test", salt).length).toBe(generateKey().length);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
import { managedNonce, randomBytes } from "@noble/ciphers/utils.js";
import { scrypt } from "@noble/hashes/scrypt.js";
export {
bytesToUtf8,
utf8ToBytes,
hexToBytes,
bytesToHex,
randomBytes,
} from "@noble/ciphers/utils.js";
const N = 2 ** 15;
const KEY_SIZE = 32;
export function generateKey(): Uint8Array {
return randomBytes(KEY_SIZE);
}
export function deriveKey(password: string, salt: Uint8Array): Uint8Array {
return scrypt(password, salt, { N, r: 8, p: 1, dkLen: KEY_SIZE });
}
export function encrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
const chacha = managedNonce(xchacha20poly1305)(key);
return chacha.encrypt(data);
}
export function decrypt(data: Uint8Array, key: Uint8Array): Uint8Array {
const chacha = managedNonce(xchacha20poly1305)(key);
return chacha.decrypt(data);
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./crypto.js";
export * from "./types.js";
export * from "./api.js";
export * from "./compression.js";
export * from "./payload.js";
export { encode, decode } from "@msgpack/msgpack";
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { packContent, unpackContent } from "./payload";
import { bytesToUtf8, utf8ToBytes } from "./crypto";
describe("payload", () => {
it("round-trips a text note through the full pipeline", () => {
const { data, extra, key } = packContent({ type: "text", text: "hello world" });
expect(extra.length).toBe(0);
const content = unpackContent(data, key);
expect(content).toEqual({ type: "text", data: "hello world" });
});
it("round-trips with a password and sets extra", () => {
const { data, extra, key } = packContent({ type: "text", text: "secret" }, "pw123");
expect(extra.length).toBeGreaterThan(0);
const content = unpackContent(data, key);
expect(content).toEqual({ type: "text", data: "secret" });
});
it("round-trips files (FileDTO)", () => {
const file = { name: "a.txt", mime: "text/plain", size: 5, data: utf8ToBytes("hello") };
const { data, key } = packContent({ type: "files", files: [file] });
const content = unpackContent(data, key);
expect(content.type).toBe("files");
if (content.type === "files") {
expect(content.data).toHaveLength(1);
expect(content.data[0]!.name).toBe("a.txt");
expect(bytesToUtf8(content.data[0]!.data)).toBe("hello");
}
});
});
+49
View File
@@ -0,0 +1,49 @@
import { encode, decode } from "@msgpack/msgpack";
import { compress, decompress } from "./compression.js";
import {
deriveKey,
encrypt,
decrypt,
generateKey,
randomBytes,
} from "./crypto.js";
import type { FileDTO, NoteContent } from "./types.js";
export type NoteInput =
| { type: "text"; text: string }
| { type: "files"; files: FileDTO[] };
export type PackResult = {
data: Uint8Array;
extra: Uint8Array;
key: Uint8Array;
};
export function packContent(input: NoteInput, password?: string): PackResult {
let key: Uint8Array;
let extra: Uint8Array;
if (password) {
const salt = randomBytes(16);
key = deriveKey(password, salt);
extra = encode({ salt, N: 32768, r: 8, p: 1 });
} else {
key = generateKey();
extra = new Uint8Array();
}
const content: NoteContent =
input.type === "text"
? { type: "text", data: input.text }
: { type: "files", data: input.files };
const encoded = encrypt(compress(encode(content)), key);
return { data: encoded, extra, key };
}
export function unpackContent(data: Uint8Array, key: Uint8Array): NoteContent {
const content = decode(decompress(decrypt(data, key))) as NoteContent;
if (content.type !== "text" && content.type !== "files") {
throw new Error("Unknown content type");
}
return content;
}
+38
View File
@@ -0,0 +1,38 @@
export type NoteMeta = {
expiration?: number;
views?: number;
extra?: Uint8Array;
};
export type ServerNote = {
meta: NoteMeta;
data: Uint8Array;
};
export type NoteContent =
| { type: "text"; data: string }
| { type: "files"; data: FileDTO[] };
export type FileDTO = {
name: string;
mime: string;
size: number;
data: Uint8Array;
};
export type Status = {
version: string;
max_size: number;
max_views: number;
max_expiration: number;
allow_advanced: boolean;
allow_files: boolean;
imprint_url: string;
imprint_html: string;
theme_image: string;
theme_text: string;
theme_page_title: string;
theme_favicon: string;
theme_new_note_notice: boolean;
theme_home_link: boolean;
};
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
}
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
});