mirror of
https://github.com/cupcakearmy/cryptgeon.git
synced 2026-09-26 20:41:45 +00:00
Merge branch 'main' into fix/spa-fallback-status-code
This commit is contained in:
Generated
+21
-1
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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,);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<()>>>>>;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,4 @@ mod model;
|
||||
mod routes;
|
||||
|
||||
pub use model::*;
|
||||
pub use routes::*;
|
||||
pub use routes::*;
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(¬e.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(())
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,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'
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './adapters.js'
|
||||
export * from './api.js'
|
||||
@@ -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.`)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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) },
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@tsconfig/strictest/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user