From 950142a9c04c2c90d0d442caabf4e42b9a178769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 23 Jul 2026 19:40:30 +0200 Subject: [PATCH 1/6] s3 support commit1 --- .env.example | 16 ++++ Cargo.toml | 4 + README.md | 17 ++++ docker-compose.yml | 24 ++++++ docker/garage/garage.toml | 14 ++++ src/api.rs | 44 +++++------ src/config.rs | 23 +++++- src/main.rs | 8 +- src/state.rs | 6 +- src/storage.rs | 159 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 docker/garage/garage.toml create mode 100644 src/storage.rs diff --git a/.env.example b/.env.example index 23f34a2..ec036c1 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,22 @@ RUST_LOG=rustpad=info,tower_http=warn # Maximum upload size UPLOAD_MAX_SIZE_MB=20 +# Attachment storage: local or s3 +STORAGE_DRIVER=local +FILES_DIR=/data/files + +# S3-compatible storage (AWS S3, Garage, Ceph, OpenStack, MinIO, R2...) +# For Docker Garage run: docker compose --profile s3 up -d +# STORAGE_DRIVER=s3 +# S3_ENDPOINT=http://garage:3900 +# S3_REGION=garage +# S3_BUCKET=attachments +# S3_ACCESS_KEY=GK0123456789abcdef0123456789abcdef +# S3_SECRET_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +# S3_FORCE_PATH_STYLE=true +# GARAGE_S3_PORT=3900 +# GARAGE_ADMIN_PORT=3903 + # Browser cache lifetime in seconds ASSET_CACHE_MAX_AGE_SECONDS=600 FILE_CACHE_MAX_AGE_SECONDS=300 diff --git a/Cargo.toml b/Cargo.toml index e216dfd..f4a75f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,10 @@ license = "MIT" [dependencies] argon2 = "0.5" +aws-config = "1" +aws-credential-types = "1" +aws-sdk-s3 = "1" +bytes = "1" axum = { version = "0.8", features = ["ws", "multipart"] } chrono = { version = "0.4", features = ["serde"] } dotenvy = "0.15" diff --git a/README.md b/README.md index 8331ba0..e28c595 100644 --- a/README.md +++ b/README.md @@ -87,3 +87,20 @@ Browser diagnostics are configured separately from backend logs with `FRONTEND_L ### Rejestracja i SMTP `REGISTRATION_ENABLED=true` włącza rejestrację. Po utworzeniu konta aplikacja wysyła przez SMTP wiadomość z nickiem i adresem `PUBLIC_URL`. `ACCOUNT_CONFIRMATION_REQUIRED=true` wymaga dodatkowo kliknięcia linku potwierdzającego przed logowaniem; domyślnie opcja jest wyłączona i wymaga skonfigurowanego SMTP. +## Attachment storage + +RustPad supports two interchangeable attachment backends selected in `.env`: + +- `STORAGE_DRIVER=local` stores files under `FILES_DIR` (default). +- `STORAGE_DRIVER=s3` uses any S3-compatible service such as AWS S3, Garage, Ceph RGW, OpenStack or MinIO. + +The public application URLs remain `/f/{token}/{filename}` for both backends. RustPad checks access and streams the object through the API, so no bucket needs to be public and existing database records do not need migration. + +For the optional Docker Garage service, set the S3 variables shown in `.env.example`, use strong unique credentials, and start: + +```sh +docker compose --profile s3 up -d --build +``` + +Garage is a separate Compose service and the existing `pgsql` and `mysql` profiles remain unchanged. The included single-node setup is intended for local/self-hosted development without redundancy; production Garage deployments should use an appropriately designed multi-node configuration. + diff --git a/docker-compose.yml b/docker-compose.yml index 7f0f4ce..7c54785 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,13 @@ services: DATABASE_MAX_CONNECTIONS: ${DATABASE_MAX_CONNECTIONS:-8} STATIC_DIR: ${STATIC_DIR:-/app/static} FILES_DIR: ${FILES_DIR:-/data/files} + STORAGE_DRIVER: ${STORAGE_DRIVER:-local} + S3_ENDPOINT: ${S3_ENDPOINT:-} + S3_REGION: ${S3_REGION:-us-east-1} + S3_BUCKET: ${S3_BUCKET:-} + S3_ACCESS_KEY: ${S3_ACCESS_KEY:-} + S3_SECRET_KEY: ${S3_SECRET_KEY:-} + S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-false} UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB:-20} REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-false} ACCOUNT_CONFIRMATION_REQUIRED: ${ACCOUNT_CONFIRMATION_REQUIRED:-false} @@ -64,3 +71,20 @@ services: retries: 30 + + garage: + image: dxflrs/garage:v2.3.0 + profiles: ["s3"] + restart: unless-stopped + command: ["/garage", "server", "--single-node", "--default-bucket"] + environment: + GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY:-GK0123456789abcdef0123456789abcdef} + GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef} + GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-attachments} + ports: + - "${GARAGE_S3_PORT:-3900}:3900" + - "${GARAGE_ADMIN_PORT:-3903}:3903" + volumes: + - ./docker/garage/garage.toml:/etc/garage.toml:ro + - ./data/garage/meta:/var/lib/garage/meta + - ./data/garage/data:/var/lib/garage/data diff --git a/docker/garage/garage.toml b/docker/garage/garage.toml new file mode 100644 index 0000000..f0c27bb --- /dev/null +++ b/docker/garage/garage.toml @@ -0,0 +1,14 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" +db_engine = "sqlite" +replication_factor = 1 +rpc_bind_addr = "[::]:3901" +rpc_secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = ".s3.garage.localhost" + +[admin] +api_bind_addr = "[::]:3903" diff --git a/src/api.rs b/src/api.rs index ad8efa5..5dc94c2 100644 --- a/src/api.rs +++ b/src/api.rs @@ -616,20 +616,19 @@ pub async fn upload_pad_file( let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::pad_file_token(&state.db, pad.id).await?; - let directory = format!("{}_{}", pad.id, file_token); - let dir = std::path::Path::new(&state.files_dir).join("pads").join(&directory); - tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?; let mut stored = safe.clone(); - let mut path = dir.join(&stored); - if path.exists() { + let mut key = crate::storage::object_key("pads", pad.id, &file_token, &stored); + if state.storage.exists(&key).await.map_err(|_| ApiError::internal("Failed to check file storage"))? { let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik"); let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default(); stored = format!("{stem}-{}{}", db::random_suffix(6), ext); - path = dir.join(&stored); + key = crate::storage::object_key("pads", pad.id, &file_token, &stored); } - tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; let url = format!("/f/{}/{}", file_token, stored); let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); + let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); + state.storage.put(&key, bytes.clone().into(), &mime, &cache_control).await + .map_err(|_| ApiError::internal("Failed to save the file"))?; db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?; Ok(Json(serde_json::json!({"name": stored, "url": url}))) } @@ -680,20 +679,19 @@ pub async fn upload_note_file( let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::note_file_token(&state.db, note.id).await?; - let directory = format!("{}_{}", note.id, file_token); - let dir = std::path::Path::new(&state.files_dir).join("notes").join(&directory); - tokio::fs::create_dir_all(&dir).await.map_err(|_| ApiError::internal("Failed to create the files directory"))?; let mut stored = safe.clone(); - let mut path = dir.join(&stored); - if path.exists() { + let mut key = crate::storage::object_key("notes", note.id, &file_token, &stored); + if state.storage.exists(&key).await.map_err(|_| ApiError::internal("Failed to check file storage"))? { let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik"); let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default(); stored = format!("{stem}-{}{}", db::random_suffix(6), ext); - path = dir.join(&stored); + key = crate::storage::object_key("notes", note.id, &file_token, &stored); } - tokio::fs::write(&path, &bytes).await.map_err(|_| ApiError::internal("Failed to save the file"))?; let url = format!("/f/{}/{}", file_token, stored); let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); + let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); + state.storage.put(&key, bytes.clone().into(), &mime, &cache_control).await + .map_err(|_| ApiError::internal("Failed to save the file"))?; db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?; Ok(Json(serde_json::json!({"name": stored, "url": url}))) } @@ -742,13 +740,8 @@ pub async fn delete_note_file( .ok_or_else(ApiError::not_found_file)?; let relative = file.url.trim_start_matches('/').split('/').collect::>(); if relative.len() == 3 && relative[0] == "f" { - let directory = format!("{}_{}", note.id, relative[1]); - let path = std::path::Path::new(&state.files_dir).join("notes").join(directory).join(sanitize_filename(relative[2])); - match tokio::fs::remove_file(&path).await { - Ok(()) => {}, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}, - Err(_) => return Err(ApiError::internal("Failed to delete the file")), - } + let key = crate::storage::object_key("notes", note.id, relative[1], &sanitize_filename(relative[2])); + state.storage.delete(&key).await.map_err(|_| ApiError::internal("Failed to delete the file"))?; } db::delete_note_file(&state.db, note.id, file_id).await?; Ok(Json(serde_json::json!({"ok": true}))) @@ -788,11 +781,10 @@ async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> R db::FileOwnerKind::Pad => "pads", db::FileOwnerKind::Note => "notes", }; - let directory = format!("{}_{}", owner.id, token); - let canonical = std::path::Path::new(&state.files_dir).join(kind).join(&directory).join(&safe); - let legacy = std::path::Path::new(&state.files_dir).join(&directory).join(&safe); - let path = if canonical.is_file() { canonical } else { legacy }; - let bytes = tokio::fs::read(&path).await.map_err(|_| ApiError::not_found_file())?; + let key = crate::storage::object_key(kind, owner.id, token, &safe); + let legacy_key = crate::storage::legacy_key(owner.id, token, &safe); + let bytes = state.storage.get_local_with_legacy(&key, &legacy_key).await + .map_err(|_| ApiError::not_found_file())?; let mime = mime_guess::from_path(&safe).first_or_octet_stream(); let mut response = bytes.into_response(); response.headers_mut().insert( diff --git a/src/config.rs b/src/config.rs index 16e0408..35d0e65 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,6 +8,7 @@ pub struct Config { pub database_max_connections: u32, pub static_dir: String, pub files_dir: String, + pub storage: crate::storage::StorageConfig, pub upload_max_size_bytes: usize, pub asset_version: String, pub asset_cache_max_age_seconds: u64, @@ -31,6 +32,19 @@ impl Config { env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?; let anonymous_access_token_ttl_days = env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; let user_session_ttl_days = env_positive_i64("USER_SESSION_TTL_DAYS", 30)?; + let files_dir = env_var("FILES_DIR", "data/files"); + let storage = match env_var("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() { + "local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() }, + "s3" => crate::storage::StorageConfig::S3 { + endpoint: env::var("S3_ENDPOINT").ok(), + region: env_var("S3_REGION", "us-east-1"), + bucket: required_env("S3_BUCKET")?, + access_key: required_env("S3_ACCESS_KEY")?, + secret_key: required_env("S3_SECRET_KEY")?, + force_path_style: env_bool("S3_FORCE_PATH_STYLE", false)?, + }, + _ => return Err("STORAGE_DRIVER must be local or s3".into()), + }; if upload_max_size_mb == 0 { return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); @@ -57,7 +71,8 @@ impl Config { ), database_max_connections, static_dir: env_var("STATIC_DIR", "static"), - files_dir: env_var("FILES_DIR", "data/files"), + files_dir, + storage, upload_max_size_bytes: upload_max_size_mb .checked_mul(1024 * 1024) .ok_or("UPLOAD_MAX_SIZE_MB is too large")?, @@ -108,3 +123,9 @@ fn env_positive_i64(name: &str, default: i64) -> Result Result> { Ok(env_var(name, &default.to_string()).parse()?) } + +fn required_env(name: &str) -> Result> { + let value = env::var(name).map_err(|_| format!("{name} is required when STORAGE_DRIVER=s3"))?; + if value.trim().is_empty() { return Err(format!("{name} cannot be empty when STORAGE_DRIVER=s3").into()); } + Ok(value) +} diff --git a/src/main.rs b/src/main.rs index 6c0b0a8..edc7f80 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod database; mod db; mod queries; mod state; +mod storage; mod websocket; use std::{net::SocketAddr, sync::Arc}; @@ -30,6 +31,7 @@ async fn main() -> Result<(), Box> { database_max_connections = config.database_max_connections, static_dir = %config.static_dir, files_dir = %config.files_dir, + storage_driver = match &config.storage { storage::StorageConfig::Local { .. } => "local", storage::StorageConfig::S3 { .. } => "s3" }, upload_max_size_bytes = config.upload_max_size_bytes, asset_cache_max_age_seconds = config.asset_cache_max_age_seconds, file_cache_max_age_seconds = config.file_cache_max_age_seconds, @@ -51,12 +53,12 @@ async fn main() -> Result<(), Box> { run_migrations(&db).await?; info!(database_kind = ?db.kind(), "database migrations completed"); - std::fs::create_dir_all(&config.files_dir)?; - info!(files_dir = %config.files_dir, "file storage ready"); + let storage = storage::Storage::from_config(config.storage.clone()).await?; + info!(storage_driver = storage.backend_name(), "file storage ready"); let state = Arc::new(AppState::new( db, config.asset_version.clone(), - config.files_dir.clone(), + storage, config.upload_max_size_bytes, config.file_cache_max_age_seconds, config.smtp.clone(), diff --git a/src/state.rs b/src/state.rs index 955effa..836a389 100644 --- a/src/state.rs +++ b/src/state.rs @@ -36,7 +36,7 @@ pub enum RoomEvent { pub struct AppState { pub db: Database, pub asset_version: String, - pub files_dir: String, + pub storage: crate::storage::Storage, pub upload_max_size_bytes: usize, pub file_cache_max_age_seconds: u64, pub smtp: Option, @@ -51,8 +51,8 @@ pub struct AppState { } impl AppState { - pub fn new(db: Database, asset_version: String, files_dir: String, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { - Self { db, asset_version, files_dir, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } + pub fn new(db: Database, asset_version: String, storage: crate::storage::Storage, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { + Self { db, asset_version, storage, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } } async fn channel_for_key(&self, key: String) -> broadcast::Sender { if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..5fb1716 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,159 @@ +use std::{path::PathBuf, sync::Arc}; + +use aws_config::Region; +use aws_credential_types::Credentials; +use aws_sdk_s3::{config::Builder as S3ConfigBuilder, primitives::ByteStream, Client}; +use bytes::Bytes; + +#[derive(Debug, Clone)] +pub enum StorageConfig { + Local { root: PathBuf }, + S3 { + endpoint: Option, + region: String, + bucket: String, + access_key: String, + secret_key: String, + force_path_style: bool, + }, +} + +#[derive(Clone)] +pub enum Storage { + Local { root: PathBuf }, + S3 { client: Client, bucket: Arc }, +} + +impl std::fmt::Debug for Storage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Local { root } => f.debug_struct("LocalStorage").field("root", root).finish(), + Self::S3 { bucket, .. } => f.debug_struct("S3Storage").field("bucket", bucket).finish(), + } + } +} + +impl Storage { + pub async fn from_config(config: StorageConfig) -> Result> { + match config { + StorageConfig::Local { root } => { + tokio::fs::create_dir_all(&root).await?; + Ok(Self::Local { root }) + } + StorageConfig::S3 { endpoint, region, bucket, access_key, secret_key, force_path_style } => { + let credentials = Credentials::new(access_key, secret_key, None, None, "rustpad-env"); + let shared = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region.clone())) + .credentials_provider(credentials) + .load() + .await; + let mut builder = S3ConfigBuilder::from(&shared) + .region(Region::new(region)) + .force_path_style(force_path_style); + if let Some(endpoint) = endpoint.filter(|value| !value.trim().is_empty()) { + builder = builder.endpoint_url(endpoint); + } + Ok(Self::S3 { client: Client::from_conf(builder.build()), bucket: Arc::from(bucket) }) + } + } + } + + pub fn backend_name(&self) -> &'static str { + match self { Self::Local { .. } => "local", Self::S3 { .. } => "s3" } + } + + pub async fn exists(&self, key: &str) -> Result { + match self { + Self::Local { root } => Ok(root.join(key).is_file()), + Self::S3 { client, bucket } => match client.head_object().bucket(bucket.as_ref()).key(key).send().await { + Ok(_) => Ok(true), + Err(error) if error.as_service_error().is_some_and(|service| service.is_not_found()) => Ok(false), + Err(error) => Err(StorageError::Backend(error.to_string())), + }, + } + } + + pub async fn put(&self, key: &str, bytes: Bytes, content_type: &str, cache_control: &str) -> Result<(), StorageError> { + match self { + Self::Local { root } => { + let path = root.join(key); + if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } + tokio::fs::write(path, bytes).await?; + Ok(()) + } + Self::S3 { client, bucket } => { + client.put_object() + .bucket(bucket.as_ref()) + .key(key) + .content_type(content_type) + .cache_control(cache_control) + .body(ByteStream::from(bytes)) + .send().await + .map_err(|error| StorageError::Backend(error.to_string()))?; + Ok(()) + } + } + } + + pub async fn get(&self, key: &str) -> Result { + match self { + Self::Local { root } => Ok(Bytes::from(tokio::fs::read(root.join(key)).await?)), + Self::S3 { client, bucket } => { + let output = client.get_object().bucket(bucket.as_ref()).key(key).send().await + .map_err(|error| StorageError::Backend(error.to_string()))?; + let bytes = output.body.collect().await + .map_err(|error| StorageError::Backend(error.to_string()))? + .into_bytes(); + Ok(bytes) + } + } + } + + pub async fn get_local_with_legacy(&self, key: &str, legacy_key: &str) -> Result { + match self { + Self::Local { root } => { + let canonical = root.join(key); + let path = if canonical.is_file() { canonical } else { root.join(legacy_key) }; + Ok(Bytes::from(tokio::fs::read(path).await?)) + } + Self::S3 { .. } => self.get(key).await, + } + } + + pub async fn delete(&self, key: &str) -> Result<(), StorageError> { + match self { + Self::Local { root } => match tokio::fs::remove_file(root.join(key)).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + }, + Self::S3 { client, bucket } => { + client.delete_object().bucket(bucket.as_ref()).key(key).send().await + .map_err(|error| StorageError::Backend(error.to_string()))?; + Ok(()) + } + } + } +} + +#[derive(Debug)] +pub enum StorageError { + Io(std::io::Error), + Backend(String), +} + +impl std::fmt::Display for StorageError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { Self::Io(error) => write!(f, "{error}"), Self::Backend(error) => f.write_str(error) } + } +} +impl std::error::Error for StorageError {} +impl From for StorageError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } + +pub fn object_key(kind: &str, owner_id: i64, token: &str, filename: &str) -> String { + format!("{kind}/{owner_id}_{token}/{filename}") +} + +pub fn legacy_key(owner_id: i64, token: &str, filename: &str) -> String { + format!("{owner_id}_{token}/{filename}") +} From 9d24da8d3270081e8f9d83a4100de6fc90d70155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 23 Jul 2026 19:54:48 +0200 Subject: [PATCH 2/6] s3 commit2 --- docker-compose.yml | 4 +--- docker/garage/garage.toml | 6 +++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7c54785..d6318db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ services: - rustpad: + rustpad-app: build: context: . dockerfile: Dockerfile @@ -70,8 +70,6 @@ services: timeout: 3s retries: 30 - - garage: image: dxflrs/garage:v2.3.0 profiles: ["s3"] diff --git a/docker/garage/garage.toml b/docker/garage/garage.toml index f0c27bb..dfb14f6 100644 --- a/docker/garage/garage.toml +++ b/docker/garage/garage.toml @@ -2,13 +2,13 @@ metadata_dir = "/var/lib/garage/meta" data_dir = "/var/lib/garage/data" db_engine = "sqlite" replication_factor = 1 -rpc_bind_addr = "[::]:3901" +rpc_bind_addr = "0.0.0.0:3901" rpc_secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" [s3_api] s3_region = "garage" -api_bind_addr = "[::]:3900" +api_bind_addr = "0.0.0.0:3900" root_domain = ".s3.garage.localhost" [admin] -api_bind_addr = "[::]:3903" +api_bind_addr = "0.0.0.0:3903" From c728f27dcd6adbf897d79d9513d1fc1f8f3dc886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Thu, 23 Jul 2026 19:55:29 +0200 Subject: [PATCH 3/6] s3 commit2 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9dc9d2f..bb02a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ data/files/* *.zip venv .venv -migrate/etherpad-dry-run-report.json \ No newline at end of file +migrate/etherpad-dry-run-report.json +data/garge \ No newline at end of file From f52cf914703534f0584a7ff3d1478e8aba8bb4f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 24 Jul 2026 10:20:27 +0200 Subject: [PATCH 4/6] share invitation --- .env.example | 4 +- Cargo.lock | 1330 +++++- Cargo.toml | 2 +- migrations/mysql/0011_share_invitations.sql | 15 + .../postgres/0011_share_invitations.sql | 13 + migrations/sqlite/0011_share_invitations.sql | 13 + src/app.rs | 1 + src/auth.rs | 87 +- src/config.rs | 2 + src/main.rs | 2 + src/state.rs | 5 +- static/css/styles.css | 4202 ++++++++++++++--- static/error.html | 5 +- static/home.html | 46 +- static/js/auth-ui.js | 2 +- static/note.html | 197 +- static/pad.html | 196 +- static/public.html | 9 +- static/workspace.html | 66 +- 19 files changed, 5512 insertions(+), 685 deletions(-) create mode 100644 migrations/mysql/0011_share_invitations.sql create mode 100644 migrations/postgres/0011_share_invitations.sql create mode 100644 migrations/sqlite/0011_share_invitations.sql diff --git a/.env.example b/.env.example index ec036c1..6c73e4a 100644 --- a/.env.example +++ b/.env.example @@ -64,10 +64,12 @@ MYSQL_USER=rustpad MYSQL_PASSWORD=rustpad MYSQL_ROOT_PASSWORD=rustpad_root -# Optional account password reset via SMTP +# Optional settings REGISTRATION_ENABLED=false ACCOUNT_CONFIRMATION_REQUIRED=false +SHARE_CONFIRMATION_REQUIRED=true +# smtp mailing PUBLIC_URL=https://pad.example.com # SMTP_HOST=smtp.example.com SMTP_PORT=587 diff --git a/Cargo.lock b/Cargo.lock index a704d39..848d56c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ dependencies = [ "libc", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -34,7 +43,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -70,6 +79,480 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.2", + "sha1 0.10.7", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-runtime" +version = "1.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c9b9de216a988dd54b754a82a7660cfe14cee4f6782ae4524470972fa0ccb39" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.1.0", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2dd7213994e2ff9382ff100403b78c30d1b74cdfcd8fa9d0d1dc3a94a5c4874" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.2", + "http-body 1.1.0", + "lru", + "percent-encoding", + "regex-lite", + "sha2 0.11.0", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.102.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c82b3ac19f1431854f7ace3a7531674633e286bfdde21976893bfee36fd493b" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.104.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321000d2b4c5519ee573f73167f612efd7329322d9b26969ad1979f0427f1913" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.107.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0d328ba962af23ecfa3c9f23b98d3d35e325fa218d7f13d17a6bf522f8a560" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint", + "form_urlencoded", + "hex", + "hmac 0.13.0", + "http 0.2.12", + "http 1.4.2", + "p256", + "percent-encoding", + "sha2 0.11.0", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e8e65f4f81fcccdeb6c3eca2af17ac21d421a1786a26a394aecf421d616d3a" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "md-5 0.11.0", + "pin-project-lite", + "sha1 0.11.0", + "sha2 0.11.0", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d8391e65fcea47c586a22e1a41f173b38615b112b2c6b7a44e80cec3e6b706" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3ef8931ad1c98aa6a55b4256f847f3116090819844e0dd41ea682cac5dd2d3" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.15", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.10.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.42", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "701a947f4797e52a911e114a898667c746c39feea467bbd1abd7b3721f702ffa" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e6f5caf6fea86f8c2206541ab5857cfcda9013426cdbe8fa0098b9e2d32182" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9db177daa6ba8afb9ee1aefcf548c907abcf52065e394ee11a92780057fe0e8c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.2", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7442cb268338f0eb8278140a107c046756aa01093d8ef5e99628d34ae09c94f5" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.4.2", +] + +[[package]] +name = "aws-smithy-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b42fcf341259d85ca10fac9a2f6448a8ec691c6955a18e45bc3b71a85fab85" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.2", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16bf10b03a3c01e6b3b7d47cd964e873ffe9e7d4e80fad16bd4c077cb068531" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.9" @@ -81,10 +564,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", - "hyper", + "hyper 1.10.1", "hyper-util", "itoa", "matchit", @@ -97,7 +580,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.10.7", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -115,8 +598,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -126,12 +609,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -153,7 +652,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -165,6 +664,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -183,6 +691,16 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cc" version = "1.2.67" @@ -190,6 +708,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -213,6 +733,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -228,6 +763,22 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -243,6 +794,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -258,6 +818,25 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.1", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.13" @@ -273,6 +852,18 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -283,6 +874,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -295,11 +904,17 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "deunicode" version = "1.6.2" @@ -312,12 +927,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -335,6 +962,26 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.16.0" @@ -344,6 +991,26 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email-encoding" version = "0.4.1" @@ -413,6 +1080,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -427,15 +1104,27 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.9", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -445,6 +1134,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -536,6 +1231,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -557,10 +1253,70 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.2", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -569,7 +1325,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -605,7 +1372,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -614,7 +1381,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -626,6 +1402,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.2" @@ -636,6 +1423,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.1.0" @@ -643,7 +1441,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http", + "http 1.4.2", ] [[package]] @@ -654,8 +1452,8 @@ checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.2", + "http-body 1.1.0", "pin-project-lite", ] @@ -677,6 +1475,39 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.10.1" @@ -687,14 +1518,47 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http", - "http-body", + "h2 0.4.15", + "http 1.4.2", + "http-body 1.1.0", "httparse", "httpdate", "itoa", "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http 1.4.2", + "hyper 1.10.1", + "hyper-util", + "rustls 0.23.42", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", ] [[package]] @@ -703,13 +1567,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", - "http", - "http-body", - "hyper", + "futures-channel", + "futures-util", + "http 1.4.2", + "http-body 1.1.0", + "hyper 1.10.1", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.5", "tokio", "tower-service", + "tracing", ] [[package]] @@ -848,12 +1720,28 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.103" @@ -871,7 +1759,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.9", ] [[package]] @@ -893,10 +1781,10 @@ dependencies = [ "nom", "percent-encoding", "quoted_printable", - "rustls", - "socket2", + "rustls 0.23.42", + "socket2 0.6.5", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", "url", "webpki-roots 1.0.8", ] @@ -957,6 +1845,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "matchers" version = "0.2.0" @@ -979,7 +1876,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -1024,11 +1931,11 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.2", "httparse", "memchr", "mime", - "spin", + "spin 0.9.9", "version_check", ] @@ -1066,6 +1973,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1101,6 +2014,30 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -1162,6 +2099,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkcs1" version = "0.7.5" @@ -1204,6 +2147,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1213,6 +2162,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1243,6 +2201,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -1331,12 +2295,28 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -1357,8 +2337,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -1371,21 +2351,55 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.13", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -1395,12 +2409,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1408,10 +2433,14 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.0.9" +version = "0.0.11" dependencies = [ "argon2", + "aws-config", + "aws-credential-types", + "aws-sdk-s3", "axum", + "bytes", "chrono", "dotenvy", "futures-util", @@ -1421,7 +2450,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "slug", "sqlx", "tokio", @@ -1443,12 +2472,74 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1522,8 +2613,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1533,8 +2635,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1568,7 +2681,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -1597,6 +2710,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.5" @@ -1616,6 +2739,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "spki" version = "0.7.3" @@ -1663,10 +2792,10 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rustls", + "rustls 0.23.42", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "tokio", @@ -1704,7 +2833,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -1727,7 +2856,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -1737,18 +2866,18 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", "rand 0.8.7", "rsa", "serde", - "sha1", - "sha2", + "sha1 0.10.7", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -1776,17 +2905,17 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.7", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -1911,6 +3040,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1947,7 +3106,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -1963,13 +3122,23 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls", + "rustls 0.23.42", "tokio", ] @@ -2035,8 +3204,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.2", + "http-body 1.1.0", "http-body-util", "http-range-header", "httpdate", @@ -2125,6 +3294,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.29.0" @@ -2133,11 +3308,11 @@ checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.2", "httparse", "log", "rand 0.9.5", - "sha1", + "sha1 0.10.7", "thiserror", ] @@ -2198,12 +3373,28 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2222,6 +3413,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2544,6 +3750,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index f4a75f3..e4dac3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.10" +version = "0.0.11" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/migrations/mysql/0011_share_invitations.sql b/migrations/mysql/0011_share_invitations.sql new file mode 100644 index 0000000..9cc19a1 --- /dev/null +++ b/migrations/mysql/0011_share_invitations.sql @@ -0,0 +1,15 @@ +CREATE TABLE resource_share_invitations ( + token_hash VARCHAR(64) PRIMARY KEY, + resource_kind VARCHAR(16) NOT NULL, + resource_slug VARCHAR(255) NOT NULL, + user_id BIGINT NOT NULL, + permission VARCHAR(2) NOT NULL, + created_by BIGINT NOT NULL, + expires_at TEXT NOT NULL, + accepted_at TEXT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + UNIQUE KEY uq_resource_share_invitation (resource_kind, resource_slug, user_id), + CONSTRAINT fk_share_invitation_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_share_invitation_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE +); +CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id); diff --git a/migrations/postgres/0011_share_invitations.sql b/migrations/postgres/0011_share_invitations.sql new file mode 100644 index 0000000..479d8e3 --- /dev/null +++ b/migrations/postgres/0011_share_invitations.sql @@ -0,0 +1,13 @@ +CREATE TABLE resource_share_invitations ( + token_hash TEXT PRIMARY KEY, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK(permission IN ('ro','rw')), + created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP::text), + UNIQUE(resource_kind, resource_slug, user_id) +); +CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id); diff --git a/migrations/sqlite/0011_share_invitations.sql b/migrations/sqlite/0011_share_invitations.sql new file mode 100644 index 0000000..0c7d747 --- /dev/null +++ b/migrations/sqlite/0011_share_invitations.sql @@ -0,0 +1,13 @@ +CREATE TABLE resource_share_invitations ( + token_hash TEXT PRIMARY KEY, + resource_kind TEXT NOT NULL, + resource_slug TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + permission TEXT NOT NULL CHECK(permission IN ('ro','rw')), + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + accepted_at TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(resource_kind, resource_slug, user_id) +); +CREATE INDEX idx_resource_share_invitations_user ON resource_share_invitations(user_id); diff --git a/src/app.rs b/src/app.rs index 52ff6cb..b8dba8e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -52,6 +52,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/api/auth/resources/privacy", post(auth::set_resource_privacy)) .route("/api/auth/resources/sharing", get(auth::resource_sharing).post(auth::share_resource_users).delete(auth::remove_resource_user)) .route("/api/auth/resources/share-links", post(auth::create_share_link).put(auth::update_share_link).delete(auth::revoke_share_link)) + .route("/share-invitations/{token}/accept", get(auth::accept_share_invitation)) .route("/api/auth/password-reset", post(auth::request_reset)) .route("/api/auth/password-reset/confirm", post(auth::confirm_reset)) .route("/api/public/{token}", get(api::public_page)) diff --git a/src/auth.rs b/src/auth.rs index 41d857b..d958b37 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,5 +1,5 @@ use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; -use axum::{extract::State, http::{HeaderMap, StatusCode}, Json}; +use axum::{extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::Redirect, Json}; use chrono::{Duration, Utc}; use lettre::{ message::{header::ContentType, Mailbox, MultiPart, SinglePart}, @@ -226,6 +226,9 @@ pub async fn share_resource_users(State(state): State, headers: Hea let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; + if state.share_confirmation_required && state.smtp.is_none() { + return Err(AuthError::service_unavailable("Share confirmation requires SMTP configuration.")); + } let emails: Vec = req.emails.split(',').map(|v| normalize(v)).filter(|v| !v.is_empty()).collect(); if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request("Enter between 1 and 100 registered e-mail addresses.")); } let mut missing = Vec::new(); @@ -233,13 +236,52 @@ pub async fn share_resource_users(State(state): State, headers: Hea let user = find_user_by_email(&state, &email).await?; let Some(user) = user else { missing.push(email); continue; }; if user.id == owner.id { continue; } + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) - .bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + + if state.share_confirmation_required { + let token = random_token(); + let token_hash = hash_token(&token); + let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); + sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)")) + .bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at) + .execute(state.db.pool()).await.map_err(AuthError::database)?; + if let Err(error) = send_share_invitation(state.smtp.as_ref().unwrap(), &owner, &user, &req.kind, req.slug.trim(), permission, &token).await { + let _ = sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE token_hash = ?")) + .bind(&token_hash).execute(state.db.pool()).await; + return Err(error); + } + } else { + sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) + .bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; + } } if !missing.is_empty() { return Err(AuthError::bad_request(&format!("No registered account for: {}", missing.join(", ")))); } - Ok(Json(serde_json::json!({"ok":true}))) + Ok(Json(serde_json::json!({"ok":true,"confirmation_required":state.share_confirmation_required}))) +} + +pub async fn accept_share_invitation(State(state): State, AxumPath(token): AxumPath) -> Result { + let token_hash = hash_token(token.trim()); + let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?")) + .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; + let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| AuthError::bad_request("The sharing invitation is invalid or has expired."))?; + let expires = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The sharing invitation is invalid or has expired."))?.with_timezone(&Utc); + if accepted_at.is_none() { + if expires <= Utc::now() { return Err(AuthError::bad_request("The sharing invitation is invalid or has expired.")); } + let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + .bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) + .bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?")) + .bind(Utc::now().to_rfc3339()).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?; + tx.commit().await.map_err(AuthError::database)?; + } + let target = if kind == "workspace" { format!("/w/{slug}") } else { format!("/p/{slug}") }; + Ok(Redirect::to(&target)) } pub async fn remove_resource_user(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { @@ -249,6 +291,8 @@ pub async fn remove_resource_user(State(state): State, headers: Hea if let Some(user) = find_user_by_email(&state, &email).await? { sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; } Ok(Json(serde_json::json!({"ok":true}))) } @@ -262,7 +306,9 @@ pub async fn resource_sharing(State(state): State, headers: HeaderM .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; let links: Vec<(String,String,Option,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC")) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }))) + let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email")) + .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }))) } pub async fn create_share_link(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { @@ -609,6 +655,37 @@ async fn send_registration_email( send_message(smtp, message, "registration e-mail").await } +async fn send_share_invitation( + smtp: &SmtpConfig, + owner: &User, + recipient_user: &User, + kind: &str, + slug: &str, + permission: &str, + token: &str, +) -> Result<(), AuthError> { + let site = smtp.public_url.trim_end_matches('/'); + let accept_url = format!("{site}/share-invitations/{token}/accept"); + let resource_label = if kind == "workspace" { "workspace" } else { "note" }; + let access_label = if permission == "rw" { "view and edit" } else { "view" }; + let sender = smtp.from.parse::().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; + let recipient = recipient_user.email.parse::().map_err(|_| AuthError::internal("Invalid recipient address."))?; + let subject = format!("{} shared a RustPad {} with you", owner.nickname, resource_label); + let text_body = format!( + "Hello {},\n\n{} shared the {} '{}' with you ({access_label}).\nAccept the invitation within 7 days:\n{}\n\nIf you were not expecting this invitation, ignore this message.", + recipient_user.nickname, owner.nickname, resource_label, slug, accept_url + ); + let html_body = format!(r#"

A RustPad {resource_label} was shared with you

Hello {},

{} shared {} with you. Permission: {access_label}.

Accept invitation

This link expires in 7 days.

"#, + recipient_user.nickname, owner.nickname, slug, accept_url + ); + let message = Message::builder().from(sender).to(recipient).subject(subject) + .multipart(MultiPart::alternative() + .singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text_body)) + .singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html_body))) + .map_err(|_| AuthError::internal("Failed to build sharing invitation e-mail."))?; + send_message(smtp, message, "sharing invitation e-mail").await +} + async fn send_message( smtp: &SmtpConfig, message: Message, diff --git a/src/config.rs b/src/config.rs index 35d0e65..dd725fc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,7 @@ pub struct Config { pub smtp: Option, pub registration_enabled: bool, pub account_confirmation_required: bool, + pub share_confirmation_required: bool, pub frontend_log_level: String, pub anonymous_access_token_ttl_days: i64, pub user_session_ttl_days: i64, @@ -82,6 +83,7 @@ impl Config { smtp, registration_enabled: env_bool("REGISTRATION_ENABLED", false)?, account_confirmation_required: env_bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, + share_confirmation_required: env_bool("SHARE_CONFIRMATION_REQUIRED", false)?, frontend_log_level: env_log_level("FRONTEND_LOG_LEVEL", "warn")?, anonymous_access_token_ttl_days, user_session_ttl_days, diff --git a/src/main.rs b/src/main.rs index edc7f80..6d158cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,7 @@ async fn main() -> Result<(), Box> { file_cache_max_age_seconds = config.file_cache_max_age_seconds, registration_enabled = config.registration_enabled, account_confirmation_required = config.account_confirmation_required, + share_confirmation_required = config.share_confirmation_required, frontend_log_level = %config.frontend_log_level, anonymous_access_token_ttl_days = config.anonymous_access_token_ttl_days, user_session_ttl_days = config.user_session_ttl_days, @@ -64,6 +65,7 @@ async fn main() -> Result<(), Box> { config.smtp.clone(), config.registration_enabled, config.account_confirmation_required, + config.share_confirmation_required, config.frontend_log_level.clone(), config.anonymous_access_token_ttl_days, config.user_session_ttl_days, diff --git a/src/state.rs b/src/state.rs index 836a389..fa4013e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -42,6 +42,7 @@ pub struct AppState { pub smtp: Option, pub registration_enabled: bool, pub account_confirmation_required: bool, + pub share_confirmation_required: bool, pub frontend_log_level: String, pub anonymous_access_token_ttl_days: i64, pub user_session_ttl_days: i64, @@ -51,8 +52,8 @@ pub struct AppState { } impl AppState { - pub fn new(db: Database, asset_version: String, storage: crate::storage::Storage, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { - Self { db, asset_version, storage, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } + pub fn new(db: Database, asset_version: String, storage: crate::storage::Storage, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, share_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { + Self { db, asset_version, storage, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, share_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } } async fn channel_for_key(&self, key: String) -> broadcast::Sender { if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } diff --git a/static/css/styles.css b/static/css/styles.css index e2f91dd..abe9899 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -20,666 +20,3640 @@ --success: #40d3a3; } -* { box-sizing: border-box; } -html { min-width: 320px; background: var(--bg); } -body { min-height: 100vh; margin: 0; background: var(--bg); color: var(--text); } -button, input, select, textarea { font: inherit; } -button, a, input, select, textarea { -webkit-tap-highlight-color: transparent; } -button { cursor: pointer; } -button:disabled { cursor: wait; opacity: .65; } -a { color: inherit; } +* { + box-sizing: border-box; +} -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } -.brand { color: var(--text); font-weight: 700; text-decoration: none; letter-spacing: -.02em; } +html { + min-width: 320px; + background: var(--bg); +} -.site-header { width: min(720px, calc(100% - 32px)); min-height: 64px; margin: 0 auto; display: flex; align-items: center; border-bottom: 1px solid var(--border); } +body { + min-height: 100vh; + margin: 0; + background: var(--bg); + color: var(--text); +} -.home-layout { width: min(520px, calc(100% - 32px)); margin: 0 auto; padding: 72px 0; } -.home-intro { margin-bottom: 28px; } -.home-intro h1 { margin: 0; font-size: clamp(2rem, 7vw, 3rem); letter-spacing: -.04em; } -.home-intro p { margin: 10px 0 0; color: var(--muted); line-height: 1.6; } +button, +input, +select, +textarea { + font: inherit; +} -.panel { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } -.create-panel { padding: 24px; } -#create-form { display: grid; gap: 20px; padding-top: 22px; } -.field { display: grid; gap: 8px; } -.field label { color: #dce1e8; font-size: .86rem; font-weight: 750; } -.label-row, .field-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.label-row span, .field-meta, .field small { color: var(--muted-2); font-size: .73rem; } -input, select { border: 1px solid var(--border-strong); outline: none; background: #0e1116; color: var(--text); } -input { width: 100%; min-height: 46px; padding: 0 13px; border-radius: 10px; } -input:focus, select:focus, textarea:focus { border-color: #7567db; outline: 1px solid #7567db; outline-offset: 1px; } -.password-input { position: relative; } -.password-input input { padding-right: 72px; } -.text-button { position: static; transform: none; border: 0; background: transparent; color: #a99ef8; padding: 4px 2px; font-size: .78rem; } -.text-button:hover { color: #c3bbff; } -.password-toggle { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); padding: 7px; } -[hidden] { display: none !important; } -.form-message { min-height: 1.2em; margin: -5px 0 0; font-size: .8rem; } -.error { color: var(--danger); } -.primary-button, .secondary-button, .inline-button { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 40px; border-radius: 8px; font-weight: 650; text-decoration: none; } -.primary-button { width: 100%; border: 1px solid #8372ef; background: var(--accent); color: white; padding: 0 16px; } -.primary-button:hover { background: var(--accent-hover); } -.secondary-button { border: 1px solid var(--border-strong); background: var(--surface-2); color: #d2d8e1; padding: 0 13px; } -.secondary-button:hover { border-color: #4b5565; background: var(--surface-3); } -.inline-button { width: auto; margin-top: 18px; padding: 0 16px; } +button, +a, +input, +select, +textarea { + -webkit-tap-highlight-color: transparent; +} -.app-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; min-height: 70px; padding: 0 20px; border-bottom: 1px solid var(--border); background: #0d1015; } -.app-header__main, .header-actions { display: flex; align-items: center; gap: 14px; min-width: 0; } -.header-divider { width: 1px; height: 30px; background: var(--border); } -.document-heading { min-width: 0; } -.document-heading h1 { overflow: hidden; margin: 0; font-size: 1rem; white-space: nowrap; text-overflow: ellipsis; } -.document-url { overflow: hidden; max-width: 360px; margin: 3px 0 0; color: var(--muted-2); font-size: .72rem; white-space: nowrap; text-overflow: ellipsis; } -.status { display: inline-flex; align-items: center; gap: 8px; min-height: 34px; padding: 0 8px; color: var(--muted); font-size: .78rem; } -.status__dot { width: 7px; height: 7px; border-radius: 50%; background: #e3a94d; } -.status__dot.is-online { background: var(--success); } -.status__dot.is-offline { background: var(--danger); } +button { + cursor: pointer; +} -.editor-layout { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 0; height: calc(100vh - 70px); overflow: hidden; transition: grid-template-columns .18s ease; } -.history-open .editor-layout { grid-template-columns: minmax(0, 1fr) 340px; } -.editor-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; min-width: 0; background: var(--surface); } -.editor-toolbar { display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: #101319; } -.toolbar-group, .view-switch { display: inline-flex; align-items: center; gap: 4px; padding-right: 8px; border-right: 1px solid var(--border); } -.toolbar-group:last-child, .view-switch { padding-right: 0; border-right: 0; } -.toolbar-fill { flex: 1; } -.editor-toolbar button, .editor-toolbar select { min-height: 34px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #b8c0cc; padding: 0 9px; font-size: .78rem; } -.editor-toolbar button:hover, .editor-toolbar select:hover { border-color: var(--border); background: var(--surface-2); color: white; } -.editor-toolbar select { border-color: var(--border); background: #11151c; } -.view-switch { padding: 3px; border: 1px solid var(--border); border-radius: 9px; background: #0d1015; } -.view-switch button.active { background: var(--surface-3); color: white; } +button:disabled { + cursor: wait; + opacity: .65; +} -.workspace { display: grid; min-height: 0; background: #0e1116; } -.workspace.view-split { grid-template-columns: 1fr 1fr; } -.workspace.view-edit { grid-template-columns: 1fr; } -.workspace.view-preview { grid-template-columns: 1fr; } -.workspace.view-edit .preview-column, .workspace.view-preview .editor-column { display: none; } -.editor-column, .preview-column { display: grid; grid-template-rows: 30px minmax(0, 1fr); min-width: 0; min-height: 0; } -.preview-column { border-left: 1px solid var(--border); } -.column-label { display: flex; align-items: center; padding: 0 18px; border-bottom: 1px solid #202631; background: #10141a; color: var(--muted-2); font-size: .7rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; } -textarea { display: block; width: 100%; height: 100%; min-height: 0; resize: none; padding: 24px; border: 0; outline: none; background: #0d1015; color: #edf1f6; font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; caret-color: #9b89ff; } -textarea::placeholder { color: #515a68; } -textarea::selection { background: rgba(124,104,238,.35); } -.preview { overflow: auto; min-height: 0; padding: 24px; color: #dce2eb; line-height: 1.72; } -.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: 1.25em 0 .5em; letter-spacing: -.03em; } -.markdown-body h1:first-child, .markdown-body h2:first-child, .markdown-body h3:first-child { margin-top: 0; } -.markdown-body h1 { font-size: 2rem; } -.markdown-body h2 { font-size: 1.5rem; } -.markdown-body p { margin: .72em 0; } -.markdown-body code { padding: .16em .36em; border: 1px solid #303745; border-radius: 5px; background: #1a2029; } -.markdown-body pre { overflow: auto; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; } -.markdown-body pre code { padding: 0; border: 0; background: transparent; } -.markdown-body blockquote { margin: 1em 0; padding: .2em 1em; border-left: 3px solid var(--accent); color: #abb5c3; } -.markdown-body a { color: #aa9df8; } -.markdown-body hr { border: 0; border-top: 1px solid var(--border); } -.editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 38px; padding: 0 16px; border-top: 1px solid var(--border); color: var(--muted-2); font-size: .72rem; } -.document-stats { display: flex; gap: 14px; } +a { + color: inherit; +} -.history-panel { position: relative; width: 340px; overflow: hidden; border-left: 1px solid var(--border); background: #101319; transform: translateX(100%); transition: transform .18s ease; } -.history-panel.open { transform: translateX(0); } -.history-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 18px; } -.history-help { margin: 0; padding: 0 18px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: .8rem; line-height: 1.5; } -.icon-button { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: #c5ccd6; font-size: 1.2rem; } -.history-list { overflow: auto; height: calc(100vh - 185px); padding: 10px 18px 24px; } -.revision { position: relative; display: grid; grid-template-columns: 12px 1fr; gap: 10px; padding: 13px 0; } -.revision::after { content: ""; position: absolute; top: 25px; bottom: -13px; left: 5px; width: 1px; background: var(--border); } -.revision:last-child::after { display: none; } -.revision__marker { position: relative; z-index: 1; width: 11px; height: 11px; margin-top: 3px; border: 2px solid #8b7af4; border-radius: 50%; background: #101319; } -.revision time { display: block; color: #d1d7df; font-size: .8rem; } -.revision small { display: block; margin-top: 4px; color: var(--muted-2); } -.revision button { margin-top: 10px; border: 0; background: transparent; color: #9e92f5; padding: 0; font-size: .76rem; font-weight: 750; } -.empty { padding: 28px 0; color: var(--muted-2); text-align: center; font-size: .82rem; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} - dialog { width: min(420px, calc(100% - 24px)); padding: 0; border: 0; background: transparent; color: inherit; } -dialog::backdrop { background: rgba(4,6,9,.82); } -.dialog-panel { display: grid; gap: 12px; padding: 24px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); text-align: left; } -.dialog-panel input { margin-top: 6px; } -.dialog-panel .primary-button { margin-top: 2px; } -.dialog-link { color: var(--muted); font-size: .78rem; } -.toast { position: fixed; right: 20px; bottom: 20px; z-index: 20; padding: 11px 14px; border: 1px solid var(--border-strong); border-radius: 10px; background: #171b23; color: #e6eaf0; font-size: .8rem; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .16s ease; } -.toast.visible { opacity: 1; transform: translateY(0); } -.error-page { display: grid; place-items: center; min-height: 100vh; padding: 24px; text-align: center; } -.error-page h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); letter-spacing: -.05em; } -.error-page p { color: var(--muted); } +.brand { + color: var(--text); + font-weight: 700; + text-decoration: none; + letter-spacing: -.02em; +} + +.site-header { + width: min(720px, calc(100% - 32px)); + min-height: 64px; + margin: 0 auto; + display: flex; + align-items: center; + border-bottom: 1px solid var(--border); +} + +.home-layout { + width: min(520px, calc(100% - 32px)); + margin: 0 auto; + padding: 72px 0; +} + +.home-intro { + margin-bottom: 28px; +} + +.home-intro h1 { + margin: 0; + font-size: clamp(2rem, 7vw, 3rem); + letter-spacing: -.04em; +} + +.home-intro p { + margin: 10px 0 0; + color: var(--muted); + line-height: 1.6; +} + +.panel { + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} + +.create-panel { + padding: 24px; +} + +#create-form { + display: grid; + gap: 20px; + padding-top: 22px; +} + +.field { + display: grid; + gap: 8px; +} + +.field label { + color: #dce1e8; + font-size: .86rem; + font-weight: 750; +} + +.label-row, +.field-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.label-row span, +.field-meta, +.field small { + color: var(--muted-2); + font-size: .73rem; +} + +input, +select { + border: 1px solid var(--border-strong); + outline: none; + background: #0e1116; + color: var(--text); +} + +input { + width: 100%; + min-height: 46px; + padding: 0 13px; + border-radius: 10px; +} + +input:focus, +select:focus, +textarea:focus { + border-color: #7567db; + outline: 1px solid #7567db; + outline-offset: 1px; +} + +.password-input { + position: relative; +} + +.password-input input { + padding-right: 72px; +} + +.text-button { + position: static; + transform: none; + border: 0; + background: transparent; + color: #a99ef8; + padding: 4px 2px; + font-size: .78rem; +} + +.text-button:hover { + color: #c3bbff; +} + +.password-toggle { + position: absolute; + top: 50%; + right: 8px; + transform: translateY(-50%); + padding: 7px; +} + +[hidden] { + display: none !important; +} + +.form-message { + min-height: 1.2em; + margin: -5px 0 0; + font-size: .8rem; +} + +.error { + color: var(--danger); +} + +.primary-button, +.secondary-button, +.inline-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 40px; + border-radius: 8px; + font-weight: 650; + text-decoration: none; +} + +.primary-button { + width: 100%; + border: 1px solid #8372ef; + background: var(--accent); + color: white; + padding: 0 16px; +} + +.primary-button:hover { + background: var(--accent-hover); +} + +.secondary-button { + border: 1px solid var(--border-strong); + background: var(--surface-2); + color: #d2d8e1; + padding: 0 13px; +} + +.secondary-button:hover { + border-color: #4b5565; + background: var(--surface-3); +} + +.inline-button { + width: auto; + margin-top: 18px; + padding: 0 16px; +} + +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + min-height: 70px; + padding: 0 20px; + border-bottom: 1px solid var(--border); + background: #0d1015; +} + +.app-header__main, +.header-actions { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.header-divider { + width: 1px; + height: 30px; + background: var(--border); +} + +.document-heading { + min-width: 0; +} + +.document-heading h1 { + overflow: hidden; + margin: 0; + font-size: 1rem; + white-space: nowrap; + text-overflow: ellipsis; +} + +.document-url { + overflow: hidden; + max-width: 360px; + margin: 3px 0 0; + color: var(--muted-2); + font-size: .72rem; + white-space: nowrap; + text-overflow: ellipsis; +} + +.status { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 34px; + padding: 0 8px; + color: var(--muted); + font-size: .78rem; +} + +.status__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #e3a94d; +} + +.status__dot.is-online { + background: var(--success); +} + +.status__dot.is-offline { + background: var(--danger); +} + +.editor-layout { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 0; + height: calc(100vh - 70px); + overflow: hidden; + transition: grid-template-columns .18s ease; +} + +.history-open .editor-layout { + grid-template-columns: minmax(0, 1fr) 340px; +} + +.editor-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-width: 0; + background: var(--surface); +} + +.editor-toolbar { + display: flex; + align-items: center; + gap: 8px; + min-height: 52px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: #101319; +} + +.toolbar-group, +.view-switch { + display: inline-flex; + align-items: center; + gap: 4px; + padding-right: 8px; + border-right: 1px solid var(--border); +} + +.toolbar-group:last-child, +.view-switch { + padding-right: 0; + border-right: 0; +} + +.toolbar-fill { + flex: 1; +} + +.editor-toolbar button, +.editor-toolbar select { + min-height: 34px; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: #b8c0cc; + padding: 0 9px; + font-size: .78rem; +} + +.editor-toolbar button:hover, +.editor-toolbar select:hover { + border-color: var(--border); + background: var(--surface-2); + color: white; +} + +.editor-toolbar select { + border-color: var(--border); + background: #11151c; +} + +.view-switch { + padding: 3px; + border: 1px solid var(--border); + border-radius: 9px; + background: #0d1015; +} + +.view-switch button.active { + background: var(--surface-3); + color: white; +} + +.workspace { + display: grid; + min-height: 0; + background: #0e1116; +} + +.workspace.view-split { + grid-template-columns: 1fr 1fr; +} + +.workspace.view-edit { + grid-template-columns: 1fr; +} + +.workspace.view-preview { + grid-template-columns: 1fr; +} + +.workspace.view-edit .preview-column, +.workspace.view-preview .editor-column { + display: none; +} + +.editor-column, +.preview-column { + display: grid; + grid-template-rows: 30px minmax(0, 1fr); + min-width: 0; + min-height: 0; +} + +.preview-column { + border-left: 1px solid var(--border); +} + +.column-label { + display: flex; + align-items: center; + padding: 0 18px; + border-bottom: 1px solid #202631; + background: #10141a; + color: var(--muted-2); + font-size: .7rem; + font-weight: 750; + letter-spacing: .08em; + text-transform: uppercase; +} + +textarea { + display: block; + width: 100%; + height: 100%; + min-height: 0; + resize: none; + padding: 24px; + border: 0; + outline: none; + background: #0d1015; + color: #edf1f6; + font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; + caret-color: #9b89ff; +} + +textarea::placeholder { + color: #515a68; +} + +textarea::selection { + background: rgba(124, 104, 238, .35); +} + +.preview { + overflow: auto; + min-height: 0; + padding: 24px; + color: #dce2eb; + line-height: 1.72; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3 { + margin: 1.25em 0 .5em; + letter-spacing: -.03em; +} + +.markdown-body h1:first-child, +.markdown-body h2:first-child, +.markdown-body h3:first-child { + margin-top: 0; +} + +.markdown-body h1 { + font-size: 2rem; +} + +.markdown-body h2 { + font-size: 1.5rem; +} + +.markdown-body p { + margin: .72em 0; +} + +.markdown-body code { + padding: .16em .36em; + border: 1px solid #303745; + border-radius: 5px; + background: #1a2029; +} + +.markdown-body pre { + overflow: auto; + padding: 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: #0a0d12; +} + +.markdown-body pre code { + padding: 0; + border: 0; + background: transparent; +} + +.markdown-body blockquote { + margin: 1em 0; + padding: .2em 1em; + border-left: 3px solid var(--accent); + color: #abb5c3; +} + +.markdown-body a { + color: #aa9df8; +} + +.markdown-body hr { + border: 0; + border-top: 1px solid var(--border); +} + +.editor-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-height: 38px; + padding: 0 16px; + border-top: 1px solid var(--border); + color: var(--muted-2); + font-size: .72rem; +} + +.document-stats { + display: flex; + gap: 14px; +} + +.history-panel { + position: relative; + width: 340px; + overflow: hidden; + border-left: 1px solid var(--border); + background: #101319; + transform: translateX(100%); + transition: transform .18s ease; +} + +.history-panel.open { + transform: translateX(0); +} + +.history-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 18px; +} + +.history-help { + margin: 0; + padding: 0 18px 16px; + border-bottom: 1px solid var(--border); + color: var(--muted); + font-size: .8rem; + line-height: 1.5; +} + +.icon-button { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: #c5ccd6; + font-size: 1.2rem; +} + +.history-list { + overflow: auto; + height: calc(100vh - 185px); + padding: 10px 18px 24px; +} + +.revision { + position: relative; + display: grid; + grid-template-columns: 12px 1fr; + gap: 10px; + padding: 13px 0; +} + +.revision::after { + content: ""; + position: absolute; + top: 25px; + bottom: -13px; + left: 5px; + width: 1px; + background: var(--border); +} + +.revision:last-child::after { + display: none; +} + +.revision__marker { + position: relative; + z-index: 1; + width: 11px; + height: 11px; + margin-top: 3px; + border: 2px solid #8b7af4; + border-radius: 50%; + background: #101319; +} + +.revision time { + display: block; + color: #d1d7df; + font-size: .8rem; +} + +.revision small { + display: block; + margin-top: 4px; + color: var(--muted-2); +} + +.revision button { + margin-top: 10px; + border: 0; + background: transparent; + color: #9e92f5; + padding: 0; + font-size: .76rem; + font-weight: 750; +} + +.empty { + padding: 28px 0; + color: var(--muted-2); + text-align: center; + font-size: .82rem; +} + +dialog { + width: min(420px, calc(100% - 24px)); + padding: 0; + border: 0; + background: transparent; + color: inherit; +} + +dialog::backdrop { + background: rgba(4, 6, 9, .82); +} + +.dialog-panel { + display: grid; + gap: 12px; + padding: 24px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + text-align: left; +} + +.dialog-panel input { + margin-top: 6px; +} + +.dialog-panel .primary-button { + margin-top: 2px; +} + +.dialog-link { + color: var(--muted); + font-size: .78rem; +} + +.toast { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 20; + padding: 11px 14px; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: #171b23; + color: #e6eaf0; + font-size: .8rem; + opacity: 0; + transform: translateY(8px); + pointer-events: none; + transition: .16s ease; +} + +.toast.visible { + opacity: 1; + transform: translateY(0); +} + +.error-page { + display: grid; + place-items: center; + min-height: 100vh; + padding: 24px; + text-align: center; +} + +.error-page h1 { + margin: 0; + font-size: clamp(2rem, 6vw, 4rem); + letter-spacing: -.05em; +} + +.error-page p { + color: var(--muted); +} @media (max-width: 980px) { - .app-header { align-items: flex-start; min-height: auto; padding: 12px 14px; } - .app-header__main, .header-actions { flex-wrap: wrap; } - .editor-layout { height: calc(100vh - 92px); } - .toolbar-settings { display: none; } - .history-open .editor-layout { grid-template-columns: minmax(0, 1fr); } - .history-panel { position: absolute; top: 0; right: 0; bottom: 0; z-index: 10; } + .app-header { + align-items: flex-start; + min-height: auto; + padding: 12px 14px; + } + + .app-header__main, + .header-actions { + flex-wrap: wrap; + } + + .editor-layout { + height: calc(100vh - 92px); + } + + .toolbar-settings { + display: none; + } + + .history-open .editor-layout { + grid-template-columns: minmax(0, 1fr); + } + + .history-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 10; + } } @media (max-width: 720px) { - .site-header, .home-layout { width: min(100% - 24px, 520px); } - .document-url, .header-divider { display: none; } - .home-layout { padding: 44px 0; } - .app-header { gap: 10px; } - .status { display: none; } - .editor-toolbar { overflow-x: auto; flex-wrap: nowrap; } - .toolbar-fill { display: none; } - .workspace.view-split { grid-template-columns: 1fr; } - .workspace.view-split .preview-column { display: none; } - .view-switch button[data-view="split"] { display: none; } - .preview-column { border-left: 0; } - textarea, .preview { padding: 18px; } - .editor-footer { align-items: flex-start; flex-direction: column; justify-content: center; gap: 2px; padding: 7px 12px; } + + .site-header, + .home-layout { + width: min(100% - 24px, 520px); + } + + .document-url, + .header-divider { + display: none; + } + + .home-layout { + padding: 44px 0; + } + + .app-header { + gap: 10px; + } + + .status { + display: none; + } + + .editor-toolbar { + overflow-x: auto; + flex-wrap: nowrap; + } + + .toolbar-fill { + display: none; + } + + .workspace.view-split { + grid-template-columns: 1fr; + } + + .workspace.view-split .preview-column { + display: none; + } + + .view-switch button[data-view="split"] { + display: none; + } + + .preview-column { + border-left: 0; + } + + textarea, + .preview { + padding: 18px; + } + + .editor-footer { + align-items: flex-start; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 7px 12px; + } } @media (max-width: 480px) { - .create-panel { padding: 20px; } - .header-actions { gap: 6px; } - .secondary-button { min-height: 36px; padding: 0 10px; font-size: .75rem; } - .history-panel { width: 100%; } + .create-panel { + padding: 20px; + } + + .header-actions { + gap: 6px; + } + + .secondary-button { + min-height: 36px; + padding: 0 10px; + font-size: .75rem; + } + + .history-panel { + width: 100%; + } } -.markdown-toggle { border: 1px solid var(--border) !important; } -.markdown-toggle.active { background: var(--surface-3) !important; color: white !important; } -.preview--raw { white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.markdown-toggle { + border: 1px solid var(--border) !important; +} -.workspace-page { width: min(1100px, calc(100% - 32px)); margin: 0 auto; padding: 44px 0 80px; } -.workspace-top { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding-bottom: 24px; border-bottom: 1px solid var(--border); } -.workspace-top h2 { margin: 0; font-size: 2rem; } -.workspace-top p { margin: 7px 0 0; color: var(--muted); } -.notes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; padding-top: 20px; } -.note-card { min-height: 130px; padding: 18px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); text-decoration: none; transition: border-color .15s, background .15s; } -.note-card:hover { border-color: var(--border-strong); background: var(--surface-2); } -.note-card h3 { margin: 0; font-size: 1rem; } -.note-card p { margin: 36px 0 0; color: var(--muted); font-size: .75rem; } -.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } -.inline-button { width: auto; padding-inline: 18px; } +.markdown-toggle.active { + background: var(--surface-3) !important; + color: white !important; +} -.error-card { width: min(560px, 100%); padding: 32px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); } -.error-code { margin: 0 0 10px; color: var(--muted-2); font: 700 .78rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .12em; } -.error-card h1 { font-size: clamp(1.8rem, 6vw, 3rem); } -.error-card > p:not(.error-code) { max-width: 46ch; margin: 14px auto 0; line-height: 1.6; } -.error-actions { display: flex; justify-content: center; gap: 8px; margin-top: 24px; } -@media (max-width: 480px) { .error-card { padding: 24px 18px; } .error-actions { flex-direction: column; } .error-actions .inline-button { width: 100%; } } +.preview--raw { + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.workspace-page { + width: min(1100px, calc(100% - 32px)); + margin: 0 auto; + padding: 44px 0 80px; +} + +.workspace-top { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border); +} + +.workspace-top h2 { + margin: 0; + font-size: 2rem; +} + +.workspace-top p { + margin: 7px 0 0; + color: var(--muted); +} + +.notes-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + padding-top: 20px; +} + +.note-card { + min-height: 130px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + text-decoration: none; + transition: border-color .15s, background .15s; +} + +.note-card:hover { + border-color: var(--border-strong); + background: var(--surface-2); +} + +.note-card h3 { + margin: 0; + font-size: 1rem; +} + +.note-card p { + margin: 36px 0 0; + color: var(--muted); + font-size: .75rem; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.inline-button { + width: auto; + padding-inline: 18px; +} + +.error-card { + width: min(560px, 100%); + padding: 32px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); +} + +.error-code { + margin: 0 0 10px; + color: var(--muted-2); + font: 700 .78rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; + letter-spacing: .12em; +} + +.error-card h1 { + font-size: clamp(1.8rem, 6vw, 3rem); +} + +.error-card>p:not(.error-code) { + max-width: 46ch; + margin: 14px auto 0; + line-height: 1.6; +} + +.error-actions { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 24px; +} + +@media (max-width: 480px) { + .error-card { + padding: 24px 18px; + } + + .error-actions { + flex-direction: column; + } + + .error-actions .inline-button { + width: 100%; + } +} /* Home: standalone note and workspace are separate choices. */ -.home-layout--wide { width: min(1040px, calc(100% - 32px)); } -.create-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; align-items: start; } -.create-panel__heading { margin-bottom: 22px; } -.create-panel__heading h2 { margin: 0; font-size: 1.35rem; } -.create-panel__heading p { margin: 7px 0 0; color: var(--muted); line-height: 1.5; } -.create-panel form { display: grid; gap: 18px; } -.create-panel .primary-button { width: 100%; } +.home-layout--wide { + width: min(1040px, calc(100% - 32px)); +} + +.create-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; + align-items: start; +} + +.create-panel__heading { + margin-bottom: 22px; +} + +.create-panel__heading h2 { + margin: 0; + font-size: 1.35rem; +} + +.create-panel__heading p { + margin: 7px 0 0; + color: var(--muted); + line-height: 1.5; +} + +.create-panel form { + display: grid; + gap: 18px; +} + +.create-panel .primary-button { + width: 100%; +} + @media (max-width: 760px) { - .home-layout--wide { width: min(100% - 24px, 560px); } - .create-grid { grid-template-columns: 1fr; } + .home-layout--wide { + width: min(100% - 24px, 560px); + } + + .create-grid { + grid-template-columns: 1fr; + } } /* Collaborative editor additions */ -.editor-shell { display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 0; overflow: hidden; background: #0d1015; } -.line-gutter { width: 62px; overflow: hidden; padding: 24px 8px 24px 0; border-right: 1px solid var(--border); color: var(--muted-2); font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; text-align: right; user-select: none; } -.line-gutter div { height: 1.72em; padding-right: 8px; } -.hide-line-numbers .editor-shell { grid-template-columns: 0 minmax(0, 1fr); } -.hide-line-numbers .line-gutter { width: 0; padding: 0; border: 0; } -.editor-shell textarea { padding-left: 18px; } -.line-toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: .78rem; white-space: nowrap; } -.line-toggle input { width: auto; min-height: auto; margin: 0; accent-color: var(--accent); } -.user-color-control { position: relative; display: inline-flex; align-items: center; } -.user-chip { display: inline-flex; align-items: center; gap: 7px; padding: 4px 6px; border: 0; border-radius: 7px; background: transparent; color: #dce2eb; font: inherit; font-size: .78rem; } -.user-chip:hover, .user-chip:focus-visible { background: var(--surface-2); outline: none; } -.user-chip__dot { width: 10px; height: 10px; flex: 0 0 auto; border: 1px solid color-mix(in srgb, var(--owner, var(--accent)) 72%, white); border-radius: 50%; background: var(--owner, var(--accent)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); } -.user-color-picker { position: absolute; top: calc(100% + 4px); left: 0; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0; pointer-events: none; } -.dialog-copy { margin: 0 0 4px; color: var(--muted); line-height: 1.5; } -.history-header h2 { margin: 0; } -.history-header p { margin: 4px 0 0; color: var(--muted-2); font-size: .75rem; } -.revision__marker { border-color: var(--owner, #8b7af4); background: var(--owner, #8b7af4); } -.revision__meta { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; } -.revision__meta strong { font-size: .82rem; } -.revision__snippet { margin: 8px 0 0; color: var(--muted); font-size: .76rem; line-height: 1.45; } -.revision__preview { max-height: 180px; overflow: auto; margin-top: 10px; padding: 10px; border: 1px solid var(--border); border-radius: 7px; background: #0b0e13; color: #c9d0da; font: .72rem/1.5 ui-monospace, monospace; white-space: pre-wrap; } -.revision button + button { margin-left: 12px; } -.mermaid { overflow: auto; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: #0a0d12; } -@media (max-width: 720px) { .line-gutter { width: 48px; padding-top: 18px; font-size: 15px; } .editor-shell textarea { padding: 18px 12px; font-size: 15px; } .user-color-control { display: none; } } +.editor-shell { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + min-height: 0; + overflow: hidden; + background: #0d1015; +} -.markdown-body img { display: block; max-width: 100%; height: auto; margin: 16px auto; border-radius: 10px; } -.markdown-body a { overflow-wrap: anywhere; } -.public-page { min-height: 100vh; background: var(--background); } -.public-header { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; justify-content: space-between; min-height: 64px; padding: 0 max(20px, calc((100vw - 900px) / 2)); border-bottom: 1px solid var(--border); background: rgba(13,16,21,.92); backdrop-filter: blur(12px); } -.public-document { width: min(900px, calc(100% - 32px)); margin: 0 auto; padding: 56px 0 96px; } -.public-document > h1 { margin: 0; font-size: clamp(2rem, 6vw, 4rem); line-height: 1.08; } -.public-meta { margin: 12px 0 36px; color: var(--muted); font-size: .8rem; } -.public-content { min-height: 240px; padding: 32px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); } -.public-content img { cursor: zoom-in; } -@media (max-width: 600px) { .public-document { padding-top: 32px; } .public-content { padding: 20px; } } +.line-gutter { + width: 62px; + overflow: hidden; + padding: 24px 8px 24px 0; + border-right: 1px solid var(--border); + color: var(--muted-2); + font: 400 17px/1.72 ui-monospace, SFMono-Regular, Consolas, monospace; + text-align: right; + user-select: none; +} + +.line-gutter div { + height: 1.72em; + padding-right: 8px; +} + +.hide-line-numbers .editor-shell { + grid-template-columns: 0 minmax(0, 1fr); +} + +.hide-line-numbers .line-gutter { + width: 0; + padding: 0; + border: 0; +} + +.editor-shell textarea { + padding-left: 18px; +} + +.line-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: .78rem; + white-space: nowrap; +} + +.line-toggle input { + width: auto; + min-height: auto; + margin: 0; + accent-color: var(--accent); +} + +.user-color-control { + position: relative; + display: inline-flex; + align-items: center; +} + +.user-chip { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 6px; + border: 0; + border-radius: 7px; + background: transparent; + color: #dce2eb; + font: inherit; + font-size: .78rem; +} + +.user-chip:hover, +.user-chip:focus-visible { + background: var(--surface-2); + outline: none; +} + +.user-chip__dot { + width: 10px; + height: 10px; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, var(--owner, var(--accent)) 72%, white); + border-radius: 50%; + background: var(--owner, var(--accent)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); +} + +.user-color-picker { + position: absolute; + top: calc(100% + 4px); + left: 0; + width: 1px; + height: 1px; + padding: 0; + border: 0; + opacity: 0; + pointer-events: none; +} + +.dialog-copy { + margin: 0 0 4px; + color: var(--muted); + line-height: 1.5; +} + +.history-header h2 { + margin: 0; +} + +.history-header p { + margin: 4px 0 0; + color: var(--muted-2); + font-size: .75rem; +} + +.revision__marker { + border-color: var(--owner, #8b7af4); + background: var(--owner, #8b7af4); +} + +.revision__meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.revision__meta strong { + font-size: .82rem; +} + +.revision__snippet { + margin: 8px 0 0; + color: var(--muted); + font-size: .76rem; + line-height: 1.45; +} + +.revision__preview { + max-height: 180px; + overflow: auto; + margin-top: 10px; + padding: 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: #0b0e13; + color: #c9d0da; + font: .72rem/1.5 ui-monospace, monospace; + white-space: pre-wrap; +} + +.revision button+button { + margin-left: 12px; +} + +.mermaid { + overflow: auto; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: #0a0d12; +} + +@media (max-width: 720px) { + .line-gutter { + width: 48px; + padding-top: 18px; + font-size: 15px; + } + + .editor-shell textarea { + padding: 18px 12px; + font-size: 15px; + } + + .user-color-control { + display: none; + } +} + +.markdown-body img { + display: block; + max-width: 100%; + height: auto; + margin: 16px auto; + border-radius: 10px; +} + +.markdown-body a { + overflow-wrap: anywhere; +} + +.public-page { + min-height: 100vh; + background: var(--background); +} + +.public-header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 64px; + padding: 0 max(20px, calc((100vw - 900px) / 2)); + border-bottom: 1px solid var(--border); + background: rgba(13, 16, 21, .92); + backdrop-filter: blur(12px); +} + +.public-document { + width: min(900px, calc(100% - 32px)); + margin: 0 auto; + padding: 56px 0 96px; +} + +.public-document>h1 { + margin: 0; + font-size: clamp(2rem, 6vw, 4rem); + line-height: 1.08; +} + +.public-meta { + margin: 12px 0 36px; + color: var(--muted); + font-size: .8rem; +} + +.public-content { + min-height: 240px; + padding: 32px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); +} + +.public-content img { + cursor: zoom-in; +} + +@media (max-width: 600px) { + .public-document { + padding-top: 32px; + } + + .public-content { + padding: 20px; + } +} /* Layout safeguards and home footer */ -.app-header__main, .document-heading { min-width: 0; } -.document-url { overflow-wrap: anywhere; } -.home-page { display: flex; min-height: 100vh; flex-direction: column; } -.home-page .home-layout { flex: 1 0 auto; } -.home-footer { flex: 0 0 auto; width: min(1040px, calc(100% - 32px)); margin: auto auto 28px; padding-top: 24px; color: var(--muted-2); font-size: .76rem; text-align: center; } -.home-footer__inner { display: flex; align-items: center; justify-content: center; gap: 10px; flex-wrap: wrap; } -.home-footer__separator { color: var(--border-strong); } -.home-footer .text-button { font-size: inherit; text-decoration: underline; text-underline-offset: 3px; } -.home-footer a { color: var(--muted); text-decoration: none; } -.home-footer a:hover { color: white; } -.home-header { border-bottom-color: rgba(195, 91, 54, .28); background: linear-gradient(135deg, rgba(126, 48, 27, .28), rgba(51, 25, 18, .08) 58%, transparent); } -.home-brand { background: linear-gradient(110deg, #f0aa74 0%, #cf633d 48%, #8f3827 100%); -webkit-background-clip: text; background-clip: text; color: transparent; } -@media (max-width: 760px) { .home-footer { width: min(100% - 24px, 560px); margin-bottom: 20px; } } +.app-header__main, +.document-heading { + min-width: 0; +} + +.document-url { + overflow-wrap: anywhere; +} + +.home-page { + display: flex; + min-height: 100vh; + flex-direction: column; +} + +.home-page .home-layout { + flex: 1 0 auto; +} + +.home-footer { + flex: 0 0 auto; + width: min(1040px, calc(100% - 32px)); + margin: auto auto 28px; + padding-top: 24px; + color: var(--muted-2); + font-size: .76rem; + text-align: center; +} + +.home-footer__inner { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + flex-wrap: wrap; +} + +.home-footer__separator { + color: var(--border-strong); +} + +.home-footer .text-button { + font-size: inherit; + text-decoration: underline; + text-underline-offset: 3px; +} + +.home-footer a { + color: var(--muted); + text-decoration: none; +} + +.home-footer a:hover { + color: white; +} + +.home-header { + border-bottom-color: rgba(195, 91, 54, .28); + background: linear-gradient(135deg, rgba(126, 48, 27, .28), rgba(51, 25, 18, .08) 58%, transparent); +} + +.home-brand { + background: linear-gradient(110deg, #f0aa74 0%, #cf633d 48%, #8f3827 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +@media (max-width: 760px) { + .home-footer { + width: min(100% - 24px, 560px); + margin-bottom: 20px; + } +} /* Show an author once at the start of each contiguous ownership block. */ -.line-gutter div { position: relative; } -.line-owner-label { position: absolute; left: 7px; top: 50%; max-width: 88px; overflow: hidden; padding: 2px 6px; border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--owner) 18%, #0d1015); color: #eef1f5; font: 600 10px/1.2 system-ui, sans-serif; text-overflow: ellipsis; white-space: nowrap; transform: translateY(-50%); } -@media (min-width: 721px) { .line-gutter { width: 132px; } } -@media (max-width: 720px) { .line-owner-label { display: none; } } +.line-gutter div { + position: relative; +} + +.line-owner-label { + position: absolute; + left: 7px; + top: 50%; + max-width: 88px; + overflow: hidden; + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--owner) 65%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--owner) 18%, #0d1015); + color: #eef1f5; + font: 600 10px/1.2 system-ui, sans-serif; + text-overflow: ellipsis; + white-space: nowrap; + transform: translateY(-50%); +} + +@media (min-width: 721px) { + .line-gutter { + width: 132px; + } +} + +@media (max-width: 720px) { + .line-owner-label { + display: none; + } +} /* Compact line numbers; author labels sit over the edited text, not in the gutter. */ -.line-gutter { width: 48px; } -.line-gutter div { position: static; } -.owner-labels { position: absolute; z-index: 2; top: 0; right: 12px; left: 48px; height: 100%; overflow: hidden; pointer-events: none; } -.owner-label { position: absolute; right: 8px; max-width: min(180px, 45%); overflow: hidden; padding: 2px 7px; border: 1px solid color-mix(in srgb, var(--owner) 62%, transparent); border-radius: 999px; background: color-mix(in srgb, var(--owner) 18%, #0d1015); color: #eef1f5; font: 600 10px/1.25 system-ui, sans-serif; opacity: .82; text-overflow: ellipsis; white-space: nowrap; } -.hide-line-numbers .owner-labels { left: 0; } -.line-owner-label { display: none !important; } -@media (min-width: 721px) { .line-gutter { width: 48px; } } -@media (max-width: 720px) { .line-gutter { width: 42px; } .owner-labels { left: 42px; right: 4px; } } +.line-gutter { + width: 48px; +} + +.line-gutter div { + position: static; +} + +.owner-labels { + position: absolute; + z-index: 2; + top: 0; + right: 12px; + left: 48px; + height: 100%; + overflow: hidden; + pointer-events: none; +} + +.owner-label { + position: absolute; + right: 8px; + max-width: min(180px, 45%); + overflow: hidden; + padding: 2px 7px; + border: 1px solid color-mix(in srgb, var(--owner) 62%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--owner) 18%, #0d1015); + color: #eef1f5; + font: 600 10px/1.25 system-ui, sans-serif; + opacity: .82; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hide-line-numbers .owner-labels { + left: 0; +} + +.line-owner-label { + display: none !important; +} + +@media (min-width: 721px) { + .line-gutter { + width: 48px; + } +} + +@media (max-width: 720px) { + .line-gutter { + width: 42px; + } + + .owner-labels { + left: 42px; + right: 4px; + } +} /* The rust gradient belongs only to the wordmark. */ -.home-header { background: transparent; } +.home-header { + background: transparent; +} /* Editor display preferences and author overlay */ -.editor-shell { position: relative; } -.owner-labels { z-index: 4; top: 0; bottom: 0; height: auto; } -.owner-line { position: absolute; right: 0; left: 0; height: var(--editor-line-height, 31px); border-left: 3px solid var(--owner); background: transparent; } -.owner-label { z-index: 1; transform: translateY(2px); } -.editor-controls { display: inline-flex; align-items: center; gap: 7px; } -.editor-controls label { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-size: .72rem; } -.editor-controls select { width: auto; min-height: 30px; padding: 3px 24px 3px 7px; border-radius: 6px; font-size: .74rem; } -.editor-workspace-font-system textarea, .editor-workspace-font-system .preview { font-family: Inter, ui-sans-serif, system-ui, sans-serif; } -.editor-workspace-font-serif textarea, .editor-workspace-font-serif .preview { font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif; } -.editor-workspace-font-arial textarea, .editor-workspace-font-arial .preview { font-family: Arial, Helvetica, sans-serif; } -.editor-workspace-font-georgia textarea, .editor-workspace-font-georgia .preview { font-family: Georgia, "Times New Roman", serif; } -.editor-workspace-font-mono textarea, .editor-workspace-font-mono .preview { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } -.compact-editor .editor-toolbar { min-height: 42px; padding-top: 5px; padding-bottom: 5px; } -.compact-editor .column-label { min-height: 30px; font-size: .68rem; } -.compact-editor textarea, .compact-editor .preview { padding-top: 14px; padding-bottom: 14px; font-size: calc(var(--editor-font-size, 18px) - 2px); line-height: 1.45; } -.workspace textarea, .workspace .preview { font-size: var(--editor-font-size, 18px); } -@media (max-width: 900px) { .editor-controls { order: 3; width: 100%; } .editor-toolbar { flex-wrap: wrap; } } +.editor-shell { + position: relative; +} -.editor-shell textarea { white-space: pre; overflow: auto; overflow-wrap: normal; word-break: normal; } +.owner-labels { + z-index: 4; + top: 0; + bottom: 0; + height: auto; +} + +.owner-line { + position: absolute; + right: 0; + left: 0; + height: var(--editor-line-height, 31px); + border-left: 3px solid var(--owner); + background: transparent; +} + +.owner-label { + z-index: 1; + transform: translateY(2px); +} + +.editor-controls { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.editor-controls label { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--muted); + font-size: .72rem; +} + +.editor-controls select { + width: auto; + min-height: 30px; + padding: 3px 24px 3px 7px; + border-radius: 6px; + font-size: .74rem; +} + +.editor-workspace-font-system textarea, +.editor-workspace-font-system .preview { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + +.editor-workspace-font-serif textarea, +.editor-workspace-font-serif .preview { + font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif; +} + +.editor-workspace-font-arial textarea, +.editor-workspace-font-arial .preview { + font-family: Arial, Helvetica, sans-serif; +} + +.editor-workspace-font-georgia textarea, +.editor-workspace-font-georgia .preview { + font-family: Georgia, "Times New Roman", serif; +} + +.editor-workspace-font-mono textarea, +.editor-workspace-font-mono .preview { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.compact-editor .editor-toolbar { + min-height: 42px; + padding-top: 5px; + padding-bottom: 5px; +} + +.compact-editor .column-label { + min-height: 30px; + font-size: .68rem; +} + +.compact-editor textarea, +.compact-editor .preview { + padding-top: 14px; + padding-bottom: 14px; + font-size: calc(var(--editor-font-size, 18px) - 2px); + line-height: 1.45; +} + +.workspace textarea, +.workspace .preview { + font-size: var(--editor-font-size, 18px); +} + +@media (max-width: 900px) { + .editor-controls { + order: 3; + width: 100%; + } + + .editor-toolbar { + flex-wrap: wrap; + } +} + +.editor-shell textarea { + white-space: pre; + overflow: auto; + overflow-wrap: normal; + word-break: normal; +} /* Keep line numbers exactly aligned with the logical text lines. */ -.line-gutter { box-sizing: border-box; font-size: var(--editor-font-size, 18px); line-height: normal; } -.line-gutter div { box-sizing: border-box; padding-right: 8px; line-height: inherit; } -.compact-editor .line-gutter { font-size: calc(var(--editor-font-size, 18px) - 2px); } +.line-gutter { + box-sizing: border-box; + font-size: var(--editor-font-size, 18px); + line-height: normal; +} + +.line-gutter div { + box-sizing: border-box; + padding-right: 8px; + line-height: inherit; +} + +.compact-editor .line-gutter { + font-size: calc(var(--editor-font-size, 18px) - 2px); +} /* Image crop/resize dialog. */ -.image-editor-dialog { width: min(920px, calc(100% - 28px)); max-width: none; padding: 0; border: 1px solid var(--border); border-radius: 14px; background: #11151b; color: #eef2f7; } -.image-editor-dialog::backdrop { background: rgba(4,6,9,.78); backdrop-filter: blur(4px); } -.image-editor-panel { display: grid; gap: 16px; padding: 18px; } -.image-editor-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } -.image-editor-head h2 { margin: 0; } -.image-editor-head p { margin: 5px 0 0; color: var(--muted); } -.image-crop-stage { display: grid; place-items: center; min-height: 360px; max-height: 58vh; overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: #080b10; } -.image-crop-stage canvas { display: block; max-width: 100%; max-height: 58vh; cursor: grab; touch-action: none; } -.image-crop-stage canvas:active { cursor: grabbing; } -.image-editor-controls { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; } -.image-editor-controls label { display: grid; gap: 6px; color: var(--muted); font-size: .78rem; } -.image-editor-controls select,.image-editor-controls input { width: 100%; } -.image-editor-actions { display: flex; justify-content: flex-end; gap: 10px; } -@media (max-width: 680px) { .image-editor-controls { grid-template-columns: 1fr; } .image-crop-stage { min-height: 260px; } } +.image-editor-dialog { + width: min(920px, calc(100% - 28px)); + max-width: none; + padding: 0; + border: 1px solid var(--border); + border-radius: 14px; + background: #11151b; + color: #eef2f7; +} + +.image-editor-dialog::backdrop { + background: rgba(4, 6, 9, .78); + backdrop-filter: blur(4px); +} + +.image-editor-panel { + display: grid; + gap: 16px; + padding: 18px; +} + +.image-editor-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.image-editor-head h2 { + margin: 0; +} + +.image-editor-head p { + margin: 5px 0 0; + color: var(--muted); +} + +.image-crop-stage { + display: grid; + place-items: center; + min-height: 360px; + max-height: 58vh; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: #080b10; +} + +.image-crop-stage canvas { + display: block; + max-width: 100%; + max-height: 58vh; + cursor: grab; + touch-action: none; +} + +.image-crop-stage canvas:active { + cursor: grabbing; +} + +.image-editor-controls { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.image-editor-controls label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: .78rem; +} + +.image-editor-controls select, +.image-editor-controls input { + width: 100%; +} + +.image-editor-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +@media (max-width: 680px) { + .image-editor-controls { + grid-template-columns: 1fr; + } + + .image-crop-stage { + min-height: 260px; + } +} /* Wider published page workspace. */ -.public-header { padding-inline: max(20px, calc((100vw - 1180px) / 2)); } -.public-document { width: min(1180px, calc(100% - 32px)); } -.public-content { padding: clamp(24px, 4vw, 52px); } +.public-header { + padding-inline: max(20px, calc((100vw - 1180px) / 2)); +} + +.public-document { + width: min(1180px, calc(100% - 32px)); +} + +.public-content { + padding: clamp(24px, 4vw, 52px); +} /* Preview and attachment management */ -.preview { overscroll-behavior: contain; scrollbar-gutter: stable; } -.markdown-body img { display: block; width: auto; max-width: 100%; height: auto; object-fit: contain; border-radius: 8px; } -.workspace.view-preview .markdown-body img { max-height: calc(100vh - 210px); } -.note-card-wrap { position: relative; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); overflow: hidden; } -.note-card-wrap .note-card { border: 0; border-radius: 0; } -.note-card-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.protect-badge, .file-flag { display: inline-flex; padding: 3px 7px; border: 1px solid var(--border-strong); border-radius: 999px; color: var(--muted); font-size: .68rem; white-space: nowrap; } -.file-flag.detached { border-color: rgba(255,123,145,.45); color: var(--danger); } -.note-delete-button { width: 100%; min-height: 36px; border: 0; border-top: 1px solid var(--border); background: transparent; color: var(--danger); } -.danger-button { color: var(--danger); } -.dialog-check { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: .82rem; } -.dialog-check input { width: auto; min-height: auto; } -.footer-link { border: 0; background: transparent; color: var(--muted); padding: 0; font-size: inherit; text-decoration: underline; text-underline-offset: 2px; } -.files-dialog { overflow: hidden; } -.files-panel { grid-template-rows: auto minmax(0, 1fr); min-height: min(590px, calc(100vh - 28px)); max-height: calc(100vh - 28px); } -.files-list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } -.files-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; } -.files-head h2, .files-head p { margin: 0; } -.files-head p { margin-top: 5px; color: var(--muted); font-size: .8rem; } -.files-list { display: grid; gap: 9px; overflow: auto; min-height: 80px; } -.file-row { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 12px; padding: 12px; border: 1px solid var(--border); border-radius: 9px; background: #0e1116; } -.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; } -.file-meta { margin-top: 4px; color: var(--muted-2); font-size: .72rem; } -.file-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; } -.file-actions button { min-height: 30px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); padding: 0 8px; font-size: .72rem; } -@media (max-width: 620px) { .file-row { grid-template-columns: 1fr; } .file-actions { justify-content: flex-start; } } +.preview { + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.markdown-body img { + display: block; + width: auto; + max-width: 100%; + height: auto; + object-fit: contain; + border-radius: 8px; +} + +.workspace.view-preview .markdown-body img { + max-height: calc(100vh - 210px); +} + +.note-card-wrap { + position: relative; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface); + overflow: hidden; +} + +.note-card-wrap .note-card { + border: 0; + border-radius: 0; +} + +.note-card-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.protect-badge, +.file-flag { + display: inline-flex; + padding: 3px 7px; + border: 1px solid var(--border-strong); + border-radius: 999px; + color: var(--muted); + font-size: .68rem; + white-space: nowrap; +} + +.file-flag.detached { + border-color: rgba(255, 123, 145, .45); + color: var(--danger); +} + +.note-delete-button { + width: 100%; + min-height: 36px; + border: 0; + border-top: 1px solid var(--border); + background: transparent; + color: var(--danger); +} + +.danger-button { + color: var(--danger); +} + +.dialog-check { + display: flex; + align-items: center; + gap: 9px; + color: var(--muted); + font-size: .82rem; +} + +.dialog-check input { + width: auto; + min-height: auto; +} + +.footer-link { + border: 0; + background: transparent; + color: var(--muted); + padding: 0; + font-size: inherit; + text-decoration: underline; + text-underline-offset: 2px; +} + +.files-dialog { + overflow: hidden; +} + +.files-panel { + grid-template-rows: auto minmax(0, 1fr); + min-height: min(590px, calc(100vh - 28px)); + max-height: calc(100vh - 28px); +} + +.files-list { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.files-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.files-head h2, +.files-head p { + margin: 0; +} + +.files-head p { + margin-top: 5px; + color: var(--muted); + font-size: .8rem; +} + +.files-list { + display: grid; + gap: 9px; + overflow: auto; + min-height: 80px; +} + +.file-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: #0e1116; +} + +.file-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 650; +} + +.file-meta { + margin-top: 4px; + color: var(--muted-2); + font-size: .72rem; +} + +.file-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.file-actions button { + min-height: 30px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); + padding: 0 8px; + font-size: .72rem; +} + +@media (max-width: 620px) { + .file-row { + grid-template-columns: 1fr; + } + + .file-actions { + justify-content: flex-start; + } +} + +.files-panel { + min-height: 0; + height: min(590px, calc(100vh - 28px)); +} + +.files-list { + align-content: start; +} + +.file-row { + align-items: start; +} + +.file-row-main { + min-width: 0; +} + +.file-actions { + align-items: center; + align-self: start; + flex-wrap: nowrap; +} + +.file-actions button { + min-height: 34px; + height: 34px; + white-space: nowrap; +} + +.file-code { + grid-column: 1 / -1; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: stretch; +} + +.file-code[hidden] { + display: none; +} + +.file-code textarea { + width: 100%; + min-height: 76px; + max-height: 150px; + resize: vertical; + box-sizing: border-box; + font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.file-code button { + align-self: stretch; + min-width: 74px; +} + +.file-delete { + color: var(--danger) !important; + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)) !important; +} -.files-panel { min-height: 0; height: min(590px, calc(100vh - 28px)); } -.files-list { align-content: start; } -.file-row { align-items: start; } -.file-row-main { min-width: 0; } -.file-actions { align-items: center; align-self: start; flex-wrap: nowrap; } -.file-actions button { min-height: 34px; height: 34px; white-space: nowrap; } -.file-code { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: stretch; } -.file-code[hidden] { display: none; } -.file-code textarea { width: 100%; min-height: 76px; max-height: 150px; resize: vertical; box-sizing: border-box; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -.file-code button { align-self: stretch; min-width: 74px; } -.file-delete { color: var(--danger) !important; border-color: color-mix(in srgb, var(--danger) 45%, var(--border)) !important; } @media (max-width: 760px) { - .file-actions { flex-wrap: wrap; justify-content: flex-start; } - .file-code { grid-template-columns: 1fr; } + .file-actions { + flex-wrap: wrap; + justify-content: flex-start; + } + + .file-code { + grid-template-columns: 1fr; + } +} + +.notes-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-top: 18px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border); +} + +.notes-toolbar__label { + color: var(--muted); + font-size: .82rem; + font-weight: 600; +} + +.workspace-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.notes-view-switch { + display: inline-flex; + padding: 3px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); +} + +.notes-view-switch button { + min-height: 32px; + padding: 0 11px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); +} + +.notes-view-switch button.active { + background: var(--surface-3); + color: var(--text); +} + +.note-card { + display: block; + min-width: 0; +} + +.note-card-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.note-card-title h3 { + min-width: 0; + overflow-wrap: anywhere; + word-break: break-word; + line-height: 1.4; +} + +.note-card p { + overflow-wrap: anywhere; +} + +.notes-table { + display: block; + padding-top: 20px; +} + +.notes-table-scroll { + width: 100%; + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); +} + +.notes-table table { + width: 100%; + min-width: 680px; + border-collapse: collapse; +} + +.notes-table th, +.notes-table td { + padding: 13px 15px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: middle; +} + +.notes-table th { + color: var(--muted); + font-size: .72rem; + text-transform: uppercase; + letter-spacing: .05em; +} + +.notes-table tbody tr:last-child td { + border-bottom: 0; +} + +.notes-table tbody tr:hover { + background: var(--surface-2); +} + +.note-table-link { + display: block; + max-width: 52ch; + color: var(--text); + font-weight: 650; + text-decoration: none; + overflow-wrap: anywhere; +} + +.note-table-link:hover { + text-decoration: underline; +} + +.note-status { + color: var(--muted); + font-size: .75rem; +} + +.notes-table-actions { + width: 1%; + white-space: nowrap; + text-align: right !important; +} + +.note-delete-button--inline { + width: auto; + min-height: 32px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 6px; } -.notes-toolbar { display: flex; align-items: center; gap: 10px; margin-top: 18px; padding-bottom: 14px; border-bottom: 1px solid var(--border); } -.notes-toolbar__label { color: var(--muted); font-size: .82rem; font-weight: 600; } -.workspace-actions { display: flex; align-items: center; gap: 10px; } -.notes-view-switch { display: inline-flex; padding: 3px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); } -.notes-view-switch button { min-height: 32px; padding: 0 11px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); } -.notes-view-switch button.active { background: var(--surface-3); color: var(--text); } -.note-card { display: block; min-width: 0; } -.note-card-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; min-width: 0; } -.note-card-title h3 { min-width: 0; overflow-wrap: anywhere; word-break: break-word; line-height: 1.4; } -.note-card p { overflow-wrap: anywhere; } -.notes-table { display: block; padding-top: 20px; } -.notes-table-scroll { width: 100%; overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); } -.notes-table table { width: 100%; min-width: 680px; border-collapse: collapse; } -.notes-table th, .notes-table td { padding: 13px 15px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; } -.notes-table th { color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; } -.notes-table tbody tr:last-child td { border-bottom: 0; } -.notes-table tbody tr:hover { background: var(--surface-2); } -.note-table-link { display: block; max-width: 52ch; color: var(--text); font-weight: 650; text-decoration: none; overflow-wrap: anywhere; } -.note-table-link:hover { text-decoration: underline; } -.note-status { color: var(--muted); font-size: .75rem; } -.notes-table-actions { width: 1%; white-space: nowrap; text-align: right !important; } -.note-delete-button--inline { width: auto; min-height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; } @media (max-width: 700px) { - .workspace-top { align-items: flex-start; } - .workspace-actions { align-items: stretch; flex-direction: column-reverse; } - .notes-view-switch button { flex: 1; } + .workspace-top { + align-items: flex-start; + } + + .workspace-actions { + align-items: stretch; + flex-direction: column-reverse; + } + + .notes-view-switch button { + flex: 1; + } } -.note-card-meta { display: grid; gap: 5px; margin-top: 28px; color: var(--muted); font-size: .75rem; } -.note-card p { margin-top: 0; } -.note-author { color: var(--muted); overflow-wrap: anywhere; } -.note-delete-button:disabled { cursor: not-allowed; color: var(--muted-2); opacity: .55; } -.notes-view-switch button { cursor: pointer; } -.notes-view-switch button.active { cursor: default; } +.note-card-meta { + display: grid; + gap: 5px; + margin-top: 28px; + color: var(--muted); + font-size: .75rem; +} + +.note-card p { + margin-top: 0; +} + +.note-author { + color: var(--muted); + overflow-wrap: anywhere; +} + +.note-delete-button:disabled { + cursor: not-allowed; + color: var(--muted-2); + opacity: .55; +} + +.notes-view-switch button { + cursor: pointer; +} + +.notes-view-switch button.active { + cursor: default; +} /* Preview editing and source line numbers. */ -.preview { padding: 12px 24px 12px 62px; line-height: 1.32; } -.preview-source-line { position: relative; min-height: 1.32em; } -.preview-source-line::before { content: attr(data-source-line); position: absolute; right: calc(100% + 18px); width: 32px; color: #596270; text-align: right; font: 400 .72rem/1.32 ui-monospace, SFMono-Regular, Consolas, monospace; user-select: none; } -.preview-editable { border-radius: 4px; outline: none; cursor: text; } -.preview-editable:hover { background: rgba(255,255,255,.025); } -.preview-editable:focus { background: rgba(124,104,238,.08); box-shadow: 0 0 0 1px rgba(124,104,238,.25); } -.markdown-body p { margin: .18em 0; } -.markdown-body h1, .markdown-body h2, .markdown-body h3 { margin: .48em 0 .18em; } -.markdown-body blockquote { margin: .3em 0; } -.markdown-body ul, .markdown-body ol { margin: .22em 0; } -.compact-editor .preview { line-height: 1.24; } -.hide-line-numbers .preview { padding-left: 24px; } -.hide-line-numbers .preview-source-line::before { display: none; } - - - -.markdown-body .table-wrap { overflow-x: auto; margin: .7em 0; } -.markdown-body table { width: 100%; border-collapse: collapse; } -.markdown-body th, .markdown-body td { padding: .55em .7em; border: 1px solid var(--border); vertical-align: top; } -.markdown-body th { background: var(--surface-2); color: var(--text); } -.markdown-body mark { padding: .05em .18em; border-radius: 3px; background: #6d5b16; color: #fff2a8; } -.markdown-body sub, .markdown-body sup { line-height: 0; } -.markdown-body dl { margin: .7em 0; } -.markdown-body dt { font-weight: 750; } -.markdown-body dd { margin: .25em 0 .65em 1.5em; color: #b9c2cf; } -.markdown-body .task-list{margin:0;padding:0;list-style:none} -.markdown-body .task-list-item{position:relative;display:grid;grid-template-columns:1em minmax(0,1fr);grid-template-rows:1.32em;column-gap:.45em;align-items:center;min-height:1.32em;margin:0;padding:0;line-height:1.32} -.markdown-body .task-list-item::before{position:absolute;top:0;right:calc(100% + 18px);width:32px;height:1.32em;line-height:1.32em;transform:none} -.markdown-body .task-checkbox{grid-column:1;grid-row:1;width:1em;height:1em;margin:0;align-self:center;accent-color:var(--accent);cursor:pointer} -.markdown-body .task-list-item>span{grid-column:2;grid-row:1;display:block;min-width:0;margin:0;padding:0;line-height:1.32} -.hide-line-numbers .markdown-body .task-list-item::before{display:none} -.compact-editor .markdown-body .task-list-item{grid-template-rows:1.24em;min-height:1.24em;line-height:1.24} -.compact-editor .markdown-body .task-list-item::before,.compact-editor .markdown-body .task-list-item>span{height:1.24em;line-height:1.24} -.markdown-body .footnotes { margin-top: 1.5em; color: var(--muted); font-size: .88em; } -.markdown-body .footnotes ol { padding-left: 1.5em; } -.markdown-body .footnote-backref { text-decoration: none; } -.shortcuts-panel { width: 100%; } -.shortcut-grid { display: grid; grid-template-columns: max-content 1fr; gap: 10px 18px; align-items: center; } -.shortcut-grid kbd { padding: 5px 8px; border: 1px solid var(--border-strong); border-bottom-width: 2px; border-radius: 6px; background: #0d1015; color: #e6eaf0; font: 600 .76rem/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; } -.shortcut-grid span { color: #c3cad4; font-size: .82rem; } -@media (max-width: 520px) { - .shortcut-grid { grid-template-columns: 1fr; gap: 5px; } - .shortcut-grid span { margin-bottom: 7px; } +.preview { + padding: 12px 24px 12px 62px; + line-height: 1.32; } -.footer-left, .footer-right { display:flex; align-items:center; gap:6px; min-width:0; } -.footer-status.status { min-height:0; padding:0; font-size:inherit; } -.markdown-more { position:relative; } -.markdown-more > summary { display:flex; align-items:center; min-height:34px; padding:0 9px; border:1px solid transparent; border-radius:7px; color:#b8c0cc; font-size:.78rem; list-style:none; cursor:pointer; } -.markdown-more > summary::-webkit-details-marker { display:none; } -.markdown-more > summary:hover, .markdown-more[open] > summary { border-color:var(--border); background:var(--surface-2); color:white; } -.markdown-more-menu { position:absolute; top:calc(100% + 8px); left:0; z-index:30; display:grid; grid-template-columns:repeat(2,minmax(120px,1fr)); gap:4px; width:290px; padding:8px; border:1px solid var(--border-strong); border-radius:9px; background:#11151c; box-shadow:0 14px 40px rgba(0,0,0,.4); } -.editor-toolbar .markdown-more-menu button { justify-content:flex-start; text-align:left; } -.hljs { color:#d7dae0; } -.hljs-keyword,.hljs-selector-tag,.hljs-literal { color:#c792ea; } -.hljs-string,.hljs-attr { color:#c3e88d; } -.hljs-number,.hljs-symbol { color:#f78c6c; } -.hljs-comment { color:#697383; font-style:italic; } -.public-content .task-checkbox { pointer-events:auto; } -@media (max-width:720px){.footer-left,.footer-right{flex-wrap:wrap}.markdown-more-menu{position:fixed;left:12px;right:12px;top:auto;bottom:58px;width:auto;}} -.public-task-toggle { display:inline-flex; align-items:center; gap:7px; color:var(--muted); font-size:.76rem; white-space:nowrap; } -.public-task-toggle input { width:15px; min-height:15px; height:15px; margin:0; accent-color:var(--accent); } -.public-page .preview-source-line { cursor:default; } -.public-page .task-checkbox:not(:disabled) { cursor:pointer; } -.public-page .task-checkbox:disabled { cursor:not-allowed; opacity:.55; } +.preview-source-line { + position: relative; + min-height: 1.32em; +} -.identity-actions, .identity-links { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } -.identity-actions .primary-button { flex: 1 0 100%; } -.identity-panel { position: relative; } -.identity-panel__close { position: absolute; top: 14px; right: 14px; } -.identity-links { justify-content: space-between; } -.identity-links .text-button { padding: 3px 0; } -.auth-panel { display: grid; gap: 12px; padding-top: 14px; border-top: 1px solid var(--border); } -.auth-panel h3 { margin: 0; } -.auth-panel label { display: grid; gap: 6px; } +.preview-source-line::before { + content: attr(data-source-line); + position: absolute; + right: calc(100% + 18px); + width: 32px; + color: #596270; + text-align: right; + font: 400 .72rem/1.32 ui-monospace, SFMono-Regular, Consolas, monospace; + user-select: none; +} + +.preview-editable { + border-radius: 4px; + outline: none; + cursor: text; +} + +.preview-editable:hover { + background: rgba(255, 255, 255, .025); +} + +.preview-editable:focus { + background: rgba(124, 104, 238, .08); + box-shadow: 0 0 0 1px rgba(124, 104, 238, .25); +} + +.markdown-body p { + margin: .18em 0; +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3 { + margin: .48em 0 .18em; +} + +.markdown-body blockquote { + margin: .3em 0; +} + +.markdown-body ul, +.markdown-body ol { + margin: .22em 0; +} + +.compact-editor .preview { + line-height: 1.24; +} + +.hide-line-numbers .preview { + padding-left: 24px; +} + +.hide-line-numbers .preview-source-line::before { + display: none; +} + + + +.markdown-body .table-wrap { + overflow-x: auto; + margin: .7em 0; +} + +.markdown-body table { + width: 100%; + border-collapse: collapse; +} + +.markdown-body th, +.markdown-body td { + padding: .55em .7em; + border: 1px solid var(--border); + vertical-align: top; +} + +.markdown-body th { + background: var(--surface-2); + color: var(--text); +} + +.markdown-body mark { + padding: .05em .18em; + border-radius: 3px; + background: #6d5b16; + color: #fff2a8; +} + +.markdown-body sub, +.markdown-body sup { + line-height: 0; +} + +.markdown-body dl { + margin: .7em 0; +} + +.markdown-body dt { + font-weight: 750; +} + +.markdown-body dd { + margin: .25em 0 .65em 1.5em; + color: #b9c2cf; +} + +.markdown-body .task-list { + margin: 0; + padding: 0; + list-style: none +} + +.markdown-body .task-list-item { + position: relative; + display: grid; + grid-template-columns: 1em minmax(0, 1fr); + grid-template-rows: 1.32em; + column-gap: .45em; + align-items: center; + min-height: 1.32em; + margin: 0; + padding: 0; + line-height: 1.32 +} + +.markdown-body .task-list-item::before { + position: absolute; + top: 0; + right: calc(100% + 18px); + width: 32px; + height: 1.32em; + line-height: 1.32em; + transform: none +} + +.markdown-body .task-checkbox { + grid-column: 1; + grid-row: 1; + width: 1em; + height: 1em; + margin: 0; + align-self: center; + accent-color: var(--accent); + cursor: pointer +} + +.markdown-body .task-list-item>span { + grid-column: 2; + grid-row: 1; + display: block; + min-width: 0; + margin: 0; + padding: 0; + line-height: 1.32 +} + +.hide-line-numbers .markdown-body .task-list-item::before { + display: none +} + +.compact-editor .markdown-body .task-list-item { + grid-template-rows: 1.24em; + min-height: 1.24em; + line-height: 1.24 +} + +.compact-editor .markdown-body .task-list-item::before, +.compact-editor .markdown-body .task-list-item>span { + height: 1.24em; + line-height: 1.24 +} + +.markdown-body .footnotes { + margin-top: 1.5em; + color: var(--muted); + font-size: .88em; +} + +.markdown-body .footnotes ol { + padding-left: 1.5em; +} + +.markdown-body .footnote-backref { + text-decoration: none; +} + +.shortcuts-panel { + width: 100%; +} + +.shortcut-grid { + display: grid; + grid-template-columns: max-content 1fr; + gap: 10px 18px; + align-items: center; +} + +.shortcut-grid kbd { + padding: 5px 8px; + border: 1px solid var(--border-strong); + border-bottom-width: 2px; + border-radius: 6px; + background: #0d1015; + color: #e6eaf0; + font: 600 .76rem/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.shortcut-grid span { + color: #c3cad4; + font-size: .82rem; +} + +@media (max-width: 520px) { + .shortcut-grid { + grid-template-columns: 1fr; + gap: 5px; + } + + .shortcut-grid span { + margin-bottom: 7px; + } +} + +.footer-left, +.footer-right { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.footer-status.status { + min-height: 0; + padding: 0; + font-size: inherit; +} + +.markdown-more { + position: relative; +} + +.markdown-more>summary { + display: flex; + align-items: center; + min-height: 34px; + padding: 0 9px; + border: 1px solid transparent; + border-radius: 7px; + color: #b8c0cc; + font-size: .78rem; + list-style: none; + cursor: pointer; +} + +.markdown-more>summary::-webkit-details-marker { + display: none; +} + +.markdown-more>summary:hover, +.markdown-more[open]>summary { + border-color: var(--border); + background: var(--surface-2); + color: white; +} + +.markdown-more-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + z-index: 30; + display: grid; + grid-template-columns: repeat(2, minmax(120px, 1fr)); + gap: 4px; + width: 290px; + padding: 8px; + border: 1px solid var(--border-strong); + border-radius: 9px; + background: #11151c; + box-shadow: 0 14px 40px rgba(0, 0, 0, .4); +} + +.editor-toolbar .markdown-more-menu button { + justify-content: flex-start; + text-align: left; +} + +.hljs { + color: #d7dae0; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-literal { + color: #c792ea; +} + +.hljs-string, +.hljs-attr { + color: #c3e88d; +} + +.hljs-number, +.hljs-symbol { + color: #f78c6c; +} + +.hljs-comment { + color: #697383; + font-style: italic; +} + +.public-content .task-checkbox { + pointer-events: auto; +} + +@media (max-width:720px) { + + .footer-left, + .footer-right { + flex-wrap: wrap + } + + .markdown-more-menu { + position: fixed; + left: 12px; + right: 12px; + top: auto; + bottom: 58px; + width: auto; + } +} + +.public-task-toggle { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: .76rem; + white-space: nowrap; +} + +.public-task-toggle input { + width: 15px; + min-height: 15px; + height: 15px; + margin: 0; + accent-color: var(--accent); +} + +.public-page .preview-source-line { + cursor: default; +} + +.public-page .task-checkbox:not(:disabled) { + cursor: pointer; +} + +.public-page .task-checkbox:disabled { + cursor: not-allowed; + opacity: .55; +} + +.identity-actions, +.identity-links { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.identity-actions .primary-button { + flex: 1 0 100%; +} + +.identity-panel { + position: relative; +} + +.identity-panel__close { + position: absolute; + top: 14px; + right: 14px; +} + +.identity-links { + justify-content: space-between; +} + +.identity-links .text-button { + padding: 3px 0; +} + +.auth-panel { + display: grid; + gap: 12px; + padding-top: 14px; + border-top: 1px solid var(--border); +} + +.auth-panel h3 { + margin: 0; +} + +.auth-panel label { + display: grid; + gap: 6px; +} /* Account footer and authentication dialog. */ -.home-footer { width: min(1040px, calc(100% - 32px)); margin: auto auto 24px; padding-top: 24px; color: var(--muted); font-size: .78rem; } -.home-footer__inner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding-top: 18px; border-top: 1px solid var(--border); } -.home-footer__account { display: inline-flex; align-items: center; gap: 8px; } -.home-footer__user { color: var(--text); font-weight: 600; } -.home-footer__author { margin-left: auto; } -.footer-action { min-height: 34px; padding: 0 12px; border: 1px solid var(--border); border-radius: 8px; background: transparent; color: var(--muted); font: inherit; cursor: pointer; } -.footer-action:hover { border-color: var(--border-strong); background: var(--surface-2); color: var(--text); } -.footer-action--primary { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); color: #d9d2ff; } -.app-dialog { width: min(440px, calc(100% - 28px)); padding: 0; border: 0; background: transparent; } -.app-dialog::backdrop { background: rgba(4, 6, 10, .72); backdrop-filter: blur(3px); } -.identity-panel { position: relative; display: grid; gap: 18px; width: 100%; padding: 28px; } -.identity-panel__header { padding-right: 38px; } -.identity-panel__header h2 { margin: 0 0 7px; } -.identity-panel__header p { margin: 0; } -.identity-fields { display: grid; gap: 14px; } -.identity-fields label { display: grid; gap: 7px; color: var(--muted); font-size: .78rem; } -.modal-close { position: absolute; top: 12px; right: 12px; display: grid; width: 34px; height: 34px; padding: 0; place-items: center; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font: 400 1.45rem/1 system-ui; cursor: pointer; } -.modal-close:hover { border-color: var(--border); background: var(--surface-2); color: white; } -.identity-links { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.identity-links .text-button { padding: 2px 0; } -.form-message.success { color: #8ed9a4; } -@media (max-width: 600px) { - .home-footer__inner { align-items: stretch; flex-direction: column; text-align: center; } - .home-footer__account { justify-content: center; } - .home-footer__author { margin-left: 0; } - .identity-panel { padding: 24px 20px 20px; } - .identity-links { align-items: flex-start; flex-direction: column; } +.home-footer { + width: min(1040px, calc(100% - 32px)); + margin: auto auto 24px; + padding-top: 24px; + color: var(--muted); + font-size: .78rem; } -.home-footer__account[hidden], .footer-action[hidden], .identity-links [hidden], .identity-fields [hidden], .auth-panel[hidden] { display: none !important; } -.system-dialog-panel { position: relative; } -.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; } -.secondary-button, .danger-button { min-height: 42px; padding: 0 16px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface-2); color: var(--text); font: inherit; cursor: pointer; } -.secondary-button:hover { border-color: var(--muted); } -.danger-button { border-color: #7f3340; background: #6e2935; color: white; } -.danger-button:hover { background: #7c3040; } +.home-footer__inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-top: 18px; + border-top: 1px solid var(--border); +} + +.home-footer__account { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.home-footer__user { + color: var(--text); + font-weight: 600; +} + +.home-footer__author { + margin-left: auto; +} + +.footer-action { + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--muted); + font: inherit; + cursor: pointer; +} + +.footer-action:hover { + border-color: var(--border-strong); + background: var(--surface-2); + color: var(--text); +} + +.footer-action--primary { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + color: #d9d2ff; +} + +.app-dialog { + width: min(440px, calc(100% - 28px)); + padding: 0; + border: 0; + background: transparent; +} + +.app-dialog::backdrop { + background: rgba(4, 6, 10, .72); + backdrop-filter: blur(3px); +} + +.identity-panel { + position: relative; + display: grid; + gap: 18px; + width: 100%; + padding: 28px; +} + +.identity-panel__header { + padding-right: 38px; +} + +.identity-panel__header h2 { + margin: 0 0 7px; +} + +.identity-panel__header p { + margin: 0; +} + +.identity-fields { + display: grid; + gap: 14px; +} + +.identity-fields label { + display: grid; + gap: 7px; + color: var(--muted); + font-size: .78rem; +} + +.modal-close { + position: absolute; + top: 12px; + right: 12px; + display: grid; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--muted); + font: 400 1.45rem/1 system-ui; + cursor: pointer; +} + +.modal-close:hover { + border-color: var(--border); + background: var(--surface-2); + color: white; +} + +.identity-links { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.identity-links .text-button { + padding: 2px 0; +} + +.form-message.success { + color: #8ed9a4; +} + +@media (max-width: 600px) { + .home-footer__inner { + align-items: stretch; + flex-direction: column; + text-align: center; + } + + .home-footer__account { + justify-content: center; + } + + .home-footer__author { + margin-left: 0; + } + + .identity-panel { + padding: 24px 20px 20px; + } + + .identity-links { + align-items: flex-start; + flex-direction: column; + } +} + +.home-footer__account[hidden], +.footer-action[hidden], +.identity-links [hidden], +.identity-fields [hidden], +.auth-panel[hidden] { + display: none !important; +} + +.system-dialog-panel { + position: relative; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.secondary-button, +.danger-button { + min-height: 42px; + padding: 0 16px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface-2); + color: var(--text); + font: inherit; + cursor: pointer; +} + +.secondary-button:hover { + border-color: var(--muted); +} + +.danger-button { + border-color: #7f3340; + background: #6e2935; + color: white; +} + +.danger-button:hover { + background: #7c3040; +} /* Native hidden must win over component display declarations. */ -[hidden] { display: none !important; } -.identity-panel #auth-panel:not([hidden]) { margin-top: 4px; } -.identity-panel #nickname:disabled { opacity: .55; cursor: not-allowed; } +[hidden] { + display: none !important; +} -#resources-dialog { width: min(760px, calc(100% - 28px)); max-width: 760px; } -.resources-panel { position: relative; box-sizing: border-box; width: 100%; min-width: 0; max-height: 80vh; overflow-x: hidden; overflow-y: auto; } -.resources-panel__header { min-width: 0; padding-right: 38px; } -.resources-panel__header h2 { margin: 0 0 7px; overflow-wrap: anywhere; } -.resources-panel__header p { margin: 0; overflow-wrap: anywhere; } -.resources-list { display: grid; min-width: 0; gap: 10px; margin-top: 18px; } -.resource-row { display:flex; min-width:0; justify-content:space-between; gap:16px; align-items:center; padding:12px; border:1px solid var(--border); border-radius:10px; } -.resource-row > :first-child { min-width: 0; } -.resource-row a { display: block; max-width: 100%; font-weight:700; overflow-wrap:anywhere; word-break:break-word; } -.resource-row small { display:block; max-width:100%; margin-top:4px; color:var(--muted-2); overflow-wrap:anywhere; } -.resource-actions { display:flex; flex:0 0 auto; gap:8px; flex-wrap:wrap; } -.resource-actions button { padding:7px 10px; } -@media (max-width:640px){.resource-row{align-items:flex-start;flex-direction:column}.resource-actions{width:100%}} +.identity-panel #auth-panel:not([hidden]) { + margin-top: 4px; +} -.resource-main { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; } -.resource-inline { width: 100%; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); } -.resource-password-form label { display: grid; gap: 6px; font-size: .8rem; } -.resource-password-form input { width: 100%; } -.resource-inline-help { margin: 6px 0 0; color: var(--muted); font-size: .75rem; } -.resource-inline-message { margin: 8px 0 0; } -.resource-inline-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; } -.resource-delete-confirm > p:first-child { margin: 0; } -@media (max-width: 640px) { .resource-main { align-items: flex-start; flex-direction: column; } .resource-actions { width: 100%; } } -.resource-row { align-items: stretch; flex-direction: column; } +.identity-panel #nickname:disabled { + opacity: .55; + cursor: not-allowed; +} + +#resources-dialog { + width: min(760px, calc(100% - 28px)); + max-width: 760px; +} + +.resources-panel { + position: relative; + box-sizing: border-box; + width: 100%; + min-width: 0; + max-height: 80vh; + overflow-x: hidden; + overflow-y: auto; +} + +.resources-panel__header { + min-width: 0; + padding-right: 38px; +} + +.resources-panel__header h2 { + margin: 0 0 7px; + overflow-wrap: anywhere; +} + +.resources-panel__header p { + margin: 0; + overflow-wrap: anywhere; +} + +.resources-list { + display: grid; + min-width: 0; + gap: 10px; + margin-top: 18px; +} + +.resource-row { + display: flex; + min-width: 0; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; +} + +.resource-row> :first-child { + min-width: 0; +} + +.resource-row a { + display: block; + max-width: 100%; + font-weight: 700; + overflow-wrap: anywhere; + word-break: break-word; +} + +.resource-row small { + display: block; + max-width: 100%; + margin-top: 4px; + color: var(--muted-2); + overflow-wrap: anywhere; +} + +.resource-actions { + display: flex; + flex: 0 0 auto; + gap: 8px; + flex-wrap: wrap; +} + +.resource-actions button { + padding: 7px 10px; +} + +@media (max-width:640px) { + .resource-row { + align-items: flex-start; + flex-direction: column + } + + .resource-actions { + width: 100% + } +} + +.resource-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; +} + +.resource-inline { + width: 100%; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.resource-password-form label { + display: grid; + gap: 6px; + font-size: .8rem; +} + +.resource-password-form input { + width: 100%; +} + +.resource-inline-help { + margin: 6px 0 0; + color: var(--muted); + font-size: .75rem; +} + +.resource-inline-message { + margin: 8px 0 0; +} + +.resource-inline-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 10px; +} + +.resource-delete-confirm>p:first-child { + margin: 0; +} + +@media (max-width: 640px) { + .resource-main { + align-items: flex-start; + flex-direction: column; + } + + .resource-actions { + width: 100%; + } +} + +.resource-row { + align-items: stretch; + flex-direction: column; +} /* Stable scrolling and collapsible Markdown sections. */ -.editor-shell { width: 100%; max-width: 100%; } -.editor-shell textarea { min-width: 0; max-width: 100%; overscroll-behavior: contain; overflow-anchor: none; } -.line-gutter { position: relative; z-index: 5; flex: none; } -.preview, .editor-shell textarea { scrollbar-gutter: stable; } -.markdown-details { margin: .65em 0; border: 1px solid var(--border); border-radius: 8px; background: rgba(255,255,255,.015); } -.markdown-details > summary { padding: .55em .8em; color: var(--text); font-weight: 650; cursor: pointer; user-select: none; } -.markdown-details[open] > summary { border-bottom: 1px solid var(--border); } -.markdown-details__content { padding: .35em .8em .7em; } -.markdown-details__content > :first-child { margin-top: 0; } -.markdown-details__content > :last-child { margin-bottom: 0; } +.editor-shell { + width: 100%; + max-width: 100%; +} + +.editor-shell textarea { + min-width: 0; + max-width: 100%; + overscroll-behavior: contain; + overflow-anchor: none; +} + +.line-gutter { + position: relative; + z-index: 5; + flex: none; +} + +.preview, +.editor-shell textarea { + scrollbar-gutter: stable; +} + +.markdown-details { + margin: .65em 0; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, .015); +} + +.markdown-details>summary { + padding: .55em .8em; + color: var(--text); + font-weight: 650; + cursor: pointer; + user-select: none; +} + +.markdown-details[open]>summary { + border-bottom: 1px solid var(--border); +} + +.markdown-details__content { + padding: .35em .8em .7em; +} + +.markdown-details__content> :first-child { + margin-top: 0; +} + +.markdown-details__content> :last-child { + margin-bottom: 0; +} /* Full-height editor: keep header/footer visible and scroll only the work area. */ -.pad-page { height: 100dvh; min-height: 0; overflow: hidden; display: grid; grid-template-rows: auto minmax(0, 1fr); } -.pad-page .app-header { min-width: 0; } -.pad-page .editor-layout { height: auto; min-height: 0; overflow: hidden; } -.pad-page .editor-panel { min-height: 0; overflow: hidden; } -.pad-page .workspace { min-height: 0; overflow: hidden; } +.pad-page { + height: 100dvh; + min-height: 0; + overflow: hidden; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.pad-page .app-header { + min-width: 0; +} + +.pad-page .editor-layout { + height: auto; + min-height: 0; + overflow: hidden; +} + +.pad-page .editor-panel { + min-height: 0; + overflow: hidden; +} + +.pad-page .workspace { + min-height: 0; + overflow: hidden; +} + .pad-page .editor-column, .pad-page .preview-column, -.pad-page .editor-shell { min-height: 0; overflow: hidden; } +.pad-page .editor-shell { + min-height: 0; + overflow: hidden; +} + .pad-page .editor-shell textarea, -.pad-page .preview { height: 100%; min-height: 0; overflow: auto; } -.pad-page .editor-footer { position: relative; z-index: 8; flex: none; background: var(--surface); } +.pad-page .preview { + height: 100%; + min-height: 0; + overflow: auto; +} + +.pad-page .editor-footer { + position: relative; + z-index: 8; + flex: none; + background: var(--surface); +} + @media (max-width: 980px) { - .pad-page .editor-layout { height: auto; } + .pad-page .editor-layout { + height: auto; + } } /* A collapsible section has one source-line marker; its rendered Markdown does not repeat line numbers inside. */ -.markdown-details__content .preview-source-line::before { display: none; } -.markdown-details__content { overflow: visible; } -.markdown-details__content pre { overflow: auto; } +.markdown-details__content .preview-source-line::before { + display: none; +} + +.markdown-details__content { + overflow: visible; +} + +.markdown-details__content pre { + overflow: auto; +} /* Ephemeral room presence and chat */ -.room-details { position: relative; display: inline-block; } -.room-details > summary { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--text); list-style: none; } -.room-details > summary::-webkit-details-marker { display: none; } -.chat-unread { min-width: 17px; height: 17px; padding: 0 5px; border-radius: 999px; background: var(--accent); color: #fff; font-size: 10px; line-height: 17px; text-align: center; } -.room-popover { position: absolute; bottom: calc(100% + 10px); left: 0; z-index: 40; display: grid; grid-template-columns: 150px minmax(280px, 380px); width: min(560px, calc(100vw - 24px)); max-height: min(430px, 70vh); overflow: hidden; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: 0 18px 50px rgb(0 0 0 / .38); color: var(--text); } -.room-users { padding: 14px; overflow: auto; border-right: 1px solid var(--border); } -.room-users strong, .room-chat__head strong { display: block; margin-bottom: 8px; font-size: .75rem; } -.room-users ul { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; } -.room-users li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } -.room-chat { display: grid; grid-template-rows: auto minmax(120px, 1fr) auto; min-height: 280px; } -.room-chat__head { padding: 12px 14px 8px; border-bottom: 1px solid var(--border); } -.room-chat__head strong { margin: 0; } -.room-chat__head span { color: var(--muted-2); font-size: .66rem; } -.chat-messages { display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding: 12px 14px; } -.chat-message { margin: 0; overflow-wrap: anywhere; line-height: 1.35; } -.chat-message strong { margin-right: 6px; color: var(--text); } -.chat-message span { color: var(--muted); } -.chat-empty { margin: auto; color: var(--muted-2); } -.chat-form { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px; border-top: 1px solid var(--border); } -.chat-form input { min-width: 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text); } -.chat-form button { padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--accent); color: #fff; cursor: pointer; } -@media (max-width: 700px) { .room-popover { grid-template-columns: 1fr; left: auto; right: -120px; } .room-users { max-height: 110px; border-right: 0; border-bottom: 1px solid var(--border); } } -.room-user { display: flex; align-items: center; gap: 8px; min-width: 0; } -.room-user__dot { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%; background: var(--owner, var(--accent)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); } -.room-user > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.chat-message a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; overflow-wrap: anywhere; } -.chat-message a:hover { filter: brightness(1.15); } +.room-details { + position: relative; + display: inline-block; +} + +.room-details>summary { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + color: var(--text); + list-style: none; +} + +.room-details>summary::-webkit-details-marker { + display: none; +} + +.chat-unread { + min-width: 17px; + height: 17px; + padding: 0 5px; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-size: 10px; + line-height: 17px; + text-align: center; +} + +.room-popover { + position: absolute; + bottom: calc(100% + 10px); + left: 0; + z-index: 40; + display: grid; + grid-template-columns: 150px minmax(280px, 380px); + width: min(560px, calc(100vw - 24px)); + max-height: min(430px, 70vh); + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); + box-shadow: 0 18px 50px rgb(0 0 0 / .38); + color: var(--text); +} + +.room-users { + padding: 14px; + overflow: auto; + border-right: 1px solid var(--border); +} + +.room-users strong, +.room-chat__head strong { + display: block; + margin-bottom: 8px; + font-size: .75rem; +} + +.room-users ul { + display: grid; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} + +.room-users li { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); +} + +.room-chat { + display: grid; + grid-template-rows: auto minmax(120px, 1fr) auto; + min-height: 280px; +} + +.room-chat__head { + padding: 12px 14px 8px; + border-bottom: 1px solid var(--border); +} + +.room-chat__head strong { + margin: 0; +} + +.room-chat__head span { + color: var(--muted-2); + font-size: .66rem; +} + +.chat-messages { + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + padding: 12px 14px; +} + +.chat-message { + margin: 0; + overflow-wrap: anywhere; + line-height: 1.35; +} + +.chat-message strong { + margin-right: 6px; + color: var(--text); +} + +.chat-message span { + color: var(--muted); +} + +.chat-empty { + margin: auto; + color: var(--muted-2); +} + +.chat-form { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + padding: 10px; + border-top: 1px solid var(--border); +} + +.chat-form input { + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-2); + color: var(--text); +} + +.chat-form button { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--accent); + color: #fff; + cursor: pointer; +} + +@media (max-width: 700px) { + .room-popover { + grid-template-columns: 1fr; + left: auto; + right: -120px; + } + + .room-users { + max-height: 110px; + border-right: 0; + border-bottom: 1px solid var(--border); + } +} + +.room-user { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.room-user__dot { + width: 9px; + height: 9px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--owner, var(--accent)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--owner, var(--accent)) 18%, transparent); +} + +.room-user>span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-message a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 2px; + overflow-wrap: anywhere; +} + +.chat-message a:hover { + filter: brightness(1.15); +} /* Sharing panel */ -.share-panel { display: grid; gap: 18px; } -.share-panel-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } -.share-panel-head h3, .share-section h4 { margin:0; } -.share-panel-head p { margin:4px 0 0; color:var(--muted); } -.share-section { display:grid; gap:10px; padding:14px; border:1px solid var(--border); border-radius:10px; background:var(--surface); } -.share-form-grid { display:grid; grid-template-columns:minmax(220px,1fr) minmax(140px,auto) auto; gap:10px; align-items:end; } -.share-form-grid label { display:grid; gap:5px; } -.share-link-form { grid-template-columns:minmax(140px,auto) minmax(110px,140px) auto auto; } -.share-forever { display:flex !important; align-items:center; gap:7px !important; min-height:38px; white-space:nowrap; } -.share-forever input { width:auto; } -.share-list { display:grid; gap:8px; } -.share-list-row { display:grid; grid-template-columns:minmax(180px,1fr) auto auto; gap:8px; align-items:center; padding:9px 0; border-top:1px solid var(--border); } -.share-list-row:first-child { border-top:0; } -.share-list-row strong, .share-list-row small { display:block; overflow-wrap:anywhere; } -.share-list-row small { color:var(--muted); margin-top:2px; } -.share-role { color:var(--muted); font-size:.82rem; } -.share-link-row { grid-template-columns:minmax(170px,1fr) minmax(130px,auto) 100px auto auto auto; } -.share-link-row input, .share-link-row select { min-width:0; } -.share-empty { margin:0; color:var(--muted); } +.share-panel { + display: grid; + gap: 18px; +} + +.share-panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.share-panel-head h3, +.share-section h4 { + margin: 0; +} + +.share-panel-head p { + margin: 4px 0 0; + color: var(--muted); +} + +.share-section { + display: grid; + gap: 10px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--surface); +} + +.share-form-grid { + display: grid; + grid-template-columns: minmax(220px, 1fr) minmax(140px, auto) auto; + gap: 10px; + align-items: end; +} + +.share-form-grid label { + display: grid; + gap: 5px; +} + +.share-link-form { + grid-template-columns: minmax(140px, auto) minmax(110px, 140px) auto auto; +} + +.share-forever { + display: flex !important; + align-items: center; + gap: 7px !important; + min-height: 38px; + white-space: nowrap; +} + +.share-forever input { + width: auto; +} + +.share-list { + display: grid; + gap: 8px; +} + +.share-list-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) auto auto; + gap: 8px; + align-items: center; + padding: 9px 0; + border-top: 1px solid var(--border); +} + +.share-list-row:first-child { + border-top: 0; +} + +.share-list-row strong, +.share-list-row small { + display: block; + overflow-wrap: anywhere; +} + +.share-list-row small { + color: var(--muted); + margin-top: 2px; +} + +.share-role { + color: var(--muted); + font-size: .82rem; +} + +.share-link-row { + grid-template-columns: minmax(170px, 1fr) minmax(130px, auto) 100px auto auto auto; +} + +.share-link-row input, +.share-link-row select { + min-width: 0; +} + +.share-empty { + margin: 0; + color: var(--muted); +} + @media (max-width:800px) { - .share-form-grid, .share-link-form, .share-list-row, .share-link-row { grid-template-columns:1fr; align-items:stretch; } - .share-panel-head { flex-direction:column; } - .share-panel-head > button { align-self:flex-end; } + + .share-form-grid, + .share-link-form, + .share-list-row, + .share-link-row { + grid-template-columns: 1fr; + align-items: stretch; + } + + .share-panel-head { + flex-direction: column; + } + + .share-panel-head>button { + align-self: flex-end; + } } /* Share dialog */ -.share-dialog { width: min(900px, calc(100% - 28px)); max-width: 900px; max-height: min(88dvh, 860px); padding: 0; overflow: hidden; } -.share-dialog::backdrop { background: rgb(0 0 0 / .68); backdrop-filter: blur(3px); } -.share-dialog .share-panel { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: min(88dvh, 860px); gap: 0; } -.share-dialog .share-panel-head { padding: 20px 22px 16px; border-bottom: 1px solid var(--border); background: var(--surface); } -.share-dialog .share-panel-head strong { color: var(--text); } -.share-dialog-body { display: grid; gap: 16px; padding: 18px 22px 22px; overflow-y: auto; background: color-mix(in srgb, var(--surface) 92%, black); } -.share-dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 64px; padding: 12px 22px; border-top: 1px solid var(--border); background: var(--surface); } -.share-dialog-footer .form-message { margin: 0; } -.dialog-close { width: 34px; height: 34px; flex: 0 0 auto; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); color: var(--muted); font-size: 1.35rem; line-height: 1; cursor: pointer; } -.dialog-close:hover { color: var(--text); border-color: var(--border-strong); } -.share-section { padding: 16px; background: var(--surface); } -.share-section-head h4 { margin: 0; } -.share-section-head p { margin: 4px 0 2px; color: var(--muted); font-size: .82rem; } -.share-form-grid label > span:not(.sr-only), .share-link-row label > span:not(.sr-only) { color: var(--muted); font-size: .76rem; } -.share-dialog select { width: 100%; min-height: 42px; padding: 0 38px 0 12px; border: 1px solid var(--border-strong); border-radius: 8px; background-color: #10141a; color: var(--text); appearance: none; background-image: linear-gradient(45deg, transparent 50%, #9aa3b2 50%), linear-gradient(135deg, #9aa3b2 50%, transparent 50%); background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; cursor: pointer; } -.share-dialog select:hover { border-color: #596273; } -.share-dialog select:focus { border-color: #7567db; box-shadow: 0 0 0 3px rgb(117 103 219 / .18); outline: none; } -.share-dialog input[type="text"], .share-dialog input[type="number"] { min-height: 42px; border-radius: 8px; padding: 0 12px; } -.share-hours-field { display: grid; grid-template-columns: minmax(70px, 1fr) auto; align-items: center; min-height: 42px; border: 1px solid var(--border-strong); border-radius: 8px; background: #10141a; overflow: hidden; } -.share-hours-field:focus-within { border-color: #7567db; box-shadow: 0 0 0 3px rgb(117 103 219 / .18); } -.share-hours-field input { width: 100%; min-width: 0; border: 0 !important; background: transparent; box-shadow: none !important; outline: 0 !important; } -.share-hours-field span { padding: 0 11px; color: var(--muted); font-size: .76rem; } -.share-list-row { min-height: 58px; padding: 10px 0; } -.share-list-identity { display: flex; align-items: center; gap: 10px; min-width: 0; } -.share-avatar { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: color-mix(in srgb, var(--accent) 20%, var(--surface-2)); color: #d8d3ff; font-weight: 700; } -.share-list-identity > div, .share-link-info { min-width: 0; } -.share-row-actions { display: flex; gap: 7px; justify-content: flex-end; } -.compact-button { min-height: 36px; padding: 7px 11px; } -.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; } +.share-dialog { + width: min(900px, calc(100% - 28px)); + max-width: 900px; + max-height: min(88dvh, 860px); + padding: 0; + overflow: hidden; +} + +.share-dialog::backdrop { + background: rgb(0 0 0 / .68); + backdrop-filter: blur(3px); +} + +.share-dialog .share-panel { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + max-height: min(88dvh, 860px); + gap: 0; +} + +.share-dialog .share-panel-head { + padding: 20px 22px 16px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.share-dialog .share-panel-head strong { + color: var(--text); +} + +.share-dialog-body { + display: grid; + gap: 16px; + padding: 18px 22px 22px; + overflow-y: auto; + background: color-mix(in srgb, var(--surface) 92%, black); +} + +.share-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 64px; + padding: 12px 22px; + border-top: 1px solid var(--border); + background: var(--surface); +} + +.share-dialog-footer .form-message { + margin: 0; +} + +.dialog-close { + width: 34px; + height: 34px; + flex: 0 0 auto; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-2); + color: var(--muted); + font-size: 1.35rem; + line-height: 1; + cursor: pointer; +} + +.dialog-close:hover { + color: var(--text); + border-color: var(--border-strong); +} + +.share-section { + padding: 16px; + background: var(--surface); +} + +.share-section-head h4 { + margin: 0; +} + +.share-section-head p { + margin: 4px 0 2px; + color: var(--muted); + font-size: .82rem; +} + +.share-form-grid label>span:not(.sr-only), +.share-link-row label>span:not(.sr-only) { + color: var(--muted); + font-size: .76rem; +} + +.share-dialog select { + width: 100%; + min-height: 42px; + padding: 0 38px 0 12px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background-color: #10141a; + color: var(--text); + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, #9aa3b2 50%), linear-gradient(135deg, #9aa3b2 50%, transparent 50%); + background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + cursor: pointer; +} + +.share-dialog select:hover { + border-color: #596273; +} + +.share-dialog select:focus { + border-color: #7567db; + box-shadow: 0 0 0 3px rgb(117 103 219 / .18); + outline: none; +} + +.share-dialog input[type="text"], +.share-dialog input[type="number"] { + min-height: 42px; + border-radius: 8px; + padding: 0 12px; +} + +.share-hours-field { + display: grid; + grid-template-columns: minmax(70px, 1fr) auto; + align-items: center; + min-height: 42px; + border: 1px solid var(--border-strong); + border-radius: 8px; + background: #10141a; + overflow: hidden; +} + +.share-hours-field:focus-within { + border-color: #7567db; + box-shadow: 0 0 0 3px rgb(117 103 219 / .18); +} + +.share-hours-field input { + width: 100%; + min-width: 0; + border: 0 !important; + background: transparent; + box-shadow: none !important; + outline: 0 !important; +} + +.share-hours-field span { + padding: 0 11px; + color: var(--muted); + font-size: .76rem; +} + +.share-list-row { + min-height: 58px; + padding: 10px 0; +} + +.share-list-identity { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.share-avatar { + display: grid; + place-items: center; + width: 34px; + height: 34px; + flex: 0 0 auto; + border-radius: 50%; + background: color-mix(in srgb, var(--accent) 20%, var(--surface-2)); + color: #d8d3ff; + font-weight: 700; +} + +.share-list-identity>div, +.share-link-info { + min-width: 0; +} + +.share-row-actions { + display: flex; + gap: 7px; + justify-content: flex-end; +} + +.compact-button { + min-height: 36px; + padding: 7px 11px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + @media (max-width: 800px) { - .share-dialog { width: calc(100% - 16px); max-height: calc(100dvh - 16px); } - .share-dialog .share-panel { max-height: calc(100dvh - 16px); } - .share-dialog .share-panel-head, .share-dialog-body, .share-dialog-footer { padding-left: 14px; padding-right: 14px; } - .share-dialog-footer { align-items: stretch; flex-direction: column; } - .share-dialog-footer > button { align-self: flex-end; } - .share-row-actions { justify-content: stretch; } - .share-row-actions button { flex: 1; } + .share-dialog { + width: calc(100% - 16px); + max-height: calc(100dvh - 16px); + } + + .share-dialog .share-panel { + max-height: calc(100dvh - 16px); + } + + .share-dialog .share-panel-head, + .share-dialog-body, + .share-dialog-footer { + padding-left: 14px; + padding-right: 14px; + } + + .share-dialog-footer { + align-items: stretch; + flex-direction: column; + } + + .share-dialog-footer>button { + align-self: flex-end; + } + + .share-row-actions { + justify-content: stretch; + } + + .share-row-actions button { + flex: 1; + } } /* Refined sharing dialog and shared-resource attribution. */ @@ -689,18 +3663,78 @@ dialog::backdrop { background: rgba(4,6,9,.82); } background: var(--surface); box-shadow: 0 28px 90px rgb(0 0 0 / .58); } -.share-dialog .share-panel { overflow: hidden; border-radius: inherit; } -.share-dialog .share-panel-head { border-radius: 18px 18px 0 0; } -.share-dialog-footer { border-radius: 0 0 18px 18px; } -.share-dialog-body { scrollbar-gutter: stable; } -.share-section { border-radius: 12px; box-shadow: inset 0 1px 0 rgb(255 255 255 / .025); } -.resource-copy { min-width: 0; } -.resource-title-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; } -.resource-title-line a { min-width: 0; } -.resource-shared-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 3px 8px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 999px; background: color-mix(in srgb, var(--accent) 12%, transparent); color: color-mix(in srgb, var(--accent) 70%, white); font-size: .68rem; font-weight: 650; line-height: 1.2; } -.resource-row--shared { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); background: color-mix(in srgb, var(--accent) 4%, transparent); } -@media (max-width: 640px) { - .share-dialog, .share-dialog .share-panel, .share-dialog .share-panel-head, .share-dialog-footer { border-radius: 14px; } - .share-dialog .share-panel-head { border-radius: 14px 14px 0 0; } - .share-dialog-footer { border-radius: 0 0 14px 14px; } + +.share-dialog .share-panel { + overflow: hidden; + border-radius: inherit; } + +.share-dialog .share-panel-head { + border-radius: 18px 18px 0 0; +} + +.share-dialog-footer { + border-radius: 0 0 18px 18px; +} + +.share-dialog-body { + scrollbar-gutter: stable; +} + +.share-section { + border-radius: 12px; + box-shadow: inset 0 1px 0 rgb(255 255 255 / .025); +} + +.resource-copy { + min-width: 0; +} + +.resource-title-line { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.resource-title-line a { + min-width: 0; +} + +.resource-shared-badge { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: color-mix(in srgb, var(--accent) 70%, white); + font-size: .68rem; + font-weight: 650; + line-height: 1.2; +} + +.resource-row--shared { + border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); + background: color-mix(in srgb, var(--accent) 4%, transparent); +} + +@media (max-width: 640px) { + + .share-dialog, + .share-dialog .share-panel, + .share-dialog .share-panel-head, + .share-dialog-footer { + border-radius: 14px; + } + + .share-dialog .share-panel-head { + border-radius: 14px 14px 0 0; + } + + .share-dialog-footer { + border-radius: 0 0 14px 14px; + } +} \ No newline at end of file diff --git a/static/error.html b/static/error.html index ea466bc..e77e4f6 100644 --- a/static/error.html +++ b/static/error.html @@ -1,5 +1,6 @@ + @@ -8,6 +9,7 @@ __ERROR_TITLE__ · RustPad +
@@ -21,4 +23,5 @@
- + + \ No newline at end of file diff --git a/static/home.html b/static/home.html index c9330ac..63562b5 100644 --- a/static/home.html +++ b/static/home.html @@ -1,13 +1,17 @@ + RustPad - + + +
@@ -26,11 +30,15 @@
-
/p/meeting-notes0/80
+
/p/meeting-notes0/80
-
optional, min. 8 characters
-
+
optional, min. 8 characters +
+
@@ -46,11 +54,15 @@
-
/w/my-project0/80
+
/w/my-project0/80
-
optional, min. 8 characters
-
+
optional, min. 8 + characters
+
@@ -63,14 +75,16 @@ @@ -82,9 +96,12 @@

- - - + + +
- + + \ No newline at end of file diff --git a/static/workspace.html b/static/workspace.html index 3a3e770..369c7d5 100644 --- a/static/workspace.html +++ b/static/workspace.html @@ -1,5 +1,61 @@ -__WORKSPACE_TITLE__ · RustPad -
RustPad

__WORKSPACE_TITLE__

-

Notes

Select a note or create a new one.

View

-

Protected workspace

Cancel
-

New note

+ + + + + + + + __WORKSPACE_TITLE__ · RustPad + + + + + + +
+
RustPad +
+

__WORKSPACE_TITLE__

+

+
+
+
+
+
+
+
+

Notes

+

Select a note or create a new one.

+
+
+
View +
+
+

+
+
+ +
+

Protected workspace

+

Cancel +
+
+ +
+

New note

+

+
+
+
+
+ + + \ No newline at end of file From 33b4667e42047951238b283ef26c601694824c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 24 Jul 2026 11:22:44 +0200 Subject: [PATCH 5/6] cleanup code --- dev.sh | 26 +- src/api.rs | 502 ++++++++++++++++----- src/app.rs | 156 +++++-- src/auth.rs | 1085 ++++++++++++++++++++++++++++++++++++---------- src/config.rs | 42 +- src/database.rs | 29 +- src/db.rs | 369 +++++++++++----- src/main.rs | 35 +- src/queries.rs | 96 ++-- src/state.rs | 118 ++++- src/storage.rs | 104 ++++- src/websocket.rs | 537 +++++++++++++++++------ 12 files changed, 2387 insertions(+), 712 deletions(-) diff --git a/dev.sh b/dev.sh index 74e112d..c94e4ad 100644 --- a/dev.sh +++ b/dev.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail + cd "$(dirname "$0")" mkdir -p data/db data/files @@ -11,15 +12,24 @@ export FILES_DIR="${FILES_DIR:-$(pwd)/data/files}" export UPLOAD_MAX_SIZE_MB="${UPLOAD_MAX_SIZE_MB:-20}" export STATIC_DIR="${STATIC_DIR:-$(pwd)/static}" export RUST_LOG="${RUST_LOG:-rustpad=debug,tower_http=info}" -# A new value on every run prevents stale HTML/JS cache issues. + +# Generate a new asset version on each run to prevent stale HTML and JavaScript. export ASSET_VERSION="${ASSET_VERSION:-dev-$(date +%s)}" if command -v cargo >/dev/null 2>&1; then - exec cargo run -elif command -v docker >/dev/null 2>&1; then - export IMAGE_TAG="${IMAGE_TAG:-dev}" - exec docker compose up --build --force-recreate --remove-orphans -else - echo "Brak cargo i docker. Zainstaluj Rust 1.85+ albo Docker." >&2 - exit 1 + echo "Cleaning RustPad build artifacts..." + cargo clean --package rustpad + + echo "Starting RustPad..." + exec cargo run --package rustpad fi + +if command -v docker >/dev/null 2>&1; then + export IMAGE_TAG="${IMAGE_TAG:-dev}" + + echo "Starting RustPad with Docker..." + exec docker compose up --build --force-recreate --remove-orphans +fi + +echo "Neither Cargo nor Docker was found. Install Rust 1.85+ or Docker." >&2 +exit 1 \ No newline at end of file diff --git a/src/api.rs b/src/api.rs index 5dc94c2..d3b5f78 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,12 +1,12 @@ use axum::{ - extract::{Multipart, Path, State}, - http::{header, HeaderMap, HeaderValue, StatusCode}, - response::{IntoResponse, Response}, Json, + extract::{Multipart, Path, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, }; -use serde::{Deserialize, Serialize}; use chrono::{Duration, Utc}; use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use slug::slugify; @@ -141,8 +141,18 @@ pub async fn create_workspace( let slug = unique_workspace_slug(&state, title).await?; let workspace = db::create_workspace(&state.db, &slug, title, password).await?; - if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? { - sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_WORKSPACE)).bind(user.id).bind(&workspace.slug).execute(state.db.pool()).await?; + if let Some(user) = crate::auth::optional_user(&state, &headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + { + sqlx::query(queries::get( + state.db.kind(), + queries::USER_ATTACH_WORKSPACE, + )) + .bind(user.id) + .bind(&workspace.slug) + .execute(state.db.pool()) + .await?; } Ok(( @@ -169,7 +179,13 @@ pub async fn open_workspace( Path(workspace_slug): Path, Json(payload): Json, ) -> Result, ApiError> { - let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let workspace = authorized_workspace( + &state, + &workspace_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let notes = db::list_notes(&state.db, workspace.id) .await? .into_iter() @@ -195,16 +211,37 @@ pub async fn create_note( Path(workspace_slug): Path, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { - let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let workspace = authorized_workspace( + &state, + &workspace_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let title = validate_name(&payload.name, "Note name")?; let base = slugify(title); if base.is_empty() { - return Err(ApiError::bad_request("The name cannot be converted into a valid address")); + return Err(ApiError::bad_request( + "The name cannot be converted into a valid address", + )); } let slug = unique_note_slug(&state, workspace.id, &base).await?; - let created_by = payload.created_by.as_deref().map(str::trim).filter(|v| !v.is_empty()).map(|v| v.chars().take(40).collect::()); - let note = db::create_note(&state.db, workspace.id, &slug, title, payload.protect, created_by.as_deref()).await?; + let created_by = payload + .created_by + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(|v| v.chars().take(40).collect::()); + let note = db::create_note( + &state.db, + workspace.id, + &slug, + title, + payload.protect, + created_by.as_deref(), + ) + .await?; Ok(( StatusCode::CREATED, Json(NoteListItem { @@ -248,7 +285,14 @@ pub async fn history( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { - let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let (workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let _ = workspace; let revisions = db::list_revisions(&state.db, note.id) .await? @@ -266,15 +310,29 @@ pub async fn restore( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; - let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) - .bind(payload.revision_id) - .bind(note.id) - .fetch_optional(state.db.pool()) + let (workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) .await?; + let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) + .bind(payload.revision_id) + .bind(note.id) + .fetch_optional(state.db.pool()) + .await?; let content = content.ok_or_else(ApiError::not_found_revision)?; - let (revision_id, updated_at) = - db::save_revision(&state.db, note.id, workspace.id, &content, Some("restore"), "[]").await?; + let (revision_id, updated_at) = db::save_revision( + &state.db, + note.id, + workspace.id, + &content, + Some("restore"), + "[]", + ) + .await?; let update = NoteUpdate { content, revision_id, @@ -282,7 +340,10 @@ pub async fn restore( author: Some("restore".into()), owner_map: "[]".into(), }; - let _ = state.note_channel(&workspace_slug, ¬e_slug).await.send(RoomEvent::Document(update)); + let _ = state + .note_channel(&workspace_slug, ¬e_slug) + .await + .send(RoomEvent::Document(update)); Ok(Json(serde_json::json!({"ok": true}))) } @@ -296,8 +357,13 @@ pub async fn authorized_workspace( .await? .ok_or_else(ApiError::not_found_workspace)?; let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?; - if workspace.is_private != 0 && !token_access { return Err(ApiError::forbidden("This workspace is private.")); } - if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password) && !token_access { + if workspace.is_private != 0 && !token_access { + return Err(ApiError::forbidden("This workspace is private.")); + } + if workspace.password_hash.is_some() + && !db::verify_workspace_password(&workspace, password) + && !token_access + { return Err(ApiError::unauthorized()); } Ok(workspace) @@ -353,7 +419,9 @@ fn validate_password(password: Option<&str>) -> Result, ApiError> { async fn unique_workspace_slug(state: &SharedState, title: &str) -> Result { let base = slugify(title); if base.is_empty() { - return Err(ApiError::bad_request("The name cannot be converted into a valid address")); + return Err(ApiError::bad_request( + "The name cannot be converted into a valid address", + )); } let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH @@ -376,7 +444,10 @@ async fn unique_note_slug( workspace_id: i64, base: &str, ) -> Result { - if db::find_note(&state.db, workspace_id, base).await?.is_none() { + if db::find_note(&state.db, workspace_id, base) + .await? + .is_none() + { return Ok(base.to_owned()); } for _ in 0..8 { @@ -391,7 +462,6 @@ async fn unique_note_slug( Err(ApiError::internal("Failed to create a unique address")) } - #[derive(Debug, Deserialize)] pub struct CreatePadRequest { name: String, @@ -424,12 +494,21 @@ pub async fn create_pad( let password = validate_password(payload.password.as_deref())?; let base = slugify(title); if base.is_empty() { - return Err(ApiError::bad_request("The name cannot be converted into a valid address")); + return Err(ApiError::bad_request( + "The name cannot be converted into a valid address", + )); } let slug = unique_pad_slug(&state, &base).await?; let pad = db::create_pad(&state.db, &slug, title, password).await?; - if let Some(user) = crate::auth::optional_user(&state, &headers).await.map_err(|e| ApiError::forbidden(&e.message))? { - sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)).bind(user.id).bind(&pad.slug).execute(state.db.pool()).await?; + if let Some(user) = crate::auth::optional_user(&state, &headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + { + sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)) + .bind(user.id) + .bind(&pad.slug) + .execute(state.db.pool()) + .await?; } Ok(( StatusCode::CREATED, @@ -462,10 +541,18 @@ pub async fn publish_pad_page( Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let token = db::publish_pad(&state.db, pad.id).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; - Ok(Json(PublishResponse { url: format!("/s/{token}") })) + Ok(Json(PublishResponse { + url: format!("/s/{token}"), + })) } pub async fn publish_note_page( @@ -473,10 +560,19 @@ pub async fn publish_note_page( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let (_, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let token = db::publish_note(&state.db, note.id).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; - Ok(Json(PublishResponse { url: format!("/s/{token}") })) + Ok(Json(PublishResponse { + url: format!("/s/{token}"), + })) } pub async fn public_page( @@ -499,9 +595,17 @@ pub async fn update_public_task( Path(token): Path, Json(payload): Json, ) -> Result, ApiError> { - let current = db::find_published_page(&state.db, &token).await?.ok_or_else(ApiError::not_found_note)?; - if !current.allow_task_updates { return Err(ApiError::forbidden("Task updates are disabled for this page")); } - let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked).await?.ok_or_else(ApiError::not_found_note)?; + let current = db::find_published_page(&state.db, &token) + .await? + .ok_or_else(ApiError::not_found_note)?; + if !current.allow_task_updates { + return Err(ApiError::forbidden( + "Task updates are disabled for this page", + )); + } + let page = db::update_public_task(&state.db, &token, payload.source_line, payload.checked) + .await? + .ok_or_else(ApiError::not_found_note)?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, @@ -515,7 +619,13 @@ pub async fn pad_history( Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let revisions = db::list_pad_revisions(&state.db, pad.id) .await? .into_iter() @@ -532,20 +642,28 @@ pub async fn pad_restore( Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; - let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) - .bind(payload.revision_id) - .bind(pad.id) - .fetch_optional(state.db.pool()) + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) .await?; - let content = content.ok_or_else(ApiError::not_found_revision)?; - let owner_map: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q030)) + let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) .bind(payload.revision_id) .bind(pad.id) .fetch_optional(state.db.pool()) .await?; + let content = content.ok_or_else(ApiError::not_found_revision)?; + let owner_map: Option = + sqlx::query_scalar(queries::get(state.db.kind(), queries::Q030)) + .bind(payload.revision_id) + .bind(pad.id) + .fetch_optional(state.db.pool()) + .await?; let owner_map = owner_map.unwrap_or_else(|| "[]".into()); - let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?; + let (revision_id, updated_at) = + db::save_pad_revision(&state.db, pad.id, &content, Some("restore"), &owner_map).await?; let update = NoteUpdate { content, revision_id, @@ -553,7 +671,10 @@ pub async fn pad_restore( author: Some("restore".into()), owner_map, }; - let _ = state.pad_channel(&slug).await.send(RoomEvent::Document(update)); + let _ = state + .pad_channel(&slug) + .await + .send(RoomEvent::Document(update)); Ok(Json(serde_json::json!({"ok": true}))) } @@ -567,7 +688,9 @@ async fn authorized_pad( .await? .ok_or_else(ApiError::not_found_note)?; let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?; - if pad.is_private != 0 && !token_access { return Err(ApiError::forbidden("This note is private.")); } + if pad.is_private != 0 && !token_access { + return Err(ApiError::forbidden("This note is private.")); + } if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access { return Err(ApiError::unauthorized()); } @@ -587,8 +710,6 @@ async fn unique_pad_slug(state: &SharedState, base: &str) -> Result, Path(slug): Path, @@ -597,15 +718,32 @@ pub async fn upload_pad_file( let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; - while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? { + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| ApiError::bad_request("Invalid form data"))? + { let name = field.name().unwrap_or_default().to_owned(); if name == "password" { - password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); + password = Some( + field + .text() + .await + .map_err(|_| ApiError::bad_request("Invalid password"))?, + ); } else if name == "access_token" { - access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?); + access_token = Some( + field + .text() + .await + .map_err(|_| ApiError::bad_request("Invalid access token"))?, + ); } else if name == "file" { let filename = field.file_name().unwrap_or("plik").to_owned(); - let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?; + let bytes = field + .bytes() + .await + .map_err(|_| ApiError::bad_request("Failed to read the file"))?; if bytes.len() > state.upload_max_size_bytes { return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); } @@ -618,16 +756,33 @@ pub async fn upload_pad_file( let file_token = db::pad_file_token(&state.db, pad.id).await?; let mut stored = safe.clone(); let mut key = crate::storage::object_key("pads", pad.id, &file_token, &stored); - if state.storage.exists(&key).await.map_err(|_| ApiError::internal("Failed to check file storage"))? { - let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik"); - let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default(); + if state + .storage + .exists(&key) + .await + .map_err(|_| ApiError::internal("Failed to check file storage"))? + { + let stem = std::path::Path::new(&safe) + .file_stem() + .and_then(|v| v.to_str()) + .unwrap_or("plik"); + let ext = std::path::Path::new(&safe) + .extension() + .and_then(|v| v.to_str()) + .map(|v| format!(".{v}")) + .unwrap_or_default(); stored = format!("{stem}-{}{}", db::random_suffix(6), ext); key = crate::storage::object_key("pads", pad.id, &file_token, &stored); } let url = format!("/f/{}/{}", file_token, stored); - let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); + let mime = mime_guess::from_path(&stored) + .first_or_octet_stream() + .to_string(); let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); - state.storage.put(&key, bytes.clone().into(), &mime, &cache_control).await + state + .storage + .put(&key, bytes.clone().into(), &mime, &cache_control) + .await .map_err(|_| ApiError::internal("Failed to save the file"))?; db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?; Ok(Json(serde_json::json!({"name": stored, "url": url}))) @@ -638,14 +793,24 @@ pub async fn pad_files( Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { - let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let mut files = db::list_pad_files(&state.db, pad.id).await?; for file in &mut files { let attached = pad.content.contains(&file.url); if attached != file.is_attached { db::set_pad_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; - file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; + file.detached_at = if attached { + None + } else { + Some(chrono::Utc::now().to_rfc3339()) + }; } file.created_at = db::normalize_timestamp(&file.created_at); } @@ -660,50 +825,101 @@ pub async fn upload_note_file( let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; - while let Some(field) = multipart.next_field().await.map_err(|_| ApiError::bad_request("Invalid form data"))? { + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| ApiError::bad_request("Invalid form data"))? + { let name = field.name().unwrap_or_default().to_owned(); if name == "password" { - password = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid password"))?); + password = Some( + field + .text() + .await + .map_err(|_| ApiError::bad_request("Invalid password"))?, + ); } else if name == "access_token" { - access_token = Some(field.text().await.map_err(|_| ApiError::bad_request("Invalid access token"))?); + access_token = Some( + field + .text() + .await + .map_err(|_| ApiError::bad_request("Invalid access token"))?, + ); } else if name == "file" { let filename = field.file_name().unwrap_or("plik").to_owned(); - let bytes = field.bytes().await.map_err(|_| ApiError::bad_request("Failed to read the file"))?; + let bytes = field + .bytes() + .await + .map_err(|_| ApiError::bad_request("Failed to read the file"))?; if bytes.len() > state.upload_max_size_bytes { return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); } file = Some((filename, bytes.to_vec())); } } - let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, password.as_deref(), access_token.as_deref()).await?; + let (_workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + password.as_deref(), + access_token.as_deref(), + ) + .await?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::note_file_token(&state.db, note.id).await?; let mut stored = safe.clone(); let mut key = crate::storage::object_key("notes", note.id, &file_token, &stored); - if state.storage.exists(&key).await.map_err(|_| ApiError::internal("Failed to check file storage"))? { - let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik"); - let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default(); + if state + .storage + .exists(&key) + .await + .map_err(|_| ApiError::internal("Failed to check file storage"))? + { + let stem = std::path::Path::new(&safe) + .file_stem() + .and_then(|v| v.to_str()) + .unwrap_or("plik"); + let ext = std::path::Path::new(&safe) + .extension() + .and_then(|v| v.to_str()) + .map(|v| format!(".{v}")) + .unwrap_or_default(); stored = format!("{stem}-{}{}", db::random_suffix(6), ext); key = crate::storage::object_key("notes", note.id, &file_token, &stored); } let url = format!("/f/{}/{}", file_token, stored); - let mime = mime_guess::from_path(&stored).first_or_octet_stream().to_string(); + let mime = mime_guess::from_path(&stored) + .first_or_octet_stream() + .to_string(); let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); - state.storage.put(&key, bytes.clone().into(), &mime, &cache_control).await + state + .storage + .put(&key, bytes.clone().into(), &mime, &cache_control) + .await .map_err(|_| ApiError::internal("Failed to save the file"))?; db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?; Ok(Json(serde_json::json!({"name": stored, "url": url}))) } - pub async fn delete_note( State(state): State, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { - let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; - if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); } + let (_workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; + if note.protected { + return Err(ApiError::bad_request( + "This note is protected and cannot be deleted", + )); + } db::delete_note(&state.db, note.id).await?; Ok(Json(serde_json::json!({"ok": true}))) } @@ -713,14 +929,25 @@ pub async fn note_files( Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { - let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; + let (_workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; let mut files = db::list_note_files(&state.db, note.id).await?; for file in &mut files { let attached = note.content.contains(&file.url); if attached != file.is_attached { db::set_note_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; - file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; + file.detached_at = if attached { + None + } else { + Some(chrono::Utc::now().to_rfc3339()) + }; } file.created_at = db::normalize_timestamp(&file.created_at); } @@ -732,16 +959,39 @@ pub async fn delete_note_file( Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>, Json(payload): Json, ) -> Result, ApiError> { - let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; - if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() { + let (workspace, note) = authorized_note( + &state, + &workspace_slug, + ¬e_slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + ) + .await?; + if workspace.password_hash.is_none() + || payload.password.as_deref().unwrap_or_default().is_empty() + { return Err(ApiError::unauthorized()); } - let file = db::find_note_file(&state.db, note.id, file_id).await? + let file = db::find_note_file(&state.db, note.id, file_id) + .await? .ok_or_else(ApiError::not_found_file)?; - let relative = file.url.trim_start_matches('/').split('/').collect::>(); + let relative = file + .url + .trim_start_matches('/') + .split('/') + .collect::>(); if relative.len() == 3 && relative[0] == "f" { - let key = crate::storage::object_key("notes", note.id, relative[1], &sanitize_filename(relative[2])); - state.storage.delete(&key).await.map_err(|_| ApiError::internal("Failed to delete the file"))?; + let key = crate::storage::object_key( + "notes", + note.id, + relative[1], + &sanitize_filename(relative[2]), + ); + state + .storage + .delete(&key) + .await + .map_err(|_| ApiError::internal("Failed to delete the file"))?; } db::delete_note_file(&state.db, note.id, file_id).await?; Ok(Json(serde_json::json!({"ok": true}))) @@ -762,7 +1012,8 @@ pub async fn download_legacy_file( return Err(ApiError::not_found_file()); }; let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?; - let owner = db::find_file_owner(&state.db, token).await? + let owner = db::find_file_owner(&state.db, token) + .await? .ok_or_else(ApiError::not_found_file)?; if owner.id != id { return Err(ApiError::not_found_file()); @@ -770,12 +1021,17 @@ pub async fn download_legacy_file( serve_token_file(&state, token, &filename).await } -async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> Result { +async fn serve_token_file( + state: &SharedState, + token: &str, + filename: &str, +) -> Result { let safe = sanitize_filename(filename); if safe != filename { return Err(ApiError::not_found_file()); } - let owner = db::find_file_owner(&state.db, token).await? + let owner = db::find_file_owner(&state.db, token) + .await? .ok_or_else(ApiError::not_found_file)?; let kind = match owner.kind { db::FileOwnerKind::Pad => "pads", @@ -783,27 +1039,53 @@ async fn serve_token_file(state: &SharedState, token: &str, filename: &str) -> R }; let key = crate::storage::object_key(kind, owner.id, token, &safe); let legacy_key = crate::storage::legacy_key(owner.id, token, &safe); - let bytes = state.storage.get_local_with_legacy(&key, &legacy_key).await + let bytes = state + .storage + .get_local_with_legacy(&key, &legacy_key) + .await .map_err(|_| ApiError::not_found_file())?; let mime = mime_guess::from_path(&safe).first_or_octet_stream(); let mut response = bytes.into_response(); response.headers_mut().insert( header::CONTENT_TYPE, - HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), + HeaderValue::from_str(mime.as_ref()) + .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), + ); + response.headers_mut().insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), ); - response.headers_mut().insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); response.headers_mut().insert( header::CACHE_CONTROL, - HeaderValue::from_str(&format!("public, max-age={}", state.file_cache_max_age_seconds)) - .expect("valid file cache-control header"), + HeaderValue::from_str(&format!( + "public, max-age={}", + state.file_cache_max_age_seconds + )) + .expect("valid file cache-control header"), ); Ok(response) } fn sanitize_filename(value: &str) -> String { - let name = std::path::Path::new(value).file_name().and_then(|v| v.to_str()).unwrap_or("plik"); - let clean: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '_' }).collect(); - if clean.is_empty() || clean == "." || clean == ".." { "plik".into() } else { clean.chars().take(160).collect() } + let name = std::path::Path::new(value) + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("plik"); + let clean: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { + c + } else { + '_' + } + }) + .collect(); + if clean.is_empty() || clean == "." || clean == ".." { + "plik".into() + } else { + clean.chars().take(160).collect() + } } #[derive(Debug, Deserialize)] @@ -827,13 +1109,17 @@ pub async fn create_resource_access_token( let slug = payload.slug.trim(); match kind { "workspace" => { - let workspace = db::find_workspace(&state.db, slug).await?.ok_or_else(ApiError::not_found_workspace)?; + let workspace = db::find_workspace(&state.db, slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; if !db::verify_workspace_password(&workspace, Some(payload.password.as_str())) { return Err(ApiError::unauthorized()); } } "pad" => { - let pad = db::find_pad(&state.db, slug).await?.ok_or_else(ApiError::not_found_note)?; + let pad = db::find_pad(&state.db, slug) + .await? + .ok_or_else(ApiError::not_found_note)?; if !db::verify_pad_password(&pad, Some(payload.password.as_str())) { return Err(ApiError::unauthorized()); } @@ -844,7 +1130,8 @@ pub async fn create_resource_access_token( let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); let token = hex::encode(bytes); - let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); + let expires_at = + (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)")) .bind(hash_access_token(&token)) .bind(kind) @@ -852,7 +1139,10 @@ pub async fn create_resource_access_token( .bind(&expires_at) .execute(state.db.pool()) .await?; - Ok(Json(AccessTokenResponse { access_token: token, expires_at })) + Ok(Json(AccessTokenResponse { + access_token: token, + expires_at, + })) } pub async fn verify_resource_access_token( @@ -864,7 +1154,11 @@ pub async fn verify_resource_access_token( let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(false); }; - if crate::auth::resource_permission(state, kind, slug, Some(token)).await.map_err(|error| ApiError::forbidden(&error.message))?.is_some() { + if crate::auth::resource_permission(state, kind, slug, Some(token)) + .await + .map_err(|error| ApiError::forbidden(&error.message))? + .is_some() + { return Ok(true); } let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?")) @@ -901,7 +1195,10 @@ impl ApiError { } } fn not_found_file() -> Self { - Self { status: StatusCode::NOT_FOUND, message: "File not found".into() } + Self { + status: StatusCode::NOT_FOUND, + message: "File not found".into(), + } } fn unauthorized() -> Self { Self { @@ -910,7 +1207,10 @@ impl ApiError { } } fn forbidden(message: &str) -> Self { - Self { status: StatusCode::FORBIDDEN, message: message.into() } + Self { + status: StatusCode::FORBIDDEN, + message: message.into(), + } } fn not_found_workspace() -> Self { Self { @@ -947,6 +1247,10 @@ impl From for ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { - (self.status, Json(serde_json::json!({"error": self.message}))).into_response() + ( + self.status, + Json(serde_json::json!({"error": self.message})), + ) + .into_response() } } diff --git a/src/app.rs b/src/app.rs index b8dba8e..dbd4d32 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,17 +1,22 @@ use axum::{ + Router, extract::{DefaultBodyLimit, Path, State}, - http::{header, HeaderValue, StatusCode}, + http::{HeaderValue, StatusCode, header}, response::{Html, IntoResponse, Response}, routing::{get, post}, - Router, }; -use tower::{service_fn, ServiceBuilder}; +use tower::{ServiceBuilder, service_fn}; use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; use crate::{api, auth, db, state::SharedState, websocket}; use std::convert::Infallible; -pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize, asset_cache_max_age_seconds: u64) -> Router { +pub fn router( + state: SharedState, + static_dir: &str, + upload_max_size_bytes: usize, + asset_cache_max_age_seconds: u64, +) -> Router { let asset_version = state.asset_version.clone(); let asset_not_found = service_fn(move |_request| { let asset_version = asset_version.clone(); @@ -28,8 +33,9 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize } }); - let asset_cache_control = HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}")) - .expect("valid asset cache-control header"); + let asset_cache_control = + HeaderValue::from_str(&format!("public, max-age={asset_cache_max_age_seconds}")) + .expect("valid asset cache-control header"); Router::new() .route("/", get(home)) @@ -40,7 +46,10 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/errors/private-workspace", get(private_workspace_error)) .route("/health", get(health)) .route("/f/{token}/{filename}", get(api::download_file)) - .route("/files/{directory}/{filename}", get(api::download_legacy_file)) + .route( + "/files/{directory}/{filename}", + get(api::download_legacy_file), + ) .route("/api/auth/identity", post(auth::identity)) .route("/api/access-token", post(api::create_resource_access_token)) .route("/api/auth/register", post(auth::register)) @@ -48,13 +57,37 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/api/auth/confirm-account", post(auth::confirm_account)) .route("/api/auth/me", get(auth::me)) .route("/api/auth/logout", post(auth::logout)) - .route("/api/auth/resources", get(auth::resources).put(auth::update_resource).delete(auth::delete_resource)) - .route("/api/auth/resources/privacy", post(auth::set_resource_privacy)) - .route("/api/auth/resources/sharing", get(auth::resource_sharing).post(auth::share_resource_users).delete(auth::remove_resource_user)) - .route("/api/auth/resources/share-links", post(auth::create_share_link).put(auth::update_share_link).delete(auth::revoke_share_link)) - .route("/share-invitations/{token}/accept", get(auth::accept_share_invitation)) + .route( + "/api/auth/resources", + get(auth::resources) + .put(auth::update_resource) + .delete(auth::delete_resource), + ) + .route( + "/api/auth/resources/privacy", + post(auth::set_resource_privacy), + ) + .route( + "/api/auth/resources/sharing", + get(auth::resource_sharing) + .post(auth::share_resource_users) + .delete(auth::remove_resource_user), + ) + .route( + "/api/auth/resources/share-links", + post(auth::create_share_link) + .put(auth::update_share_link) + .delete(auth::revoke_share_link), + ) + .route( + "/share-invitations/{token}/accept", + get(auth::accept_share_invitation), + ) .route("/api/auth/password-reset", post(auth::request_reset)) - .route("/api/auth/password-reset/confirm", post(auth::confirm_reset)) + .route( + "/api/auth/password-reset/confirm", + post(auth::confirm_reset), + ) .route("/api/public/{token}", get(api::public_page)) .route("/api/public/{token}/tasks", post(api::update_public_task)) .route("/api/pads", post(api::create_pad)) @@ -62,11 +95,20 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize .route("/api/pads/{slug}/history", post(api::pad_history)) .route("/api/pads/{slug}/publish", post(api::publish_pad_page)) .route("/api/pads/{slug}/restore", post(api::pad_restore)) - .route("/api/pads/{slug}/files", post(api::upload_pad_file).put(api::pad_files)) + .route( + "/api/pads/{slug}/files", + post(api::upload_pad_file).put(api::pad_files), + ) .route("/api/workspaces", post(api::create_workspace)) .route("/api/workspaces/{workspace_slug}", get(api::workspace_info)) - .route("/api/workspaces/{workspace_slug}/open", post(api::open_workspace)) - .route("/api/workspaces/{workspace_slug}/notes", post(api::create_note)) + .route( + "/api/workspaces/{workspace_slug}/open", + post(api::open_workspace), + ) + .route( + "/api/workspaces/{workspace_slug}/notes", + post(api::create_note), + ) .route( "/api/workspaces/{workspace_slug}/notes/{note_slug}", get(api::note_info).delete(api::delete_note), @@ -92,10 +134,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize axum::routing::delete(api::delete_note_file), ) .route("/ws/p/{slug}", get(websocket::upgrade_pad)) - .route( - "/ws/{workspace_slug}/{note_slug}", - get(websocket::upgrade), - ) + .route("/ws/{workspace_slug}/{note_slug}", get(websocket::upgrade)) .route("/static", get(static_not_found)) .route("/static/{*path}", get(static_not_found)) .nest_service( @@ -109,12 +148,13 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize ) .fallback(not_found) .method_not_allowed_fallback(method_not_allowed) - .layer(DefaultBodyLimit::max(upload_max_size_bytes.saturating_add(1024 * 1024))) + .layer(DefaultBodyLimit::max( + upload_max_size_bytes.saturating_add(1024 * 1024), + )) .layer(TraceLayer::new_for_http()) .with_state(state) } - async fn private_workspace_error(State(state): State) -> Response { error_response( StatusCode::FORBIDDEN, @@ -132,19 +172,26 @@ async fn health() -> &'static str { } async fn home(State(state): State) -> Response { - versioned_html(include_str!("../static/home.html"), &state.asset_version, state.registration_enabled, &state.frontend_log_level) + versioned_html( + include_str!("../static/home.html"), + &state.asset_version, + state.registration_enabled, + &state.frontend_log_level, + ) } -async fn pad( - State(state): State, - Path(slug): Path, -) -> Response { +async fn pad(State(state): State, Path(slug): Path) -> Response { match db::find_pad(&state.db, &slug).await { Ok(Some(pad)) => { let html = include_str!("../static/pad.html") .replace("__PAD_TITLE__", &escape_html(&pad.title)); - versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level) - }, + versioned_html( + &html, + &state.asset_version, + state.registration_enabled, + &state.frontend_log_level, + ) + } Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -161,12 +208,14 @@ async fn pad( } } -async fn public_page( - State(state): State, - Path(token): Path, -) -> Response { +async fn public_page(State(state): State, Path(token): Path) -> Response { match db::find_published_page(&state.db, &token).await { - Ok(Some(_)) => versioned_html(include_str!("../static/public.html"), &state.asset_version, state.registration_enabled, &state.frontend_log_level), + Ok(Some(_)) => versioned_html( + include_str!("../static/public.html"), + &state.asset_version, + state.registration_enabled, + &state.frontend_log_level, + ), Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -191,8 +240,13 @@ async fn workspace( Ok(Some(workspace)) => { let html = include_str!("../static/workspace.html") .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)); - versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level) - }, + versioned_html( + &html, + &state.asset_version, + state.registration_enabled, + &state.frontend_log_level, + ) + } Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -238,8 +292,13 @@ async fn note( .replace("__NOTE_TITLE__", &escape_html(¬e.title)) .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); - versioned_html(&html, &state.asset_version, state.registration_enabled, &state.frontend_log_level) - }, + versioned_html( + &html, + &state.asset_version, + state.registration_enabled, + &state.frontend_log_level, + ) + } Ok(None) => error_response( StatusCode::NOT_FOUND, "404", @@ -326,14 +385,26 @@ fn error_response( response } -fn versioned_html(template: &str, asset_version: &str, registration_enabled: bool, frontend_log_level: &str) -> Response { +fn versioned_html( + template: &str, + asset_version: &str, + registration_enabled: bool, + frontend_log_level: &str, +) -> Response { let frontend_config = format!( r#""#, escape_js_string(frontend_log_level), ); let html = template .replace("__ASSET_VERSION__", asset_version) - .replace("__REGISTRATION_ENABLED__", if registration_enabled { "true" } else { "false" }) + .replace( + "__REGISTRATION_ENABLED__", + if registration_enabled { + "true" + } else { + "false" + }, + ) .replace("", &format!("{frontend_config}")); let mut response = Html(html).into_response(); no_store(&mut response); @@ -341,7 +412,10 @@ fn versioned_html(template: &str, asset_version: &str, registration_enabled: boo } fn escape_js_string(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"").replace('<', "\\u003c") + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('<', "\\u003c") } fn no_store(response: &mut Response) { diff --git a/src/auth.rs b/src/auth.rs index d958b37..095a446 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,10 +1,18 @@ -use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; -use axum::{extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::Redirect, Json}; +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, +}; +use axum::{ + Json, + extract::{Path as AxumPath, State}, + http::{HeaderMap, StatusCode}, + response::Redirect, +}; use chrono::{Duration, Utc}; use lettre::{ - message::{header::ContentType, Mailbox, MultiPart, SinglePart}, - transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, + message::{Mailbox, MultiPart, SinglePart, header::ContentType}, + transport::smtp::authentication::Credentials, }; use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; @@ -12,7 +20,10 @@ use sha2::{Digest, Sha256}; use sqlx::FromRow; use tracing::{debug, info, warn}; -use crate::{queries, state::{SharedState, SmtpConfig}}; +use crate::{ + queries, + state::{SharedState, SmtpConfig}, +}; const MIN_PASSWORD: usize = 8; const MAX_PASSWORD: usize = 128; @@ -20,222 +31,578 @@ const MAX_NICKNAME: usize = 40; #[derive(Debug, Clone, FromRow)] pub struct User { - pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option + pub id: i64, + pub nickname: String, + pub email: String, + pub password_hash: String, + pub confirmed_at: Option, } -#[derive(Deserialize)] pub struct IdentityRequest { - nickname: String, #[serde(default)] session_token: Option +#[derive(Deserialize)] +pub struct IdentityRequest { + nickname: String, + #[serde(default)] + session_token: Option, } -#[derive(Deserialize)] pub struct RegisterRequest { - nickname: String, email: String, password: String +#[derive(Deserialize)] +pub struct RegisterRequest { + nickname: String, + email: String, + password: String, } -#[derive(Deserialize)] pub struct LoginRequest { email: String, password: String } -#[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String } -#[derive(Deserialize)] pub struct ResetRequest { email: String } -#[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String } -#[derive(Deserialize)] pub struct ResourceActionRequest { - kind: String, slug: String, #[serde(default)] password: Option +#[derive(Deserialize)] +pub struct LoginRequest { + email: String, + password: String, } -#[derive(Serialize, FromRow)] pub struct ResourceItem { - slug: String, title: String, protected: i64, updated_at: String, #[sqlx(rename = "private")] +#[derive(Deserialize)] +pub struct ConfirmAccountRequest { + token: String, +} +#[derive(Deserialize)] +pub struct ResetRequest { + email: String, +} +#[derive(Deserialize)] +pub struct ResetConfirmRequest { + token: String, + password: String, +} +#[derive(Deserialize)] +pub struct ResourceActionRequest { + kind: String, + slug: String, + #[serde(default)] + password: Option, +} +#[derive(Serialize, FromRow)] +pub struct ResourceItem { + slug: String, + title: String, + protected: i64, + updated_at: String, + #[sqlx(rename = "private")] #[serde(rename = "private")] - private_resource: i64, owned: i64, permission: String, shared_by: String + private_resource: i64, + owned: i64, + permission: String, + shared_by: String, } -#[derive(Deserialize)] pub struct PrivacyRequest { kind: String, slug: String, private: bool } -#[derive(Deserialize)] pub struct ShareUsersRequest { kind: String, slug: String, emails: String, permission: String } -#[derive(Deserialize)] pub struct RemoveShareRequest { kind: String, slug: String, email: String } -#[derive(Deserialize)] pub struct CreateShareLinkRequest { kind: String, slug: String, permission: String, expires_at: Option } -#[derive(Deserialize)] pub struct UpdateShareLinkRequest { kind: String, slug: String, token: String, permission: String, expires_at: Option } -#[derive(Deserialize)] pub struct RevokeShareLinkRequest { kind: String, slug: String, token: String } - -#[derive(Serialize)] pub struct ResourceList { - workspaces: Vec, pads: Vec +#[derive(Deserialize)] +pub struct PrivacyRequest { + kind: String, + slug: String, + private: bool, } -#[derive(Serialize)] pub struct SessionResponse { - token: String, nickname: String, email: String, expires_at: String +#[derive(Deserialize)] +pub struct ShareUsersRequest { + kind: String, + slug: String, + emails: String, + permission: String, } -#[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool } -#[derive(Serialize)] pub struct RegisterResponse { - token: Option, nickname: String, email: String, expires_at: Option, confirmation_required: bool, message: String +#[derive(Deserialize)] +pub struct RemoveShareRequest { + kind: String, + slug: String, + email: String, +} +#[derive(Deserialize)] +pub struct CreateShareLinkRequest { + kind: String, + slug: String, + permission: String, + expires_at: Option, +} +#[derive(Deserialize)] +pub struct UpdateShareLinkRequest { + kind: String, + slug: String, + token: String, + permission: String, + expires_at: Option, +} +#[derive(Deserialize)] +pub struct RevokeShareLinkRequest { + kind: String, + slug: String, + token: String, } -pub async fn identity(State(state): State, Json(req): Json) -> Result, AuthError> { +#[derive(Serialize)] +pub struct ResourceList { + workspaces: Vec, + pads: Vec, +} +#[derive(Serialize)] +pub struct SessionResponse { + token: String, + nickname: String, + email: String, + expires_at: String, +} +#[derive(Serialize)] +pub struct IdentityResponse { + nickname: String, + registered: bool, +} +#[derive(Serialize)] +pub struct RegisterResponse { + token: Option, + nickname: String, + email: String, + expires_at: Option, + confirmation_required: bool, + message: String, +} + +pub async fn identity( + State(state): State, + Json(req): Json, +) -> Result, AuthError> { let nickname = validate_nickname(&req.nickname)?; debug!(nickname = %nickname, has_session = req.session_token.is_some(), "identity check requested"); match find_user_by_nickname(&state, &nickname).await? { - None => { debug!(nickname = %nickname, "nickname is available for guest use"); Ok(Json(IdentityResponse { nickname, registered: false })) }, + None => { + debug!(nickname = %nickname, "nickname is available for guest use"); + Ok(Json(IdentityResponse { + nickname, + registered: false, + })) + } Some(user) => { - let token = req.session_token.as_deref().ok_or_else(|| AuthError::unauthorized("This nickname is registered. Log in to use it."))?; - let current = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired. Log in again."))?; - if current.id != user.id { return Err(AuthError::unauthorized("This nickname belongs to another account.")); } + let token = req.session_token.as_deref().ok_or_else(|| { + AuthError::unauthorized("This nickname is registered. Log in to use it.") + })?; + let current = user_from_token(&state, token).await?.ok_or_else(|| { + AuthError::unauthorized("Your session has expired. Log in again.") + })?; + if current.id != user.id { + return Err(AuthError::unauthorized( + "This nickname belongs to another account.", + )); + } info!(user_id = user.id, nickname = %user.nickname, "registered identity authorized"); - Ok(Json(IdentityResponse { nickname: user.nickname, registered: true })) + Ok(Json(IdentityResponse { + nickname: user.nickname, + registered: true, + })) } } } -pub async fn register(State(state): State, Json(req): Json) -> Result<(StatusCode, Json), AuthError> { - if !state.registration_enabled { warn!("registration attempt rejected because registration is disabled"); return Err(AuthError::forbidden("Registration is disabled.")); } - if state.account_confirmation_required && state.smtp.is_none() { return Err(AuthError::service_unavailable("Account confirmation requires SMTP configuration.")); } +pub async fn register( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), AuthError> { + if !state.registration_enabled { + warn!("registration attempt rejected because registration is disabled"); + return Err(AuthError::forbidden("Registration is disabled.")); + } + if state.account_confirmation_required && state.smtp.is_none() { + return Err(AuthError::service_unavailable( + "Account confirmation requires SMTP configuration.", + )); + } let nickname = validate_nickname(&req.nickname)?; let email = validate_email(&req.email)?; info!(nickname = %nickname, email_domain = %email_domain(&email), "registration requested"); validate_password(&req.password)?; let nickname_key = normalize(&nickname); let email_key = normalize(&email); - if find_user_by_nickname(&state, &nickname).await?.is_some() { return Err(AuthError::conflict("This nickname is already registered.")); } - if find_user_by_email(&state, &email).await?.is_some() { return Err(AuthError::conflict("This e-mail address is already registered.")); } + if find_user_by_nickname(&state, &nickname).await?.is_some() { + return Err(AuthError::conflict("This nickname is already registered.")); + } + if find_user_by_email(&state, &email).await?.is_some() { + return Err(AuthError::conflict( + "This e-mail address is already registered.", + )); + } let hash = hash_password(&req.password)?; let confirmed_at = (!state.account_confirmation_required).then(|| Utc::now().to_rfc3339()); sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_USER)) - .bind(&nickname).bind(nickname_key).bind(&email).bind(email_key).bind(hash).bind(confirmed_at).execute(state.db.pool()).await + .bind(&nickname) + .bind(nickname_key) + .bind(&email) + .bind(email_key) + .bind(hash) + .bind(confirmed_at) + .execute(state.db.pool()) + .await .map_err(AuthError::database)?; - let user = find_user_by_nickname(&state, &nickname).await?.ok_or_else(|| AuthError::internal("Failed to create the account."))?; + let user = find_user_by_nickname(&state, &nickname) + .await? + .ok_or_else(|| AuthError::internal("Failed to create the account."))?; let mut confirmation_token = None; if state.smtp.is_some() { let token = random_confirmation_token(); if state.account_confirmation_required { let expires = (Utc::now() + Duration::hours(24)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_CONFIRMATION_TOKEN)) - .bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_INSERT_CONFIRMATION_TOKEN, + )) + .bind(hash_token(&token)) + .bind(user.id) + .bind(expires) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; confirmation_token = Some(token.as_str()); } - if let Err(error) = send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await { + if let Err(error) = + send_registration_email(state.smtp.as_ref().unwrap(), &user, confirmation_token).await + { if state.account_confirmation_required { - if let Err(delete_error) = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)).bind(user.id).execute(state.db.pool()).await { + if let Err(delete_error) = + sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_USER)) + .bind(user.id) + .execute(state.db.pool()) + .await + { tracing::error!(error=%delete_error, user_id=user.id, "failed to roll back account after confirmation e-mail error"); } return Err(error); } - warn!(user_id = user.id, "account created, but registration e-mail could not be sent"); + warn!( + user_id = user.id, + "account created, but registration e-mail could not be sent" + ); } } else { - warn!(user_id = user.id, "account created without registration e-mail because SMTP is not configured"); + warn!( + user_id = user.id, + "account created without registration e-mail because SMTP is not configured" + ); } if state.account_confirmation_required { info!(user_id = user.id, nickname = %user.nickname, "account registered; confirmation required"); - return Ok((StatusCode::CREATED, Json(RegisterResponse { - token: None, nickname: user.nickname, email: user.email, expires_at: None, - confirmation_required: true, - message: "Account created. Check your e-mail and confirm the account before logging in.".into(), - }))); + return Ok(( + StatusCode::CREATED, + Json(RegisterResponse { + token: None, + nickname: user.nickname, + email: user.email, + expires_at: None, + confirmation_required: true, + message: + "Account created. Check your e-mail and confirm the account before logging in." + .into(), + }), + )); } let session = create_session(&state, &user).await?; info!(user_id = user.id, nickname = %user.nickname, "account registered and session created"); - Ok((StatusCode::CREATED, Json(RegisterResponse { - token: Some(session.token), nickname: session.nickname, email: session.email, expires_at: Some(session.expires_at), - confirmation_required: false, message: "Account created.".into(), - }))) + Ok(( + StatusCode::CREATED, + Json(RegisterResponse { + token: Some(session.token), + nickname: session.nickname, + email: session.email, + expires_at: Some(session.expires_at), + confirmation_required: false, + message: "Account created.".into(), + }), + )) } -pub async fn login(State(state): State, Json(req): Json) -> Result, AuthError> { +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result, AuthError> { let email = validate_email(&req.email)?; debug!(email_domain = %email_domain(&email), "login requested"); - let user = find_user_by_email(&state, &email).await?.ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?; - if !verify_password(&user.password_hash, &req.password) { warn!(user_id = user.id, "login rejected: invalid password"); return Err(AuthError::unauthorized("Invalid e-mail address or password.")); } - if state.account_confirmation_required && user.confirmed_at.is_none() { return Err(AuthError::forbidden("Confirm the account using the link sent by e-mail before logging in.")); } + let user = find_user_by_email(&state, &email) + .await? + .ok_or_else(|| AuthError::unauthorized("Invalid e-mail address or password."))?; + if !verify_password(&user.password_hash, &req.password) { + warn!(user_id = user.id, "login rejected: invalid password"); + return Err(AuthError::unauthorized( + "Invalid e-mail address or password.", + )); + } + if state.account_confirmation_required && user.confirmed_at.is_none() { + return Err(AuthError::forbidden( + "Confirm the account using the link sent by e-mail before logging in.", + )); + } let session = create_session(&state, &user).await?; info!(user_id = user.id, nickname = %user.nickname, "login successful"); Ok(Json(session)) } -pub async fn confirm_account(State(state): State, Json(req): Json) -> Result, AuthError> { +pub async fn confirm_account( + State(state): State, + Json(req): Json, +) -> Result, AuthError> { let now_time = Utc::now(); let now = now_time.to_rfc3339(); let token_hash = hash_token(req.token.trim()); - let row: Option<(i64, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_CONFIRMATION_TOKEN)) - .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; - let (user_id, expires_at, used_at) = row.ok_or_else(|| AuthError::bad_request("The confirmation link is invalid or has expired."))?; - let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))?.with_timezone(&Utc); - if used_at.is_some() || expires_at <= now_time { return Err(AuthError::bad_request("The confirmation link is invalid or has expired.")); } + let row: Option<(i64, String, Option)> = sqlx::query_as(queries::get( + state.db.kind(), + queries::AUTH_FIND_CONFIRMATION_TOKEN, + )) + .bind(&token_hash) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; + let (user_id, expires_at, used_at) = row.ok_or_else(|| { + AuthError::bad_request("The confirmation link is invalid or has expired.") + })?; + let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at) + .map_err(|_| AuthError::bad_request("The confirmation link is invalid or has expired."))? + .with_timezone(&Utc); + if used_at.is_some() || expires_at <= now_time { + return Err(AuthError::bad_request( + "The confirmation link is invalid or has expired.", + )); + } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)).bind(&now).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_CONFIRMATION_TOKEN_USED)).bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), queries::AUTH_CONFIRM_USER)) + .bind(&now) + .bind(&now) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_MARK_CONFIRMATION_TOKEN_USED, + )) + .bind(&now) + .bind(&token_hash) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; info!(user_id, "account confirmed"); - Ok(Json(serde_json::json!({"ok": true, "message": "Account confirmed. You can now log in."}))) + Ok(Json( + serde_json::json!({"ok": true, "message": "Account confirmed. You can now log in."}), + )) } -pub async fn me(State(state): State, headers: HeaderMap) -> Result, AuthError> { +pub async fn me( + State(state): State, + headers: HeaderMap, +) -> Result, AuthError> { let token = bearer(&headers).ok_or_else(|| AuthError::unauthorized("Not logged in."))?; - let user = user_from_token(&state, token).await?.ok_or_else(|| AuthError::unauthorized("Your session has expired."))?; + let user = user_from_token(&state, token) + .await? + .ok_or_else(|| AuthError::unauthorized("Your session has expired."))?; debug!(user_id = user.id, "session validation successful"); - let expires_at: String = sqlx::query_scalar(queries::get(state.db.kind(), queries::AUTH_SESSION_EXPIRES_AT)) - .bind(token).fetch_one(state.db.pool()).await.map_err(AuthError::database)?; - Ok(Json(SessionResponse { token: token.into(), nickname: user.nickname, email: user.email, expires_at })) + let expires_at: String = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_SESSION_EXPIRES_AT, + )) + .bind(token) + .fetch_one(state.db.pool()) + .await + .map_err(AuthError::database)?; + Ok(Json(SessionResponse { + token: token.into(), + nickname: user.nickname, + email: user.email, + expires_at, + })) } -pub async fn resources(State(state): State, headers: HeaderMap) -> Result, AuthError> { +pub async fn resources( + State(state): State, + headers: HeaderMap, +) -> Result, AuthError> { let user = require_user(&state, &headers).await?; - let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_WORKSPACES)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - let pads = sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)).bind(user.id).bind(user.id).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; + let workspaces = sqlx::query_as::<_, ResourceItem>(queries::get( + state.db.kind(), + queries::USER_LIST_WORKSPACES, + )) + .bind(user.id) + .bind(user.id) + .fetch_all(state.db.pool()) + .await + .map_err(AuthError::database)?; + let pads = + sqlx::query_as::<_, ResourceItem>(queries::get(state.db.kind(), queries::USER_LIST_PADS)) + .bind(user.id) + .bind(user.id) + .fetch_all(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json(ResourceList { workspaces, pads })) } -pub async fn update_resource(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn update_resource( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let user = require_user(&state, &headers).await?; - let hash = match req.password.as_deref().map(str::trim).filter(|v| !v.is_empty()) { Some(v) => { validate_password(v)?; Some(hash_password(v)?) }, None => None }; + let hash = match req + .password + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + { + Some(v) => { + validate_password(v)?; + Some(hash_password(v)?) + } + None => None, + }; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; - let query = match req.kind.as_str() { "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, "pad" => queries::USER_SET_PAD_PASSWORD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; - sqlx::query(queries::get(state.db.kind(), query)).bind(hash).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?")) - .bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + let query = match req.kind.as_str() { + "workspace" => queries::USER_SET_WORKSPACE_PASSWORD, + "pad" => queries::USER_SET_PAD_PASSWORD, + _ => return Err(AuthError::bad_request("Unknown resource type.")), + }; + sqlx::query(queries::get(state.db.kind(), query)) + .bind(hash) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?", + )) + .bind(req.kind.as_str()) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } -pub async fn delete_resource(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn delete_resource( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; - let query = match req.kind.as_str() { "workspace" => queries::USER_DELETE_WORKSPACE, "pad" => queries::USER_DELETE_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?")) - .bind(req.kind.as_str()).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), query)).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + let query = match req.kind.as_str() { + "workspace" => queries::USER_DELETE_WORKSPACE, + "pad" => queries::USER_DELETE_PAD, + _ => return Err(AuthError::bad_request("Unknown resource type.")), + }; + sqlx::query(queries::get( + state.db.kind(), + "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?", + )) + .bind(req.kind.as_str()) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get(state.db.kind(), query)) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } -pub async fn optional_user(state: &SharedState, headers: &HeaderMap) -> Result, AuthError> { - match bearer(headers) { Some(token) => user_from_token(state, token).await, None => Ok(None) } +pub async fn optional_user( + state: &SharedState, + headers: &HeaderMap, +) -> Result, AuthError> { + match bearer(headers) { + Some(token) => user_from_token(state, token).await, + None => Ok(None), + } } async fn require_user(state: &SharedState, headers: &HeaderMap) -> Result { - optional_user(state, headers).await?.ok_or_else(|| AuthError::unauthorized("Log in first.")) + optional_user(state, headers) + .await? + .ok_or_else(|| AuthError::unauthorized("Log in first.")) } -async fn ensure_owner(state: &SharedState, user_id: i64, kind: &str, slug: &str) -> Result<(), AuthError> { - let query = match kind { "workspace" => queries::USER_OWNS_WORKSPACE, "pad" => queries::USER_OWNS_PAD, _ => return Err(AuthError::bad_request("Unknown resource type.")) }; - let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)).bind(user_id).bind(slug.trim()).fetch_one(state.db.pool()).await.map_err(AuthError::database)?; - if count == 0 { return Err(AuthError::forbidden("This item does not belong to your account.")); } +async fn ensure_owner( + state: &SharedState, + user_id: i64, + kind: &str, + slug: &str, +) -> Result<(), AuthError> { + let query = match kind { + "workspace" => queries::USER_OWNS_WORKSPACE, + "pad" => queries::USER_OWNS_PAD, + _ => return Err(AuthError::bad_request("Unknown resource type.")), + }; + let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query)) + .bind(user_id) + .bind(slug.trim()) + .fetch_one(state.db.pool()) + .await + .map_err(AuthError::database)?; + if count == 0 { + return Err(AuthError::forbidden( + "This item does not belong to your account.", + )); + } Ok(()) } - -pub async fn set_resource_privacy(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn set_resource_privacy( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; - let table = match req.kind.as_str() { "workspace" => "workspaces", "pad" => "pads", _ => return Err(AuthError::bad_request("Unknown resource type.")) }; - let query = format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"); - sqlx::query(&query).bind(req.private).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; + let table = match req.kind.as_str() { + "workspace" => "workspaces", + "pad" => "pads", + _ => return Err(AuthError::bad_request("Unknown resource type.")), + }; + let query = + format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"); + sqlx::query(&query) + .bind(req.private) + .bind(req.slug.trim()) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } -pub async fn share_resource_users(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn share_resource_users( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; if state.share_confirmation_required && state.smtp.is_none() { - return Err(AuthError::service_unavailable("Share confirmation requires SMTP configuration.")); + return Err(AuthError::service_unavailable( + "Share confirmation requires SMTP configuration.", + )); + } + let emails: Vec = req + .emails + .split(',') + .map(|v| normalize(v)) + .filter(|v| !v.is_empty()) + .collect(); + if emails.is_empty() || emails.len() > 100 { + return Err(AuthError::bad_request( + "Enter between 1 and 100 registered e-mail addresses.", + )); } - let emails: Vec = req.emails.split(',').map(|v| normalize(v)).filter(|v| !v.is_empty()).collect(); - if emails.is_empty() || emails.len() > 100 { return Err(AuthError::bad_request("Enter between 1 and 100 registered e-mail addresses.")); } let mut missing = Vec::new(); for email in emails { let user = find_user_by_email(&state, &email).await?; - let Some(user) = user else { missing.push(email); continue; }; - if user.id == owner.id { continue; } + let Some(user) = user else { + missing.push(email); + continue; + }; + if user.id == owner.id { + continue; + } sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; @@ -249,9 +616,24 @@ pub async fn share_resource_users(State(state): State, headers: Hea sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)")) .bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at) .execute(state.db.pool()).await.map_err(AuthError::database)?; - if let Err(error) = send_share_invitation(state.smtp.as_ref().unwrap(), &owner, &user, &req.kind, req.slug.trim(), permission, &token).await { - let _ = sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE token_hash = ?")) - .bind(&token_hash).execute(state.db.pool()).await; + if let Err(error) = send_share_invitation( + state.smtp.as_ref().unwrap(), + &owner, + &user, + &req.kind, + req.slug.trim(), + permission, + &token, + ) + .await + { + let _ = sqlx::query(queries::get( + state.db.kind(), + "DELETE FROM resource_share_invitations WHERE token_hash = ?", + )) + .bind(&token_hash) + .execute(state.db.pool()) + .await; return Err(error); } } else { @@ -259,32 +641,65 @@ pub async fn share_resource_users(State(state): State, headers: Hea .bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; } } - if !missing.is_empty() { return Err(AuthError::bad_request(&format!("No registered account for: {}", missing.join(", ")))); } - Ok(Json(serde_json::json!({"ok":true,"confirmation_required":state.share_confirmation_required}))) + if !missing.is_empty() { + return Err(AuthError::bad_request(&format!( + "No registered account for: {}", + missing.join(", ") + ))); + } + Ok(Json( + serde_json::json!({"ok":true,"confirmation_required":state.share_confirmation_required}), + )) } -pub async fn accept_share_invitation(State(state): State, AxumPath(token): AxumPath) -> Result { +pub async fn accept_share_invitation( + State(state): State, + AxumPath(token): AxumPath, +) -> Result { let token_hash = hash_token(token.trim()); let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?")) .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; - let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| AuthError::bad_request("The sharing invitation is invalid or has expired."))?; - let expires = chrono::DateTime::parse_from_rfc3339(&expires_at).map_err(|_| AuthError::bad_request("The sharing invitation is invalid or has expired."))?.with_timezone(&Utc); + let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| { + AuthError::bad_request("The sharing invitation is invalid or has expired.") + })?; + let expires = chrono::DateTime::parse_from_rfc3339(&expires_at) + .map_err(|_| AuthError::bad_request("The sharing invitation is invalid or has expired."))? + .with_timezone(&Utc); if accepted_at.is_none() { - if expires <= Utc::now() { return Err(AuthError::bad_request("The sharing invitation is invalid or has expired.")); } + if expires <= Utc::now() { + return Err(AuthError::bad_request( + "The sharing invitation is invalid or has expired.", + )); + } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) .bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?")) - .bind(Utc::now().to_rfc3339()).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?", + )) + .bind(Utc::now().to_rfc3339()) + .bind(&token_hash) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; } - let target = if kind == "workspace" { format!("/w/{slug}") } else { format!("/p/{slug}") }; + let target = if kind == "workspace" { + format!("/w/{slug}") + } else { + format!("/p/{slug}") + }; Ok(Redirect::to(&target)) } -pub async fn remove_resource_user(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn remove_resource_user( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let email = normalize(&req.email); @@ -297,10 +712,18 @@ pub async fn remove_resource_user(State(state): State, headers: Hea Ok(Json(serde_json::json!({"ok":true}))) } -pub async fn resource_sharing(State(state): State, headers: HeaderMap, axum::extract::Query(params): axum::extract::Query>) -> Result, AuthError> { +pub async fn resource_sharing( + State(state): State, + headers: HeaderMap, + axum::extract::Query(params): axum::extract::Query>, +) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; - let kind = params.get("kind").ok_or_else(|| AuthError::bad_request("Missing kind."))?; - let slug = params.get("slug").ok_or_else(|| AuthError::bad_request("Missing slug."))?; + let kind = params + .get("kind") + .ok_or_else(|| AuthError::bad_request("Missing kind."))?; + let slug = params + .get("slug") + .ok_or_else(|| AuthError::bad_request("Missing slug."))?; ensure_owner(&state, owner.id, kind, slug).await?; let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email")) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; @@ -308,34 +731,60 @@ pub async fn resource_sharing(State(state): State, headers: HeaderM .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email")) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - Ok(Json(serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }))) + Ok(Json( + serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }), + )) } -pub async fn create_share_link(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn create_share_link( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; validate_share_expiration(req.expires_at.as_deref())?; - let token = random_token(); let token_hash = hash_token(&token); + let token = random_token(); + let token_hash = hash_token(&token); sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)")) .bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) } else { format!("/p/{}", req.slug.trim()) }; - Ok(Json(serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}))) + let base = if req.kind == "workspace" { + format!("/w/{}", req.slug.trim()) + } else { + format!("/p/{}", req.slug.trim()) + }; + Ok(Json( + serde_json::json!({"token":token,"url":format!("{base}?share={token}"),"permission":permission,"expires_at":req.expires_at}), + )) } - -pub async fn update_share_link(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn update_share_link( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; validate_share_expiration(req.expires_at.as_deref())?; let result = sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL")) .bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; - if result.rows_affected() == 0 { return Err(AuthError::bad_request("Share link was not found or is already revoked.")); } - Ok(Json(serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}))) + if result.rows_affected() == 0 { + return Err(AuthError::bad_request( + "Share link was not found or is already revoked.", + )); + } + Ok(Json( + serde_json::json!({"ok":true,"permission":permission,"expires_at":req.expires_at}), + )) } -pub async fn revoke_share_link(State(state): State, headers: HeaderMap, Json(req): Json) -> Result, AuthError> { +pub async fn revoke_share_link( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?")) @@ -343,20 +792,40 @@ pub async fn revoke_share_link(State(state): State, headers: Header Ok(Json(serde_json::json!({"ok":true}))) } -fn validate_permission(value: &str) -> Result<&str, AuthError> { match value { "ro"|"rw" => Ok(value), _ => Err(AuthError::bad_request("Permission must be ro or rw.")) } } +fn validate_permission(value: &str) -> Result<&str, AuthError> { + match value { + "ro" | "rw" => Ok(value), + _ => Err(AuthError::bad_request("Permission must be ro or rw.")), + } +} fn validate_share_expiration(value: Option<&str>) -> Result<(), AuthError> { - let Some(value) = value else { return Ok(()); }; - let expires = chrono::DateTime::parse_from_rfc3339(value).map_err(|_| AuthError::bad_request("Invalid expiration date."))?.with_timezone(&Utc); - if expires <= Utc::now() { return Err(AuthError::bad_request("Expiration must be in the future.")); } + let Some(value) = value else { + return Ok(()); + }; + let expires = chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| AuthError::bad_request("Invalid expiration date."))? + .with_timezone(&Utc); + if expires <= Utc::now() { + return Err(AuthError::bad_request("Expiration must be in the future.")); + } Ok(()) } -pub async fn resource_permission(state: &SharedState, kind: &str, slug: &str, token: Option<&str>) -> Result, AuthError> { - let Some(token) = token.filter(|v| !v.is_empty()) else { return Ok(None); }; +pub async fn resource_permission( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result, AuthError> { + let Some(token) = token.filter(|v| !v.is_empty()) else { + return Ok(None); + }; if let Some(user) = user_from_token(state, token).await? { let owns = ensure_owner(state, user.id, kind, slug).await.is_ok(); - if owns { return Ok(Some("rw".into())); } + if owns { + return Ok(Some("rw".into())); + } let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) .bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; return Ok(permission); @@ -367,78 +836,171 @@ pub async fn resource_permission(state: &SharedState, kind: &str, slug: &str, to Ok(permission) } -pub async fn logout(State(state): State, headers: HeaderMap) -> Result, AuthError> { +pub async fn logout( + State(state): State, + headers: HeaderMap, +) -> Result, AuthError> { if let Some(token) = bearer(&headers) { - let result = sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSION_BY_TOKEN)).bind(token).execute(state.db.pool()).await.map_err(AuthError::database)?; + let result = sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_SESSION_BY_TOKEN, + )) + .bind(token) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; info!(rows_affected = result.rows_affected(), "logout processed"); - } else { debug!("logout requested without an active session"); } + } else { + debug!("logout requested without an active session"); + } Ok(Json(serde_json::json!({"ok": true}))) } -pub async fn request_reset(State(state): State, Json(req): Json) -> Result, AuthError> { +pub async fn request_reset( + State(state): State, + Json(req): Json, +) -> Result, AuthError> { let email = validate_email(&req.email)?; info!(email_domain = %email_domain(&email), "password reset requested"); - let smtp = state.smtp.as_ref().ok_or_else(|| AuthError::service_unavailable("Password reset is not configured on this server."))?; + let smtp = state.smtp.as_ref().ok_or_else(|| { + AuthError::service_unavailable("Password reset is not configured on this server.") + })?; if let Some(user) = find_user_by_email(&state, &email).await? { - let token = random_token(); - let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_RESET_TOKENS_BY_USER)).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::AUTH_INSERT_RESET_TOKEN)) - .bind(hash_token(&token)).bind(user.id).bind(expires).execute(state.db.pool()).await.map_err(AuthError::database)?; + let token = random_token(); + let expires = (Utc::now() + Duration::minutes(30)).to_rfc3339(); + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_RESET_TOKENS_BY_USER, + )) + .bind(user.id) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_INSERT_RESET_TOKEN, + )) + .bind(hash_token(&token)) + .bind(user.id) + .bind(expires) + .execute(state.db.pool()) + .await + .map_err(AuthError::database)?; send_reset(smtp, &user, &token).await?; info!(user_id = user.id, "password reset e-mail sent"); } else { debug!(email_domain = %email_domain(&email), "password reset requested for unknown account"); } - Ok(Json(serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."}))) + Ok(Json( + serde_json::json!({"ok": true, "message": "If the account exists, a reset link has been sent."}), + )) } -pub async fn confirm_reset(State(state): State, Json(req): Json) -> Result, AuthError> { +pub async fn confirm_reset( + State(state): State, + Json(req): Json, +) -> Result, AuthError> { validate_password(&req.password)?; info!("password reset confirmation requested"); let now_time = Utc::now(); let now = now_time.to_rfc3339(); let token_hash = hash_token(req.token.trim()); - let token_row: Option<(i64, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), queries::AUTH_FIND_RESET_TOKEN)) - .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; - let (user_id, expires_at, used_at) = token_row.ok_or_else(|| AuthError::bad_request("The reset link is invalid or has expired."))?; + let token_row: Option<(i64, String, Option)> = sqlx::query_as(queries::get( + state.db.kind(), + queries::AUTH_FIND_RESET_TOKEN, + )) + .bind(&token_hash) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database)?; + let (user_id, expires_at, used_at) = token_row + .ok_or_else(|| AuthError::bad_request("The reset link is invalid or has expired."))?; let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at) .map_err(|_| AuthError::bad_request("The reset link is invalid or has expired."))? .with_timezone(&Utc); if used_at.is_some() || expires_at <= now_time { - warn!(user_id, used = used_at.is_some(), expired = expires_at <= now_time, "password reset token rejected"); - return Err(AuthError::bad_request("The reset link is invalid or has expired.")); + warn!( + user_id, + used = used_at.is_some(), + expired = expires_at <= now_time, + "password reset token rejected" + ); + return Err(AuthError::bad_request( + "The reset link is invalid or has expired.", + )); } let password_hash = hash_password(&req.password)?; let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; let updated = sqlx::query(queries::get(state.db.kind(), queries::AUTH_UPDATE_PASSWORD)) - .bind(password_hash).bind(&now).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; - if updated.rows_affected() != 1 { return Err(AuthError::internal("The account could not be updated.")); } - sqlx::query(queries::get(state.db.kind(), queries::AUTH_MARK_RESET_TOKEN_USED)) - .bind(&now).bind(&token_hash).execute(&mut *tx).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), queries::AUTH_DELETE_SESSIONS_BY_USER)) - .bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; + .bind(password_hash) + .bind(&now) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + if updated.rows_affected() != 1 { + return Err(AuthError::internal("The account could not be updated.")); + } + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_MARK_RESET_TOKEN_USED, + )) + .bind(&now) + .bind(&token_hash) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; + sqlx::query(queries::get( + state.db.kind(), + queries::AUTH_DELETE_SESSIONS_BY_USER, + )) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(AuthError::database)?; tx.commit().await.map_err(AuthError::database)?; - info!(user_id, "password reset completed and existing sessions revoked"); + info!( + user_id, + "password reset completed and existing sessions revoked" + ); Ok(Json(serde_json::json!({"ok": true}))) } pub async fn user_from_token(state: &SharedState, token: &str) -> Result, AuthError> { let now = Utc::now().to_rfc3339(); sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_SESSION)) - .bind(token).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database) + .bind(token) + .bind(now) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database) } -pub async fn authorize_nickname(state: &SharedState, nickname: Option, token: Option) -> Result, String> { - let Some(nickname) = nickname else { return Ok(None); }; +pub async fn authorize_nickname( + state: &SharedState, + nickname: Option, + token: Option, +) -> Result, String> { + let Some(nickname) = nickname else { + return Ok(None); + }; let nickname = validate_nickname(&nickname).map_err(|e| e.message)?; - let registered = find_user_by_nickname(state, &nickname).await.map_err(|_| "Database error".to_string())?; + let registered = find_user_by_nickname(state, &nickname) + .await + .map_err(|_| "Database error".to_string())?; match registered { None => Ok(Some(nickname)), Some(owner) => { - let Some(token) = token else { return Err("This nickname is registered. Log in to use it.".into()); }; - let current = user_from_token(state, &token).await.map_err(|_| "Database error".to_string())?; - match current { Some(user) if user.id == owner.id => Ok(Some(owner.nickname)), _ => Err("This nickname belongs to another account or the session expired.".into()) } + let Some(token) = token else { + return Err("This nickname is registered. Log in to use it.".into()); + }; + let current = user_from_token(state, &token) + .await + .map_err(|_| "Database error".to_string())?; + match current { + Some(user) if user.id == owner.id => Ok(Some(owner.nickname)), + _ => Err("This nickname belongs to another account or the session expired.".into()), + } } } } @@ -447,36 +1009,58 @@ async fn create_session(state: &SharedState, user: &User) -> Result Result, AuthError> { - sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_NICKNAME)) - .bind(normalize(nickname)).fetch_optional(state.db.pool()).await.map_err(AuthError::database) +async fn find_user_by_nickname( + state: &SharedState, + nickname: &str, +) -> Result, AuthError> { + sqlx::query_as::<_, User>(queries::get( + state.db.kind(), + queries::AUTH_USER_BY_NICKNAME, + )) + .bind(normalize(nickname)) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database) } async fn find_user_by_email(state: &SharedState, email: &str) -> Result, AuthError> { sqlx::query_as::<_, User>(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL)) - .bind(normalize(email)).fetch_optional(state.db.pool()).await.map_err(AuthError::database) + .bind(normalize(email)) + .fetch_optional(state.db.pool()) + .await + .map_err(AuthError::database) } fn validate_nickname(v: &str) -> Result { - let v=v.trim(); - if v.is_empty() || v.chars().count()>MAX_NICKNAME { - return Err(AuthError::bad_request("Nickname must contain 1 to 40 characters.")); + let v = v.trim(); + if v.is_empty() || v.chars().count() > MAX_NICKNAME { + return Err(AuthError::bad_request( + "Nickname must contain 1 to 40 characters.", + )); } if v.chars().any(|c| c.is_control()) { - return Err(AuthError::bad_request("Nickname contains invalid characters.")); + return Err(AuthError::bad_request( + "Nickname contains invalid characters.", + )); } Ok(v.into()) } fn validate_email(value: &str) -> Result { let value = value.trim(); - if value.len() > 320 - || !value.contains('@') - || value.starts_with('@') - || value.ends_with('@') - { + if value.len() > 320 || !value.contains('@') || value.starts_with('@') || value.ends_with('@') { return Err(AuthError::bad_request("Enter a valid e-mail address.")); } @@ -666,31 +1250,58 @@ async fn send_share_invitation( ) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let accept_url = format!("{site}/share-invitations/{token}/accept"); - let resource_label = if kind == "workspace" { "workspace" } else { "note" }; - let access_label = if permission == "rw" { "view and edit" } else { "view" }; - let sender = smtp.from.parse::().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; - let recipient = recipient_user.email.parse::().map_err(|_| AuthError::internal("Invalid recipient address."))?; - let subject = format!("{} shared a RustPad {} with you", owner.nickname, resource_label); + let resource_label = if kind == "workspace" { + "workspace" + } else { + "note" + }; + let access_label = if permission == "rw" { + "view and edit" + } else { + "view" + }; + let sender = smtp + .from + .parse::() + .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; + let recipient = recipient_user + .email + .parse::() + .map_err(|_| AuthError::internal("Invalid recipient address."))?; + let subject = format!( + "{} shared a RustPad {} with you", + owner.nickname, resource_label + ); let text_body = format!( "Hello {},\n\n{} shared the {} '{}' with you ({access_label}).\nAccept the invitation within 7 days:\n{}\n\nIf you were not expecting this invitation, ignore this message.", recipient_user.nickname, owner.nickname, resource_label, slug, accept_url ); - let html_body = format!(r#"

A RustPad {resource_label} was shared with you

Hello {},

{} shared {} with you. Permission: {access_label}.

Accept invitation

This link expires in 7 days.

"#, - recipient_user.nickname, owner.nickname, slug, accept_url + let html_body = format!( + r#"

A RustPad {resource_label} was shared with you

Hello {},

{} shared {} with you. Permission: {access_label}.

Accept invitation

This link expires in 7 days.

If the button does not work, copy and paste this link into your browser:
{}

"#, + recipient_user.nickname, owner.nickname, slug, accept_url, accept_url, accept_url ); - let message = Message::builder().from(sender).to(recipient).subject(subject) - .multipart(MultiPart::alternative() - .singlepart(SinglePart::builder().header(ContentType::TEXT_PLAIN).body(text_body)) - .singlepart(SinglePart::builder().header(ContentType::TEXT_HTML).body(html_body))) + let message = Message::builder() + .from(sender) + .to(recipient) + .subject(subject) + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text_body), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body), + ), + ) .map_err(|_| AuthError::internal("Failed to build sharing invitation e-mail."))?; send_message(smtp, message, "sharing invitation e-mail").await } -async fn send_message( - smtp: &SmtpConfig, - message: Message, - label: &str, -) -> Result<(), AuthError> { +async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Result<(), AuthError> { let mut builder = if smtp.port == 465 { AsyncSmtpTransport::::relay(&smtp.host) } else { @@ -719,11 +1330,7 @@ async fn send_message( Ok(()) } -async fn send_reset( - smtp: &SmtpConfig, - user: &User, - token: &str, -) -> Result<(), AuthError> { +async fn send_reset(smtp: &SmtpConfig, user: &User, token: &str) -> Result<(), AuthError> { let site = smtp.public_url.trim_end_matches('/'); let reset_url = format!("{site}/?reset_token={token}"); let sender = smtp @@ -779,52 +1386,68 @@ async fn send_reset( send_message(smtp, message, "password reset e-mail").await } -pub struct AuthError { status: StatusCode, pub message: String } +pub struct AuthError { + status: StatusCode, + pub message: String, +} impl AuthError { - fn bad_request(m:&str)->Self { + fn bad_request(m: &str) -> Self { Self { - status:StatusCode::BAD_REQUEST,message:m.into() + status: StatusCode::BAD_REQUEST, + message: m.into(), } } - fn unauthorized(m:&str)->Self { + fn unauthorized(m: &str) -> Self { Self { - status:StatusCode::UNAUTHORIZED,message:m.into() + status: StatusCode::UNAUTHORIZED, + message: m.into(), } } - fn forbidden(m:&str)->Self { + fn forbidden(m: &str) -> Self { Self { - status:StatusCode::FORBIDDEN,message:m.into() + status: StatusCode::FORBIDDEN, + message: m.into(), } } - fn conflict(m:&str)->Self { + fn conflict(m: &str) -> Self { Self { - status:StatusCode::CONFLICT,message:m.into() + status: StatusCode::CONFLICT, + message: m.into(), } } - fn internal(m:&str)->Self { + fn internal(m: &str) -> Self { Self { - status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into() + status: StatusCode::INTERNAL_SERVER_ERROR, + message: m.into(), } } - fn service_unavailable(m:&str)->Self { + fn service_unavailable(m: &str) -> Self { Self { - status:StatusCode::SERVICE_UNAVAILABLE,message:m.into() + status: StatusCode::SERVICE_UNAVAILABLE, + message: m.into(), } } - fn database(e:sqlx::Error)->Self { + fn database(e: sqlx::Error) -> Self { tracing::error!(error=%e,"authentication database error"); Self::internal("Database error.") } } fn email_domain(email: &str) -> &str { - email.rsplit_once('@').map(|(_, domain)| domain).unwrap_or("invalid") + email + .rsplit_once('@') + .map(|(_, domain)| domain) + .unwrap_or("invalid") } impl axum::response::IntoResponse for AuthError { - fn into_response(self)->axum::response::Response { - (self.status,Json(serde_json::json!( { - "error":self.message - } - ))).into_response() + fn into_response(self) -> axum::response::Response { + ( + self.status, + Json(serde_json::json!( { + "error":self.message + } + )), + ) + .into_response() } } diff --git a/src/config.rs b/src/config.rs index dd725fc..f74ca1b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,16 +26,21 @@ impl Config { pub fn from_env() -> Result> { let host = env_var("APP_HOST", "127.0.0.1").parse()?; let port = env_var("APP_PORT", "3000").parse()?; - let database_max_connections = - env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?; + let database_max_connections = env_var("DATABASE_MAX_CONNECTIONS", "8").parse()?; - let upload_max_size_mb: usize = - env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?; - let anonymous_access_token_ttl_days = env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; + let upload_max_size_mb: usize = env_var("UPLOAD_MAX_SIZE_MB", "20").parse()?; + let anonymous_access_token_ttl_days = + env_positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; let user_session_ttl_days = env_positive_i64("USER_SESSION_TTL_DAYS", 30)?; let files_dir = env_var("FILES_DIR", "data/files"); - let storage = match env_var("STORAGE_DRIVER", "local").trim().to_ascii_lowercase().as_str() { - "local" => crate::storage::StorageConfig::Local { root: files_dir.clone().into() }, + let storage = match env_var("STORAGE_DRIVER", "local") + .trim() + .to_ascii_lowercase() + .as_str() + { + "local" => crate::storage::StorageConfig::Local { + root: files_dir.clone().into(), + }, "s3" => crate::storage::StorageConfig::S3 { endpoint: env::var("S3_ENDPOINT").ok(), region: env_var("S3_REGION", "us-east-1"), @@ -51,25 +56,28 @@ impl Config { return Err("UPLOAD_MAX_SIZE_MB must be greater than 0".into()); } - let smtp_host = std::env::var("SMTP_HOST").ok().filter(|v| !v.trim().is_empty()); + let smtp_host = std::env::var("SMTP_HOST") + .ok() + .filter(|v| !v.trim().is_empty()); let smtp = if let Some(host) = smtp_host { Some(crate::state::SmtpConfig { host, port: env_var("SMTP_PORT", "587").parse()?, username: std::env::var("SMTP_USERNAME").unwrap_or_default(), password: std::env::var("SMTP_PASSWORD").unwrap_or_default(), - from: std::env::var("SMTP_FROM").map_err(|_| "SMTP_FROM is required when SMTP_HOST is set")?, - public_url: std::env::var("PUBLIC_URL").map_err(|_| "PUBLIC_URL is required when SMTP_HOST is set")?, + from: std::env::var("SMTP_FROM") + .map_err(|_| "SMTP_FROM is required when SMTP_HOST is set")?, + public_url: std::env::var("PUBLIC_URL") + .map_err(|_| "PUBLIC_URL is required when SMTP_HOST is set")?, }) - } else { None }; + } else { + None + }; Ok(Self { host, port, - database_url: env_var( - "DATABASE_URL", - "sqlite:///data/db/rustpad.db?mode=rwc", - ), + database_url: env_var("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"), database_max_connections, static_dir: env_var("STATIC_DIR", "static"), files_dir, @@ -128,6 +136,8 @@ fn env_nonnegative_u64(name: &str, default: u64) -> Result Result> { let value = env::var(name).map_err(|_| format!("{name} is required when STORAGE_DRIVER=s3"))?; - if value.trim().is_empty() { return Err(format!("{name} cannot be empty when STORAGE_DRIVER=s3").into()); } + if value.trim().is_empty() { + return Err(format!("{name} cannot be empty when STORAGE_DRIVER=s3").into()); + } Ok(value) } diff --git a/src/database.rs b/src/database.rs index 691eb3d..53ad689 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,5 +1,5 @@ use crate::queries; -use sqlx::{any::AnyPoolOptions, AnyPool}; +use sqlx::{AnyPool, any::AnyPoolOptions}; use tracing::{debug, info}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,9 +26,15 @@ impl Database { .await?; if kind == DatabaseKind::Sqlite { debug!("applying SQLite connection pragmas"); - sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON).execute(&pool).await?; - sqlx::query(queries::SQLITE_JOURNAL_WAL).execute(&pool).await?; - sqlx::query(queries::SQLITE_BUSY_TIMEOUT).execute(&pool).await?; + sqlx::query(queries::SQLITE_FOREIGN_KEYS_ON) + .execute(&pool) + .await?; + sqlx::query(queries::SQLITE_JOURNAL_WAL) + .execute(&pool) + .await?; + sqlx::query(queries::SQLITE_BUSY_TIMEOUT) + .execute(&pool) + .await?; } info!(?kind, max_connections, "database pool ready"); Ok(Self { pool, kind }) @@ -44,9 +50,16 @@ impl Database { impl DatabaseKind { fn from_url(url: &str) -> Result { - if url.starts_with("sqlite:") { Ok(Self::Sqlite) } - else if url.starts_with("postgres:") || url.starts_with("postgresql:") { Ok(Self::Postgres) } - else if url.starts_with("mysql:") { Ok(Self::MySql) } - else { Err(sqlx::Error::Configuration("DATABASE_URL must use sqlite://, postgres:// or mysql://".into())) } + if url.starts_with("sqlite:") { + Ok(Self::Sqlite) + } else if url.starts_with("postgres:") || url.starts_with("postgresql:") { + Ok(Self::Postgres) + } else if url.starts_with("mysql:") { + Ok(Self::MySql) + } else { + Err(sqlx::Error::Configuration( + "DATABASE_URL must use sqlite://, postgres:// or mysql://".into(), + )) + } } } diff --git a/src/db.rs b/src/db.rs index 63453e3..839c032 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,15 +1,19 @@ -use argon2::{ - password_hash::SaltString, Argon2, PasswordHash, PasswordHasher, PasswordVerifier, +use crate::{ + database::{Database, DatabaseKind}, + queries, }; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier, password_hash::SaltString}; use chrono::{DateTime, NaiveDateTime, Utc}; use rand_core::{OsRng, RngCore}; use serde::Serialize; use sqlx::FromRow; -use crate::{database::{Database, DatabaseKind}, queries}; use sqlx::{Any, Transaction}; - -async fn inserted_id(kind: DatabaseKind, tx: &mut Transaction<'_, Any>, table: &str) -> Result { +async fn inserted_id( + kind: DatabaseKind, + tx: &mut Transaction<'_, Any>, + table: &str, +) -> Result { let query = match kind { DatabaseKind::Sqlite => queries::SQLITE_LAST_INSERT_ID, DatabaseKind::MySql => queries::MYSQL_LAST_INSERT_ID, @@ -92,9 +96,9 @@ pub struct Revision { pub async fn find_workspace(pool: &Database, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) - .bind(slug) - .fetch_optional(pool.pool()) - .await + .bind(slug) + .fetch_optional(pool.pool()) + .await } pub async fn create_workspace( @@ -103,22 +107,27 @@ pub async fn create_workspace( title: &str, password: Option<&str>, ) -> Result { - let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); + let password_hash = password + .filter(|value| !value.is_empty()) + .map(hash_password); sqlx::query(queries::get(pool.kind(), queries::Q002)) - .bind(slug) - .bind(title) - .bind(password_hash) - .execute(pool.pool()) - .await?; + .bind(slug) + .bind(title) + .bind(password_hash) + .execute(pool.pool()) + .await?; sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) - .bind(slug) - .fetch_one(pool.pool()) - .await + .bind(slug) + .fetch_one(pool.pool()) + .await } pub fn verify_workspace_password(workspace: &Workspace, password: Option<&str>) -> bool { - match (&workspace.password_hash, password.filter(|value| !value.is_empty())) { + match ( + &workspace.password_hash, + password.filter(|value| !value.is_empty()), + ) { (None, _) => true, (Some(hash), Some(password)) => PasswordHash::new(hash) .ok() @@ -177,13 +186,13 @@ pub async fn create_note( created_by: Option<&str>, ) -> Result { sqlx::query(queries::get(pool.kind(), queries::Q005)) - .bind(workspace_id) - .bind(slug) - .bind(title) - .bind(protected) - .bind(created_by) - .execute(pool.pool()) - .await?; + .bind(workspace_id) + .bind(slug) + .bind(title) + .bind(protected) + .bind(created_by) + .execute(pool.pool()) + .await?; find_note(pool, workspace_id, slug) .await? @@ -227,9 +236,9 @@ pub async fn save_revision( pub async fn list_revisions(pool: &Database, note_id: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q010)) - .bind(note_id) - .fetch_all(pool.pool()) - .await + .bind(note_id) + .fetch_all(pool.pool()) + .await } pub fn random_suffix(length: usize) -> String { @@ -265,7 +274,9 @@ pub fn normalize_timestamp(value: &str) -> String { let offset_start = postgres.len() - 3; let offset = &postgres[offset_start..]; if (offset.starts_with('+') || offset.starts_with('-')) - && offset[1..].chars().all(|character| character.is_ascii_digit()) + && offset[1..] + .chars() + .all(|character| character.is_ascii_digit()) { postgres.push_str(":00"); } @@ -305,9 +316,9 @@ pub struct Pad { pub async fn find_pad(pool: &Database, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) - .bind(slug) - .fetch_optional(pool.pool()) - .await + .bind(slug) + .fetch_optional(pool.pool()) + .await } pub async fn create_pad( @@ -316,22 +327,27 @@ pub async fn create_pad( title: &str, password: Option<&str>, ) -> Result { - let password_hash = password.filter(|value| !value.is_empty()).map(hash_password); + let password_hash = password + .filter(|value| !value.is_empty()) + .map(hash_password); sqlx::query(queries::get(pool.kind(), queries::Q012)) - .bind(slug) - .bind(title) - .bind(password_hash) - .execute(pool.pool()) - .await?; + .bind(slug) + .bind(title) + .bind(password_hash) + .execute(pool.pool()) + .await?; sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) - .bind(slug) - .fetch_one(pool.pool()) - .await + .bind(slug) + .fetch_one(pool.pool()) + .await } pub fn verify_pad_password(pad: &Pad, password: Option<&str>) -> bool { - match (&pad.password_hash, password.filter(|value| !value.is_empty())) { + match ( + &pad.password_hash, + password.filter(|value| !value.is_empty()), + ) { (None, _) => true, (Some(hash), Some(password)) => PasswordHash::new(hash) .ok() @@ -380,9 +396,9 @@ pub async fn list_pad_revisions( pad_id: i64, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) - .bind(pad_id) - .fetch_all(pool.pool()) - .await + .bind(pad_id) + .fetch_all(pool.pool()) + .await } #[derive(Debug, Clone, Serialize)] @@ -407,7 +423,6 @@ struct PublishedPageRow { updated_at: String, } - #[derive(Debug, Clone, FromRow)] struct PostgresPublishedPageRow { token: String, @@ -421,7 +436,15 @@ struct PostgresPublishedPageRow { impl From for PublishedPage { fn from(value: PostgresPublishedPageRow) -> Self { - Self { token: value.token, pad_id: value.pad_id, note_id: value.note_id, allow_task_updates: value.allow_task_updates, title: value.title, content: value.content, updated_at: value.updated_at } + Self { + token: value.token, + pad_id: value.pad_id, + note_id: value.note_id, + allow_task_updates: value.allow_task_updates, + title: value.title, + content: value.content, + updated_at: value.updated_at, + } } } impl From for PublishedPage { @@ -472,78 +495,151 @@ pub async fn publish_note(pool: &Database, note_id: i64) -> Result Result, sqlx::Error> { +pub async fn find_published_page( + pool: &Database, + token: &str, +) -> Result, sqlx::Error> { if pool.kind() == DatabaseKind::Postgres { - return Ok(sqlx::query_as::<_, PostgresPublishedPageRow>(queries::Q021_POSTGRES) - .bind(token).fetch_optional(pool.pool()).await?.map(Into::into)); + return Ok( + sqlx::query_as::<_, PostgresPublishedPageRow>(queries::Q021_POSTGRES) + .bind(token) + .fetch_optional(pool.pool()) + .await? + .map(Into::into), + ); } - Ok(sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021)) - .bind(token).fetch_optional(pool.pool()).await?.map(Into::into)) + Ok( + sqlx::query_as::<_, PublishedPageRow>(queries::get(pool.kind(), queries::Q021)) + .bind(token) + .fetch_optional(pool.pool()) + .await? + .map(Into::into), + ) } pub async fn pad_public_task_updates(pool: &Database, pad_id: i64) -> Result { if pool.kind() == DatabaseKind::Postgres { return Ok(sqlx::query_scalar::<_, bool>(queries::Q044_POSTGRES) - .bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(false)); } let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q044)) - .bind(pad_id).fetch_optional(pool.pool()).await?.unwrap_or(0); + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(0); Ok(value != 0) } pub async fn note_public_task_updates(pool: &Database, note_id: i64) -> Result { if pool.kind() == DatabaseKind::Postgres { return Ok(sqlx::query_scalar::<_, bool>(queries::Q045_POSTGRES) - .bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(false)); + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(false)); } let value = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q045)) - .bind(note_id).fetch_optional(pool.pool()).await?.unwrap_or(0); + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .unwrap_or(0); Ok(value != 0) } -pub async fn set_pad_public_task_updates(pool: &Database, pad_id: i64, allow: bool) -> Result<(), sqlx::Error> { +pub async fn set_pad_public_task_updates( + pool: &Database, + pad_id: i64, + allow: bool, +) -> Result<(), sqlx::Error> { publish_pad(pool, pad_id).await?; let mut query = sqlx::query(queries::get(pool.kind(), queries::Q040)); - query = if pool.kind() == DatabaseKind::Postgres { query.bind(allow) } else { query.bind(if allow { 1i64 } else { 0i64 }) }; + query = if pool.kind() == DatabaseKind::Postgres { + query.bind(allow) + } else { + query.bind(if allow { 1i64 } else { 0i64 }) + }; query.bind(pad_id).execute(pool.pool()).await?; Ok(()) } -pub async fn set_note_public_task_updates(pool: &Database, note_id: i64, allow: bool) -> Result<(), sqlx::Error> { +pub async fn set_note_public_task_updates( + pool: &Database, + note_id: i64, + allow: bool, +) -> Result<(), sqlx::Error> { publish_note(pool, note_id).await?; let mut query = sqlx::query(queries::get(pool.kind(), queries::Q041)); - query = if pool.kind() == DatabaseKind::Postgres { query.bind(allow) } else { query.bind(if allow { 1i64 } else { 0i64 }) }; + query = if pool.kind() == DatabaseKind::Postgres { + query.bind(allow) + } else { + query.bind(if allow { 1i64 } else { 0i64 }) + }; query.bind(note_id).execute(pool.pool()).await?; Ok(()) } -pub async fn update_public_task(pool: &Database, token: &str, source_line: usize, checked: bool) -> Result, sqlx::Error> { - let Some(mut page) = find_published_page(pool, token).await? else { return Ok(None); }; - if !page.allow_task_updates || source_line == 0 { return Ok(Some(page)); } +pub async fn update_public_task( + pool: &Database, + token: &str, + source_line: usize, + checked: bool, +) -> Result, sqlx::Error> { + let Some(mut page) = find_published_page(pool, token).await? else { + return Ok(None); + }; + if !page.allow_task_updates || source_line == 0 { + return Ok(Some(page)); + } let mut lines: Vec = page.content.split('\n').map(str::to_owned).collect(); - let Some(line) = lines.get_mut(source_line - 1) else { return Ok(Some(page)); }; + let Some(line) = lines.get_mut(source_line - 1) else { + return Ok(Some(page)); + }; let bytes = line.as_bytes(); let mut i = 0usize; - while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } - if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') { return Ok(Some(page)); } + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i >= bytes.len() || !matches!(bytes[i], b'-' | b'*' | b'+') { + return Ok(Some(page)); + } i += 1; - while i < bytes.len() && bytes[i].is_ascii_whitespace() { i += 1; } - if i + 2 >= bytes.len() || bytes[i] != b'[' || !matches!(bytes[i + 1], b' ' | b'x' | b'X') || bytes[i + 2] != b']' { return Ok(Some(page)); } + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + if i + 2 >= bytes.len() + || bytes[i] != b'[' + || !matches!(bytes[i + 1], b' ' | b'x' | b'X') + || bytes[i + 2] != b']' + { + return Ok(Some(page)); + } line.replace_range(i + 1..i + 2, if checked { "x" } else { " " }); page.content = lines.join("\n"); if let Some(id) = page.pad_id { - sqlx::query(queries::get(pool.kind(), queries::Q042)).bind(&page.content).bind(id).execute(pool.pool()).await?; + sqlx::query(queries::get(pool.kind(), queries::Q042)) + .bind(&page.content) + .bind(id) + .execute(pool.pool()) + .await?; } else if let Some(id) = page.note_id { - sqlx::query(queries::get(pool.kind(), queries::Q043)).bind(&page.content).bind(id).execute(pool.pool()).await?; + sqlx::query(queries::get(pool.kind(), queries::Q043)) + .bind(&page.content) + .bind(id) + .execute(pool.pool()) + .await?; } find_published_page(pool, token).await } pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q022)) - .bind(pad_id) - .fetch_one(pool.pool()) - .await? + if let Some(token) = + sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q022)) + .bind(pad_id) + .fetch_one(pool.pool()) + .await? { return Ok(token); } @@ -562,10 +658,11 @@ pub async fn pad_file_token(pool: &Database, pad_id: i64) -> Result Result { - if let Some(token) = sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q024)) - .bind(note_id) - .fetch_one(pool.pool()) - .await? + if let Some(token) = + sqlx::query_scalar::<_, Option>(queries::get(pool.kind(), queries::Q024)) + .bind(note_id) + .fetch_one(pool.pool()) + .await? { return Ok(token); } @@ -583,7 +680,6 @@ pub async fn note_file_token(pool: &Database, note_id: i64) -> Result Result, sqlx::Error> { +pub async fn find_file_owner( + pool: &Database, + token: &str, +) -> Result, sqlx::Error> { if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q026)) .bind(token) .fetch_optional(pool.pool()) .await? { - return Ok(Some(FileOwner { kind: FileOwnerKind::Pad, id })); + return Ok(Some(FileOwner { + kind: FileOwnerKind::Pad, + id, + })); } if let Some(id) = sqlx::query_scalar::<_, i64>(queries::get(pool.kind(), queries::Q027)) .bind(token) .fetch_optional(pool.pool()) .await? { - return Ok(Some(FileOwner { kind: FileOwnerKind::Note, id })); + return Ok(Some(FileOwner { + kind: FileOwnerKind::Note, + id, + })); } Ok(None) } - #[derive(Debug, Clone, Serialize, FromRow)] pub struct NoteFile { pub id: i64, @@ -655,14 +759,29 @@ impl From for NoteFile { } pub async fn delete_note(pool: &Database, note_id: i64) -> Result<(), sqlx::Error> { - sqlx::query(queries::get(pool.kind(), queries::Q031)).bind(note_id).execute(pool.pool()).await?; + sqlx::query(queries::get(pool.kind(), queries::Q031)) + .bind(note_id) + .execute(pool.pool()) + .await?; Ok(()) } -pub async fn register_note_file(pool: &Database, note_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> { +pub async fn register_note_file( + pool: &Database, + note_id: i64, + filename: &str, + url: &str, + mime_type: &str, + size_bytes: i64, +) -> Result<(), sqlx::Error> { sqlx::query(queries::get(pool.kind(), queries::Q032)) - .bind(note_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes) - .execute(pool.pool()).await?; + .bind(note_id) + .bind(filename) + .bind(url) + .bind(mime_type) + .bind(size_bytes) + .execute(pool.pool()) + .await?; Ok(()) } @@ -670,7 +789,11 @@ pub async fn list_note_files(pool: &Database, note_id: i64) -> Result Result, sqlx::Error> { +async fn list_files( + pool: &Database, + query: &'static str, + owner_id: i64, +) -> Result, sqlx::Error> { if pool.kind() == DatabaseKind::Sqlite { return Ok(sqlx::query_as::<_, SqliteNoteFile>(query) .bind(owner_id) @@ -686,17 +809,41 @@ async fn list_files(pool: &Database, query: &'static str, owner_id: i64) -> Resu .await } -pub async fn set_note_file_attached(pool: &Database, file_id: i64, attached: bool) -> Result<(), sqlx::Error> { - let detached_at: Option = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; - sqlx::query(queries::get(pool.kind(), queries::Q034)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?; +pub async fn set_note_file_attached( + pool: &Database, + file_id: i64, + attached: bool, +) -> Result<(), sqlx::Error> { + let detached_at: Option = if attached { + None + } else { + Some(chrono::Utc::now().to_rfc3339()) + }; + sqlx::query(queries::get(pool.kind(), queries::Q034)) + .bind(attached) + .bind(detached_at) + .bind(file_id) + .execute(pool.pool()) + .await?; Ok(()) } - -pub async fn register_pad_file(pool: &Database, pad_id: i64, filename: &str, url: &str, mime_type: &str, size_bytes: i64) -> Result<(), sqlx::Error> { +pub async fn register_pad_file( + pool: &Database, + pad_id: i64, + filename: &str, + url: &str, + mime_type: &str, + size_bytes: i64, +) -> Result<(), sqlx::Error> { sqlx::query(queries::get(pool.kind(), queries::Q035)) - .bind(pad_id).bind(filename).bind(url).bind(mime_type).bind(size_bytes) - .execute(pool.pool()).await?; + .bind(pad_id) + .bind(filename) + .bind(url) + .bind(mime_type) + .bind(size_bytes) + .execute(pool.pool()) + .await?; Ok(()) } @@ -704,14 +851,30 @@ pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result Result<(), sqlx::Error> { - let detached_at: Option = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; - sqlx::query(queries::get(pool.kind(), queries::Q037)).bind(attached).bind(detached_at).bind(file_id).execute(pool.pool()).await?; +pub async fn set_pad_file_attached( + pool: &Database, + file_id: i64, + attached: bool, +) -> Result<(), sqlx::Error> { + let detached_at: Option = if attached { + None + } else { + Some(chrono::Utc::now().to_rfc3339()) + }; + sqlx::query(queries::get(pool.kind(), queries::Q037)) + .bind(attached) + .bind(detached_at) + .bind(file_id) + .execute(pool.pool()) + .await?; Ok(()) } - -pub async fn find_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result, sqlx::Error> { +pub async fn find_note_file( + pool: &Database, + note_id: i64, + file_id: i64, +) -> Result, sqlx::Error> { if pool.kind() == DatabaseKind::Sqlite { return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::Q038) .bind(file_id) @@ -727,7 +890,11 @@ pub async fn find_note_file(pool: &Database, note_id: i64, file_id: i64) -> Resu .await } -pub async fn delete_note_file(pool: &Database, note_id: i64, file_id: i64) -> Result<(), sqlx::Error> { +pub async fn delete_note_file( + pool: &Database, + note_id: i64, + file_id: i64, +) -> Result<(), sqlx::Error> { sqlx::query(queries::get(pool.kind(), queries::Q039)) .bind(file_id) .bind(note_id) diff --git a/src/main.rs b/src/main.rs index 6d158cc..a2c1470 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ mod api; -mod auth; mod app; +mod auth; mod config; mod database; mod db; @@ -45,8 +45,14 @@ async fn main() -> Result<(), Box> { asset_version = %config.asset_version, "configuration loaded" ); - if let Some(path) = config.database_url.strip_prefix("sqlite://").and_then(|v| v.split('?').next()) { - if let Some(parent) = std::path::Path::new(path).parent() { std::fs::create_dir_all(parent)?; } + if let Some(path) = config + .database_url + .strip_prefix("sqlite://") + .and_then(|v| v.split('?').next()) + { + if let Some(parent) = std::path::Path::new(path).parent() { + std::fs::create_dir_all(parent)?; + } } info!("connecting to database"); let db = Database::connect(&config.database_url, config.database_max_connections).await?; @@ -55,7 +61,10 @@ async fn main() -> Result<(), Box> { info!(database_kind = ?db.kind(), "database migrations completed"); let storage = storage::Storage::from_config(config.storage.clone()).await?; - info!(storage_driver = storage.backend_name(), "file storage ready"); + info!( + storage_driver = storage.backend_name(), + "file storage ready" + ); let state = Arc::new(AppState::new( db, config.asset_version.clone(), @@ -124,12 +133,20 @@ async fn run_migrations(db: &Database) -> Result<(), sqlx::migrate::MigrateError DatabaseKind::Postgres => std::path::Path::new("migrations/postgres"), DatabaseKind::MySql => std::path::Path::new("migrations/mysql"), }; - sqlx::migrate::Migrator::new(path).await?.run(db.pool()).await + sqlx::migrate::Migrator::new(path) + .await? + .run(db.pool()) + .await } fn database_kind_label(url: &str) -> &'static str { - if url.starts_with("sqlite:") { "sqlite" } - else if url.starts_with("postgres:") || url.starts_with("postgresql:") { "postgres" } - else if url.starts_with("mysql:") { "mysql" } - else { "unknown" } + if url.starts_with("sqlite:") { + "sqlite" + } else if url.starts_with("postgres:") || url.starts_with("postgresql:") { + "postgres" + } else if url.starts_with("mysql:") { + "mysql" + } else { + "unknown" + } } diff --git a/src/queries.rs b/src/queries.rs index 3220f03..c23eb9b 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -1,6 +1,8 @@ -use std::{collections::HashMap, sync::{Mutex, OnceLock}}; use crate::database::DatabaseKind; - +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, +}; // Database bootstrap and identity helpers. pub const SQLITE_FOREIGN_KEYS_ON: &str = "PRAGMA foreign_keys = ON"; @@ -8,55 +10,77 @@ pub const SQLITE_JOURNAL_WAL: &str = "PRAGMA journal_mode = WAL"; pub const SQLITE_BUSY_TIMEOUT: &str = "PRAGMA busy_timeout = 5000"; pub const SQLITE_LAST_INSERT_ID: &str = "SELECT last_insert_rowid()"; pub const MYSQL_LAST_INSERT_ID: &str = "SELECT LAST_INSERT_ID()"; -pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"; -pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = "SELECT currval(pg_get_serial_sequence('revisions', 'id'))"; +pub const POSTGRES_NOTE_REVISION_LAST_INSERT_ID: &str = + "SELECT currval(pg_get_serial_sequence('note_revisions', 'id'))"; +pub const POSTGRES_PAD_REVISION_LAST_INSERT_ID: &str = + "SELECT currval(pg_get_serial_sequence('revisions', 'id'))"; // Authentication queries. pub const AUTH_INSERT_USER: &str = "INSERT INTO users (nickname, nickname_key, email, email_key, password_hash, confirmed_at) VALUES (?, ?, ?, ?, ?, ?)"; pub const AUTH_DELETE_USER: &str = "DELETE FROM users WHERE id = ?"; pub const AUTH_SESSION_EXPIRES_AT: &str = "SELECT expires_at FROM user_sessions WHERE token = ?"; pub const AUTH_DELETE_SESSION_BY_TOKEN: &str = "DELETE FROM user_sessions WHERE token = ?"; -pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str = "DELETE FROM account_confirmation_tokens WHERE user_id = ?"; -pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str = "INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; -pub const AUTH_FIND_CONFIRMATION_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"; -pub const AUTH_CONFIRM_USER: &str = "UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"; -pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str = "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?"; -pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = "DELETE FROM password_reset_tokens WHERE user_id = ?"; -pub const AUTH_INSERT_RESET_TOKEN: &str = "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; -pub const AUTH_FIND_RESET_TOKEN: &str = "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"; -pub const AUTH_UPDATE_PASSWORD: &str = "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"; -pub const AUTH_MARK_RESET_TOKEN_USED: &str = "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?"; +pub const AUTH_DELETE_CONFIRMATION_TOKENS_BY_USER: &str = + "DELETE FROM account_confirmation_tokens WHERE user_id = ?"; +pub const AUTH_INSERT_CONFIRMATION_TOKEN: &str = + "INSERT INTO account_confirmation_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; +pub const AUTH_FIND_CONFIRMATION_TOKEN: &str = + "SELECT user_id, expires_at, used_at FROM account_confirmation_tokens WHERE token = ?"; +pub const AUTH_CONFIRM_USER: &str = + "UPDATE users SET confirmed_at = ?, updated_at = ? WHERE id = ?"; +pub const AUTH_MARK_CONFIRMATION_TOKEN_USED: &str = + "UPDATE account_confirmation_tokens SET used_at = ? WHERE token = ?"; +pub const AUTH_DELETE_RESET_TOKENS_BY_USER: &str = + "DELETE FROM password_reset_tokens WHERE user_id = ?"; +pub const AUTH_INSERT_RESET_TOKEN: &str = + "INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?)"; +pub const AUTH_FIND_RESET_TOKEN: &str = + "SELECT user_id, expires_at, used_at FROM password_reset_tokens WHERE token = ?"; +pub const AUTH_UPDATE_PASSWORD: &str = + "UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?"; +pub const AUTH_MARK_RESET_TOKEN_USED: &str = + "UPDATE password_reset_tokens SET used_at = ? WHERE token = ?"; pub const AUTH_DELETE_SESSIONS_BY_USER: &str = "DELETE FROM user_sessions WHERE user_id = ?"; pub const AUTH_USER_BY_SESSION: &str = "SELECT u.id, u.nickname, u.email, u.password_hash, u.confirmed_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.token = ? AND s.expires_at > ?"; -pub const AUTH_INSERT_SESSION: &str = "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"; -pub const AUTH_USER_BY_NICKNAME: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = ?"; -pub const AUTH_USER_BY_EMAIL: &str = "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?"; +pub const AUTH_INSERT_SESSION: &str = + "INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"; +pub const AUTH_USER_BY_NICKNAME: &str = + "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE nickname_key = ?"; +pub const AUTH_USER_BY_EMAIL: &str = + "SELECT id, nickname, email, password_hash, confirmed_at FROM users WHERE email_key = ?"; pub const USER_ATTACH_WORKSPACE: &str = "INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"; -pub const USER_ATTACH_PAD: &str = "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"; +pub const USER_ATTACH_PAD: &str = + "INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"; pub const USER_LIST_WORKSPACES: &str = "SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"; pub const USER_LIST_PADS: &str = "SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END AS protected, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? UNION SELECT p.slug, p.title, CASE WHEN p.password_hash IS NULL THEN 0 ELSE 1 END, p.updated_at, CASE WHEN p.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_pads owner_up JOIN users u ON u.id = owner_up.user_id WHERE owner_up.pad_id = p.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN pads p ON p.slug = rp.resource_slug WHERE rp.resource_kind = 'pad' AND rp.user_id = ? ORDER BY updated_at DESC"; pub const USER_OWNS_WORKSPACE: &str = "SELECT COUNT(*) FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? AND w.slug = ?"; pub const USER_OWNS_PAD: &str = "SELECT COUNT(*) FROM user_pads up JOIN pads p ON p.id = up.pad_id WHERE up.user_id = ? AND p.slug = ?"; pub const USER_DELETE_WORKSPACE: &str = "DELETE FROM workspaces WHERE slug = ?"; pub const USER_DELETE_PAD: &str = "DELETE FROM pads WHERE slug = ?"; -pub const USER_SET_WORKSPACE_PASSWORD: &str = "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; -pub const USER_SET_PAD_PASSWORD: &str = "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; - +pub const USER_SET_WORKSPACE_PASSWORD: &str = + "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const USER_SET_PAD_PASSWORD: &str = + "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = ?"; pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)"; pub const Q003: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? ORDER BY updated_at DESC, id DESC"; pub const Q004: &str = "SELECT id, workspace_id, slug, title, content, created_at, updated_at, owner_map, protected, created_by FROM notes WHERE workspace_id = ? AND slug = ?"; -pub const Q005: &str = "INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"; -pub const Q006: &str = "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q005: &str = + "INSERT INTO notes (workspace_id, slug, title, protected, created_by) VALUES (?, ?, ?, ?, ?)"; +pub const Q006: &str = + "UPDATE notes SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; pub const Q007: &str = "UPDATE workspaces SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"; -pub const Q008: &str = "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; +pub const Q008: &str = + "INSERT INTO note_revisions (note_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; pub const Q009: &str = "SELECT updated_at FROM notes WHERE id = ?"; pub const Q010: &str = "SELECT id, content, created_at, author, owner_map FROM note_revisions WHERE note_id = ? ORDER BY id DESC LIMIT 100"; pub const Q011: &str = "SELECT id, slug, title, content, password_hash, created_at, updated_at, owner_map, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM pads WHERE slug = ?"; pub const Q012: &str = "INSERT INTO pads (slug, title, password_hash) VALUES (?, ?, ?)"; -pub const Q013: &str = "UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; -pub const Q014: &str = "INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; +pub const Q013: &str = + "UPDATE pads SET content = ?, owner_map = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; +pub const Q014: &str = + "INSERT INTO revisions (pad_id, content, author, owner_map) VALUES (?, ?, ?, ?)"; pub const Q015: &str = "SELECT updated_at FROM pads WHERE id = ?"; pub const Q016: &str = "SELECT id, content, created_at, author, owner_map FROM revisions WHERE pad_id = ? ORDER BY id DESC LIMIT 100"; pub const Q017: &str = "SELECT token FROM published_pages WHERE pad_id = ?"; @@ -74,10 +98,12 @@ pub const Q028: &str = "SELECT content FROM note_revisions WHERE id = ? AND note pub const Q029: &str = "SELECT content FROM revisions WHERE id = ? AND pad_id = ?"; pub const Q030: &str = "SELECT owner_map FROM revisions WHERE id = ? AND pad_id = ?"; pub const Q031: &str = "DELETE FROM notes WHERE id = ?"; -pub const Q032: &str = "INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; +pub const Q032: &str = + "INSERT INTO note_files (note_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; pub const Q033: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE note_id = ? ORDER BY id DESC"; pub const Q034: &str = "UPDATE note_files SET is_attached = ?, detached_at = ? WHERE id = ?"; -pub const Q035: &str = "INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; +pub const Q035: &str = + "INSERT INTO pad_files (pad_id, filename, url, mime_type, size_bytes) VALUES (?, ?, ?, ?, ?)"; pub const Q036: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM pad_files WHERE pad_id = ? ORDER BY id DESC"; pub const Q037: &str = "UPDATE pad_files SET is_attached = ?, detached_at = ? WHERE id = ?"; pub const Q038: &str = "SELECT id, filename, url, mime_type, size_bytes, created_at, is_attached, detached_at FROM note_files WHERE id = ? AND note_id = ?"; @@ -86,10 +112,14 @@ pub const Q039: &str = "DELETE FROM note_files WHERE id = ? AND note_id = ?"; static POSTGRES_QUERIES: OnceLock>> = OnceLock::new(); pub fn get(kind: DatabaseKind, query: &'static str) -> &'static str { - if kind != DatabaseKind::Postgres { return query; } + if kind != DatabaseKind::Postgres { + return query; + } let cache = POSTGRES_QUERIES.get_or_init(|| Mutex::new(HashMap::new())); let mut cache = cache.lock().expect("query cache lock poisoned"); - if let Some(value) = cache.get(query) { return value; } + if let Some(value) = cache.get(query) { + return value; + } let cache_key = query; let query = query.replace("CURRENT_TIMESTAMP", "(CURRENT_TIMESTAMP::text)"); let mut index = 0; @@ -113,8 +143,10 @@ pub const Q041: &str = "UPDATE published_pages SET allow_task_updates = ? WHERE pub const Q042: &str = "UPDATE pads SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; pub const Q043: &str = "UPDATE notes SET content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"; -pub const Q044: &str = "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"; -pub const Q045: &str = "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"; +pub const Q044: &str = + "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE pad_id = ?"; +pub const Q045: &str = + "SELECT CASE WHEN allow_task_updates THEN 1 ELSE 0 END FROM published_pages WHERE note_id = ?"; pub const Q021_POSTGRES: &str = "SELECT pp.token, pp.pad_id, pp.note_id, pp.allow_task_updates, COALESCE(p.title, n.title) AS title, COALESCE(p.content, n.content) AS content, COALESCE(p.updated_at, n.updated_at) AS updated_at FROM published_pages pp LEFT JOIN pads p ON p.id = pp.pad_id LEFT JOIN notes n ON n.id = pp.note_id WHERE pp.token = $1"; pub const Q044_POSTGRES: &str = "SELECT allow_task_updates FROM published_pages WHERE pad_id = $1"; diff --git a/src/state.rs b/src/state.rs index fa4013e..191d503 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,13 +1,24 @@ -use std::{collections::HashMap, sync::{Arc, atomic::{AtomicU64, Ordering}}}; use crate::database::Database; -use tokio::sync::{broadcast, RwLock}; use serde::Serialize; +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; +use tokio::sync::{RwLock, broadcast}; const CHANNEL_CAPACITY: usize = 256; #[derive(Debug, Clone)] pub struct SmtpConfig { - pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub from: String, + pub public_url: String, } #[derive(Debug, Clone)] @@ -52,31 +63,98 @@ pub struct AppState { } impl AppState { - pub fn new(db: Database, asset_version: String, storage: crate::storage::Storage, upload_max_size_bytes: usize, file_cache_max_age_seconds: u64, smtp: Option, registration_enabled: bool, account_confirmation_required: bool, share_confirmation_required: bool, frontend_log_level: String, anonymous_access_token_ttl_days: i64, user_session_ttl_days: i64) -> Self { - Self { db, asset_version, storage, upload_max_size_bytes, file_cache_max_age_seconds, smtp, registration_enabled, account_confirmation_required, share_confirmation_required, frontend_log_level, anonymous_access_token_ttl_days, user_session_ttl_days, channels: RwLock::new(HashMap::new()), presence: RwLock::new(HashMap::new()), next_connection_id: AtomicU64::new(1) } + pub fn new( + db: Database, + asset_version: String, + storage: crate::storage::Storage, + upload_max_size_bytes: usize, + file_cache_max_age_seconds: u64, + smtp: Option, + registration_enabled: bool, + account_confirmation_required: bool, + share_confirmation_required: bool, + frontend_log_level: String, + anonymous_access_token_ttl_days: i64, + user_session_ttl_days: i64, + ) -> Self { + Self { + db, + asset_version, + storage, + upload_max_size_bytes, + file_cache_max_age_seconds, + smtp, + registration_enabled, + account_confirmation_required, + share_confirmation_required, + frontend_log_level, + anonymous_access_token_ttl_days, + user_session_ttl_days, + channels: RwLock::new(HashMap::new()), + presence: RwLock::new(HashMap::new()), + next_connection_id: AtomicU64::new(1), + } } async fn channel_for_key(&self, key: String) -> broadcast::Sender { - if let Some(sender) = self.channels.read().await.get(&key) { return sender.clone(); } + if let Some(sender) = self.channels.read().await.get(&key) { + return sender.clone(); + } let mut channels = self.channels.write().await; - channels.entry(key).or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0).clone() + channels + .entry(key) + .or_insert_with(|| broadcast::channel(CHANNEL_CAPACITY).0) + .clone() } - pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String { format!("workspace:{workspace_slug}/{note_slug}") } - pub fn pad_room_key(slug: &str) -> String { format!("pad:{slug}") } - pub async fn note_channel(&self, workspace_slug: &str, note_slug: &str) -> broadcast::Sender { self.channel_for_key(Self::note_room_key(workspace_slug, note_slug)).await } - pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { self.channel_for_key(Self::pad_room_key(slug)).await } - pub async fn join_room(&self, key: &str, nickname: String, color: Option) -> (u64, Vec) { + pub fn note_room_key(workspace_slug: &str, note_slug: &str) -> String { + format!("workspace:{workspace_slug}/{note_slug}") + } + pub fn pad_room_key(slug: &str) -> String { + format!("pad:{slug}") + } + pub async fn note_channel( + &self, + workspace_slug: &str, + note_slug: &str, + ) -> broadcast::Sender { + self.channel_for_key(Self::note_room_key(workspace_slug, note_slug)) + .await + } + pub async fn pad_channel(&self, slug: &str) -> broadcast::Sender { + self.channel_for_key(Self::pad_room_key(slug)).await + } + pub async fn join_room( + &self, + key: &str, + nickname: String, + color: Option, + ) -> (u64, Vec) { let id = self.next_connection_id.fetch_add(1, Ordering::Relaxed); let mut presence = self.presence.write().await; let room = presence.entry(key.to_owned()).or_default(); - room.insert(id, PresenceUser { name: nickname, color }); + room.insert( + id, + PresenceUser { + name: nickname, + color, + }, + ); (id, sorted_users(room)) } - pub async fn update_room_color(&self, key: &str, id: u64, color: Option) -> Vec { + pub async fn update_room_color( + &self, + key: &str, + id: u64, + color: Option, + ) -> Vec { let mut presence = self.presence.write().await; if let Some(room) = presence.get_mut(key) { - if let Some(user) = room.get_mut(&id) { user.color = color; } + if let Some(user) = room.get_mut(&id) { + user.color = color; + } sorted_users(room) - } else { Vec::new() } + } else { + Vec::new() + } } pub async fn leave_room(&self, key: &str, id: u64) -> Vec { let mut presence = self.presence.write().await; @@ -84,9 +162,13 @@ impl AppState { room.remove(&id); let users = sorted_users(room); let empty = room.is_empty(); - if empty { presence.remove(key); } + if empty { + presence.remove(key); + } users - } else { Vec::new() } + } else { + Vec::new() + } } } diff --git a/src/storage.rs b/src/storage.rs index 5fb1716..549b5e5 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -2,12 +2,14 @@ use std::{path::PathBuf, sync::Arc}; use aws_config::Region; use aws_credential_types::Credentials; -use aws_sdk_s3::{config::Builder as S3ConfigBuilder, primitives::ByteStream, Client}; +use aws_sdk_s3::{Client, config::Builder as S3ConfigBuilder, primitives::ByteStream}; use bytes::Bytes; #[derive(Debug, Clone)] pub enum StorageConfig { - Local { root: PathBuf }, + Local { + root: PathBuf, + }, S3 { endpoint: Option, region: String, @@ -40,8 +42,16 @@ impl Storage { tokio::fs::create_dir_all(&root).await?; Ok(Self::Local { root }) } - StorageConfig::S3 { endpoint, region, bucket, access_key, secret_key, force_path_style } => { - let credentials = Credentials::new(access_key, secret_key, None, None, "rustpad-env"); + StorageConfig::S3 { + endpoint, + region, + bucket, + access_key, + secret_key, + force_path_style, + } => { + let credentials = + Credentials::new(access_key, secret_key, None, None, "rustpad-env"); let shared = aws_config::defaults(aws_config::BehaviorVersion::latest()) .region(Region::new(region.clone())) .credentials_provider(credentials) @@ -53,42 +63,70 @@ impl Storage { if let Some(endpoint) = endpoint.filter(|value| !value.trim().is_empty()) { builder = builder.endpoint_url(endpoint); } - Ok(Self::S3 { client: Client::from_conf(builder.build()), bucket: Arc::from(bucket) }) + Ok(Self::S3 { + client: Client::from_conf(builder.build()), + bucket: Arc::from(bucket), + }) } } } pub fn backend_name(&self) -> &'static str { - match self { Self::Local { .. } => "local", Self::S3 { .. } => "s3" } + match self { + Self::Local { .. } => "local", + Self::S3 { .. } => "s3", + } } pub async fn exists(&self, key: &str) -> Result { match self { Self::Local { root } => Ok(root.join(key).is_file()), - Self::S3 { client, bucket } => match client.head_object().bucket(bucket.as_ref()).key(key).send().await { + Self::S3 { client, bucket } => match client + .head_object() + .bucket(bucket.as_ref()) + .key(key) + .send() + .await + { Ok(_) => Ok(true), - Err(error) if error.as_service_error().is_some_and(|service| service.is_not_found()) => Ok(false), + Err(error) + if error + .as_service_error() + .is_some_and(|service| service.is_not_found()) => + { + Ok(false) + } Err(error) => Err(StorageError::Backend(error.to_string())), }, } } - pub async fn put(&self, key: &str, bytes: Bytes, content_type: &str, cache_control: &str) -> Result<(), StorageError> { + pub async fn put( + &self, + key: &str, + bytes: Bytes, + content_type: &str, + cache_control: &str, + ) -> Result<(), StorageError> { match self { Self::Local { root } => { let path = root.join(key); - if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; } + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } tokio::fs::write(path, bytes).await?; Ok(()) } Self::S3 { client, bucket } => { - client.put_object() + client + .put_object() .bucket(bucket.as_ref()) .key(key) .content_type(content_type) .cache_control(cache_control) .body(ByteStream::from(bytes)) - .send().await + .send() + .await .map_err(|error| StorageError::Backend(error.to_string()))?; Ok(()) } @@ -99,9 +137,17 @@ impl Storage { match self { Self::Local { root } => Ok(Bytes::from(tokio::fs::read(root.join(key)).await?)), Self::S3 { client, bucket } => { - let output = client.get_object().bucket(bucket.as_ref()).key(key).send().await + let output = client + .get_object() + .bucket(bucket.as_ref()) + .key(key) + .send() + .await .map_err(|error| StorageError::Backend(error.to_string()))?; - let bytes = output.body.collect().await + let bytes = output + .body + .collect() + .await .map_err(|error| StorageError::Backend(error.to_string()))? .into_bytes(); Ok(bytes) @@ -109,11 +155,19 @@ impl Storage { } } - pub async fn get_local_with_legacy(&self, key: &str, legacy_key: &str) -> Result { + pub async fn get_local_with_legacy( + &self, + key: &str, + legacy_key: &str, + ) -> Result { match self { Self::Local { root } => { let canonical = root.join(key); - let path = if canonical.is_file() { canonical } else { root.join(legacy_key) }; + let path = if canonical.is_file() { + canonical + } else { + root.join(legacy_key) + }; Ok(Bytes::from(tokio::fs::read(path).await?)) } Self::S3 { .. } => self.get(key).await, @@ -128,7 +182,12 @@ impl Storage { Err(error) => Err(error.into()), }, Self::S3 { client, bucket } => { - client.delete_object().bucket(bucket.as_ref()).key(key).send().await + client + .delete_object() + .bucket(bucket.as_ref()) + .key(key) + .send() + .await .map_err(|error| StorageError::Backend(error.to_string()))?; Ok(()) } @@ -144,11 +203,18 @@ pub enum StorageError { impl std::fmt::Display for StorageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { Self::Io(error) => write!(f, "{error}"), Self::Backend(error) => f.write_str(error) } + match self { + Self::Io(error) => write!(f, "{error}"), + Self::Backend(error) => f.write_str(error), + } } } impl std::error::Error for StorageError {} -impl From for StorageError { fn from(value: std::io::Error) -> Self { Self::Io(value) } } +impl From for StorageError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} pub fn object_key(kind: &str, owner_id: i64, token: &str, filename: &str) -> String { format!("{kind}/{owner_id}_{token}/{filename}") diff --git a/src/websocket.rs b/src/websocket.rs index b1cfe27..da8a1e8 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -1,193 +1,470 @@ -use axum::{extract::{ws::{Message, WebSocket}, Path, State, WebSocketUpgrade}, response::Response}; +use crate::{ + auth, db, + state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState}, +}; +use axum::{ + extract::{ + Path, State, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + response::Response, +}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use tracing::{debug, info, warn}; -use crate::{auth, db, state::{AppState, NoteUpdate, PresenceUser, RoomEvent, SharedState}}; use std::time::{Duration, Instant}; +use tracing::{debug, info, warn}; #[derive(Debug, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ClientMessage { - Authenticate { password: Option, access_token: Option, nickname: Option, session_token: Option, color: Option }, - Update { content: String, owner_map: Option }, - Ping { nonce: u64 }, - Chat { text: String }, - SetColor { color: Option }, + Authenticate { + password: Option, + access_token: Option, + nickname: Option, + session_token: Option, + color: Option, + }, + Update { + content: String, + owner_map: Option, + }, + Ping { + nonce: u64, + }, + Chat { + text: String, + }, + SetColor { + color: Option, + }, } #[derive(Debug, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ServerMessage { - Authenticated { workspace_title: String, note_title: String, content: String, owner_map: String }, - Document { content: String, revision_id: i64, updated_at: String, author: Option, owner_map: String }, - Presence { users: Vec }, - Chat { sender: String, text: String }, - Pong { nonce: u64 }, - Error { message: String }, + Authenticated { + workspace_title: String, + note_title: String, + content: String, + owner_map: String, + }, + Document { + content: String, + revision_id: i64, + updated_at: String, + author: Option, + owner_map: String, + }, + Presence { + users: Vec, + }, + Chat { + sender: String, + text: String, + }, + Pong { + nonce: u64, + }, + Error { + message: String, + }, } -pub async fn upgrade(ws: WebSocketUpgrade, Path((workspace_slug, note_slug)): Path<(String, String)>, State(state): State) -> Response { +pub async fn upgrade( + ws: WebSocketUpgrade, + Path((workspace_slug, note_slug)): Path<(String, String)>, + State(state): State, +) -> Response { ws.on_upgrade(move |socket| handle_socket(socket, state, workspace_slug, note_slug)) } -async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug: String, note_slug: String) { +async fn handle_socket( + mut socket: WebSocket, + state: SharedState, + workspace_slug: String, + note_slug: String, +) { info!(%workspace_slug, %note_slug, "note websocket connected"); - let Some(workspace) = db::find_workspace(&state.db, &workspace_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); let _=send_error(&mut socket,"Workspace not found").await; return; }; - let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug).await.ok().flatten() else { warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); let _=send_error(&mut socket,"Note not found").await; return; }; + let Some(workspace) = db::find_workspace(&state.db, &workspace_slug) + .await + .ok() + .flatten() + else { + warn!(%workspace_slug, %note_slug, "note websocket rejected: workspace not found"); + let _ = send_error(&mut socket, "Workspace not found").await; + return; + }; + let Some(note) = db::find_note(&state.db, workspace.id, ¬e_slug) + .await + .ok() + .flatten() + else { + warn!(%workspace_slug, %note_slug, "note websocket rejected: note not found"); + let _ = send_error(&mut socket, "Note not found").await; + return; + }; let (password, access_token, nickname, session_token, color) = match socket.recv().await { Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { - Ok(ClientMessage::Authenticate { password, access_token, nickname, session_token, color }) => (password, access_token, clean_nickname(nickname), session_token, clean_color(color)), - _ => { let _=send_error(&mut socket,"Wymagane uwierzytelnienie").await; return; } - }, _ => return + Ok(ClientMessage::Authenticate { + password, + access_token, + nickname, + session_token, + color, + }) => ( + password, + access_token, + clean_nickname(nickname), + session_token, + clean_color(color), + ), + _ => { + let _ = send_error(&mut socket, "Wymagane uwierzytelnienie").await; + return; + } + }, + _ => return, }; - let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { Ok(value) => value, Err(message) => { let _=send_error(&mut socket,&message).await; return; } }; - let permission = auth::resource_permission(&state, "workspace", &workspace_slug, session_token.as_deref().or(access_token.as_deref())).await.ok().flatten(); + let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { + Ok(value) => value, + Err(message) => { + let _ = send_error(&mut socket, &message).await; + return; + } + }; + let permission = auth::resource_permission( + &state, + "workspace", + &workspace_slug, + session_token.as_deref().or(access_token.as_deref()), + ) + .await + .ok() + .flatten(); let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); - if workspace.is_private != 0 && permission.is_none() { let _=send_error(&mut socket,"This workspace is private").await; return; } - if workspace.password_hash.is_some() && !password_ok && permission.is_none() { warn!(workspace_id = workspace.id, note_id = note.id, "note websocket rejected: invalid workspace password"); let _=send_error(&mut socket,"Invalid password").await; return; } + if workspace.is_private != 0 && permission.is_none() { + let _ = send_error(&mut socket, "This workspace is private").await; + return; + } + if workspace.password_hash.is_some() && !password_ok && permission.is_none() { + warn!( + workspace_id = workspace.id, + note_id = note.id, + "note websocket rejected: invalid workspace password" + ); + let _ = send_error(&mut socket, "Invalid password").await; + return; + } let write_allowed = password_ok || permission.as_deref() != Some("ro"); info!(workspace_id = workspace.id, note_id = note.id, nickname = ?nickname, "note websocket authenticated"); - if send(&mut socket,&ServerMessage::Authenticated { workspace_title:workspace.title.clone(), note_title:note.title.clone(), content:note.content.clone(), owner_map:note.owner_map.clone() }).await.is_err(){return;} + if send( + &mut socket, + &ServerMessage::Authenticated { + workspace_title: workspace.title.clone(), + note_title: note.title.clone(), + content: note.content.clone(), + owner_map: note.owner_map.clone(), + }, + ) + .await + .is_err() + { + return; + } let room_key = AppState::note_room_key(&workspace_slug, ¬e_slug); - let channel=state.note_channel(&workspace_slug,¬e_slug).await; - let mut updates=channel.subscribe(); + let channel = state.note_channel(&workspace_slug, ¬e_slug).await; + let mut updates = channel.subscribe(); let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); - let (connection_id, users) = state.join_room(&room_key, display_name.clone(), color).await; + let (connection_id, users) = state + .join_room(&room_key, display_name.clone(), color) + .await; let _ = channel.send(RoomEvent::Presence(users)); let mut last_chat = Instant::now() - Duration::from_secs(1); - let (mut sender,mut receiver)=socket.split(); - loop { tokio::select! { - incoming=receiver.next()=>match incoming { - Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text) { - Ok(ClientMessage::Update{content,owner_map})=>{ - if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } - if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } - let owner_map=owner_map.unwrap_or_else(||"[]".into()); - match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { - Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));} - Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"), + let (mut sender, mut receiver) = socket.split(); + loop { + tokio::select! { + incoming=receiver.next()=>match incoming { + Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text) { + Ok(ClientMessage::Update{content,owner_map})=>{ + if !write_allowed { let _=send_split(&mut sender,&ServerMessage::Error{message:"Read-only access".into()}).await; continue; } + if content.len()>2_000_000 { let _=send_split(&mut sender,&ServerMessage::Error{message:"The document is too large".into()}).await; continue; } + let owner_map=owner_map.unwrap_or_else(||"[]".into()); + match db::save_revision(&state.db,note.id,workspace.id,&content,nickname.as_deref(),&owner_map).await { + Ok((revision_id,updated_at))=>{let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map}));} + Err(error)=>warn!(%error, workspace_id = workspace.id, note_id = note.id, "failed to save revision"), + } } - } - Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; }, - Ok(ClientMessage::Chat{text})=>{ - let text=clean_chat(text); - if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } - } - Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, - Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"), + Ok(ClientMessage::Ping{nonce})=>{ let _=send_split(&mut sender,&ServerMessage::Pong{nonce}).await; }, + Ok(ClientMessage::Chat{text})=>{ + let text=clean_chat(text); + if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } + } + Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, + Ok(ClientMessage::Authenticate{..})=>{}, Err(error)=>warn!(%error,"invalid websocket message"), + }, + Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;} }, - Some(Ok(Message::Close(_)))|None=>break, Some(Ok(_))=>{}, Some(Err(error))=>{debug!(%error,"websocket receive error");break;} - }, - update=updates.recv()=>match update { - Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;}, - Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;}, - Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, - Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + update=updates.recv()=>match update { + Ok(RoomEvent::Document(update))=>if send_split(&mut sender,&ServerMessage::Document{content:update.content,revision_id:update.revision_id,updated_at:update.updated_at,author:update.author,owner_map:update.owner_map}).await.is_err(){break;}, + Ok(RoomEvent::Presence(users))=>if send_split(&mut sender,&ServerMessage::Presence{users}).await.is_err(){break;}, + Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_split(&mut sender,&ServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_note(&state.db,workspace.id,¬e_slug).await { if send_split(&mut sender,&ServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, + Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + } } - }} + } let users = state.leave_room(&room_key, connection_id).await; let _ = channel.send(RoomEvent::Presence(users)); - info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected"); + info!( + workspace_id = workspace.id, + note_id = note.id, + "note websocket disconnected" + ); } -fn clean_nickname(value: Option)->Option { - value.map(|v|v.trim().chars().take(40).collect::()).filter(|v|!v.is_empty()) +fn clean_nickname(value: Option) -> Option { + value + .map(|v| v.trim().chars().take(40).collect::()) + .filter(|v| !v.is_empty()) } fn clean_color(value: Option) -> Option { - value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit())) + value.map(|v| v.trim().to_ascii_lowercase()).filter(|v| { + v.len() == 7 && v.starts_with('#') && v[1..].chars().all(|c| c.is_ascii_hexdigit()) + }) } fn clean_chat(value: String) -> String { - value.chars().map(|c| if matches!(c, '\r' | '\n' | '\0') { ' ' } else { c }).collect::().trim().chars().take(1000).collect() + value + .chars() + .map(|c| { + if matches!(c, '\r' | '\n' | '\0') { + ' ' + } else { + c + } + }) + .collect::() + .trim() + .chars() + .take(1000) + .collect() } -async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error> { - send(socket,&ServerMessage::Error { - message:message.into() - } - ).await +async fn send_error(socket: &mut WebSocket, message: &str) -> Result<(), axum::Error> { + send( + socket, + &ServerMessage::Error { + message: message.into(), + }, + ) + .await } -async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error> { - socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +async fn send(socket: &mut WebSocket, message: &ServerMessage) -> Result<(), axum::Error> { + socket + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await } -async fn send_split(sender:&mut futures_util::stream::SplitSink,message:&ServerMessage)->Result<(),axum::Error> { - sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +async fn send_split( + sender: &mut futures_util::stream::SplitSink, + message: &ServerMessage, +) -> Result<(), axum::Error> { + sender + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await } #[derive(Debug, Serialize)] -#[serde(tag="type",rename_all="snake_case")] +#[serde(tag = "type", rename_all = "snake_case")] enum PadServerMessage { - Authenticated { title: String, content: String, owner_map: String }, - Document { content: String, revision_id: i64, updated_at: String, author: Option, owner_map: String }, - Presence { users: Vec }, - Chat { sender: String, text: String }, - Pong { nonce: u64 }, - Error { message: String }, + Authenticated { + title: String, + content: String, + owner_map: String, + }, + Document { + content: String, + revision_id: i64, + updated_at: String, + author: Option, + owner_map: String, + }, + Presence { + users: Vec, + }, + Chat { + sender: String, + text: String, + }, + Pong { + nonce: u64, + }, + Error { + message: String, + }, } -pub async fn upgrade_pad(ws:WebSocketUpgrade,Path(slug):Path,State(state):State)->Response{ - ws.on_upgrade(move|socket|handle_pad_socket(socket,state,slug)) +pub async fn upgrade_pad( + ws: WebSocketUpgrade, + Path(slug): Path, + State(state): State, +) -> Response { + ws.on_upgrade(move |socket| handle_pad_socket(socket, state, slug)) } -async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ +async fn handle_pad_socket(mut socket: WebSocket, state: SharedState, slug: String) { info!(%slug, "pad websocket connected"); - let Some(pad)=db::find_pad(&state.db,&slug).await.ok().flatten() else {warn!(%slug, "pad websocket rejected: pad not found");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Note not found".into()}).await;return;}; - let (password,access_token,nickname,session_token,color)=match socket.recv().await{ - Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ - Ok(ClientMessage::Authenticate{password,access_token,nickname,session_token,color})=>(password,access_token,clean_nickname(nickname),session_token,clean_color(color)), - _=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Wymagane uwierzytelnienie".into()}).await;return;} - },_=>return + let Some(pad) = db::find_pad(&state.db, &slug).await.ok().flatten() else { + warn!(%slug, "pad websocket rejected: pad not found"); + let _ = send_pad( + &mut socket, + &PadServerMessage::Error { + message: "Note not found".into(), + }, + ) + .await; + return; }; - let nickname=match auth::authorize_nickname(&state,nickname,session_token.clone()).await{Ok(value)=>value,Err(message)=>{let _=send_pad(&mut socket,&PadServerMessage::Error{message}).await;return;}}; - let permission=auth::resource_permission(&state,"pad",&slug,session_token.as_deref().or(access_token.as_deref())).await.ok().flatten(); - let password_ok=db::verify_pad_password(&pad,password.as_deref()); - if pad.is_private != 0 && permission.is_none(){let _=send_pad(&mut socket,&PadServerMessage::Error{message:"This note is private".into()}).await;return;} - if pad.password_hash.is_some() && !password_ok && permission.is_none(){warn!(pad_id = pad.id, "pad websocket rejected: invalid password");let _=send_pad(&mut socket,&PadServerMessage::Error{message:"Invalid password".into()}).await;return;} - let write_allowed=password_ok || permission.as_deref()!=Some("ro"); + let (password, access_token, nickname, session_token, color) = match socket.recv().await { + Some(Ok(Message::Text(text))) => match serde_json::from_str::(&text) { + Ok(ClientMessage::Authenticate { + password, + access_token, + nickname, + session_token, + color, + }) => ( + password, + access_token, + clean_nickname(nickname), + session_token, + clean_color(color), + ), + _ => { + let _ = send_pad( + &mut socket, + &PadServerMessage::Error { + message: "Wymagane uwierzytelnienie".into(), + }, + ) + .await; + return; + } + }, + _ => return, + }; + let nickname = match auth::authorize_nickname(&state, nickname, session_token.clone()).await { + Ok(value) => value, + Err(message) => { + let _ = send_pad(&mut socket, &PadServerMessage::Error { message }).await; + return; + } + }; + let permission = auth::resource_permission( + &state, + "pad", + &slug, + session_token.as_deref().or(access_token.as_deref()), + ) + .await + .ok() + .flatten(); + let password_ok = db::verify_pad_password(&pad, password.as_deref()); + if pad.is_private != 0 && permission.is_none() { + let _ = send_pad( + &mut socket, + &PadServerMessage::Error { + message: "This note is private".into(), + }, + ) + .await; + return; + } + if pad.password_hash.is_some() && !password_ok && permission.is_none() { + warn!(pad_id = pad.id, "pad websocket rejected: invalid password"); + let _ = send_pad( + &mut socket, + &PadServerMessage::Error { + message: "Invalid password".into(), + }, + ) + .await; + return; + } + let write_allowed = password_ok || permission.as_deref() != Some("ro"); info!(pad_id = pad.id, nickname = ?nickname, "pad websocket authenticated"); - if send_pad(&mut socket,&PadServerMessage::Authenticated{title:pad.title.clone(),content:pad.content.clone(),owner_map:pad.owner_map.clone()}).await.is_err(){return;} + if send_pad( + &mut socket, + &PadServerMessage::Authenticated { + title: pad.title.clone(), + content: pad.content.clone(), + owner_map: pad.owner_map.clone(), + }, + ) + .await + .is_err() + { + return; + } let room_key = AppState::pad_room_key(&slug); - let channel=state.pad_channel(&slug).await; - let mut updates=channel.subscribe(); + let channel = state.pad_channel(&slug).await; + let mut updates = channel.subscribe(); let display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); - let (connection_id, users) = state.join_room(&room_key, display_name.clone(), color).await; + let (connection_id, users) = state + .join_room(&room_key, display_name.clone(), color) + .await; let _ = channel.send(RoomEvent::Presence(users)); let mut last_chat = Instant::now() - Duration::from_secs(1); - let(mut sender,mut receiver)=socket.split(); - loop{tokio::select!{ - incoming=receiver.next()=>match incoming{ - Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ - Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;} - if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; } - let owner_map=owner_map.unwrap_or_else(||"[]".into()); - if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{ - let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map})); + let (mut sender, mut receiver) = socket.split(); + loop { + tokio::select! { + incoming=receiver.next()=>match incoming{ + Some(Ok(Message::Text(text)))=>match serde_json::from_str::(&text){ + Ok(ClientMessage::Update{content,owner_map})=>{if !write_allowed{let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"Read-only access".into()}).await;continue;} + if content.len()>2_000_000 { let _=send_pad_split(&mut sender,&PadServerMessage::Error{message:"The document is too large".into()}).await; continue; } + let owner_map=owner_map.unwrap_or_else(||"[]".into()); + if let Ok((revision_id,updated_at))=db::save_pad_revision(&state.db,pad.id,&content,nickname.as_deref(),&owner_map).await{ + let _=channel.send(RoomEvent::Document(NoteUpdate{content,revision_id,updated_at,author:nickname.clone(),owner_map})); + } } - } - Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; }, - Ok(ClientMessage::Chat{text})=>{ - let text=clean_chat(text); - if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } - } - Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, - Ok(ClientMessage::Authenticate{..})=>{}, - Err(error)=>warn!(%error,"invalid pad websocket message"), + Ok(ClientMessage::Ping{nonce})=>{ let _=send_pad_split(&mut sender,&PadServerMessage::Pong{nonce}).await; }, + Ok(ClientMessage::Chat{text})=>{ + let text=clean_chat(text); + if !text.is_empty() && last_chat.elapsed() >= Duration::from_millis(500) { last_chat=Instant::now(); let _=channel.send(RoomEvent::Chat{sender:display_name.clone(),text}); } + } + Ok(ClientMessage::SetColor{color})=>{ let users=state.update_room_color(&room_key,connection_id,clean_color(color)).await; let _=channel.send(RoomEvent::Presence(users)); }, + Ok(ClientMessage::Authenticate{..})=>{}, + Err(error)=>warn!(%error,"invalid pad websocket message"), + }, + Some(Ok(Message::Close(_)))|None=>break, + Some(Ok(_))=>{}, + Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;} }, - Some(Ok(Message::Close(_)))|None=>break, - Some(Ok(_))=>{}, - Some(Err(error))=>{debug!(%error,"pad websocket receive error");break;} - }, - update=updates.recv()=>match update{ - Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;}, - Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;}, - Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, - Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + update=updates.recv()=>match update{ + Ok(RoomEvent::Document(u))=>if send_pad_split(&mut sender,&PadServerMessage::Document{content:u.content,revision_id:u.revision_id,updated_at:u.updated_at,author:u.author,owner_map:u.owner_map}).await.is_err(){break;}, + Ok(RoomEvent::Presence(users))=>if send_pad_split(&mut sender,&PadServerMessage::Presence{users}).await.is_err(){break;}, + Ok(RoomEvent::Chat{sender:chat_sender,text})=>if send_pad_split(&mut sender,&PadServerMessage::Chat{sender:chat_sender,text}).await.is_err(){break;}, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_))=>if let Ok(Some(current))=db::find_pad(&state.db,&slug).await { if send_pad_split(&mut sender,&PadServerMessage::Document{content:current.content,revision_id:0,updated_at:current.updated_at,author:None,owner_map:current.owner_map}).await.is_err(){break;} }, + Err(tokio::sync::broadcast::error::RecvError::Closed)=>break, + } } - }} + } let users = state.leave_room(&room_key, connection_id).await; let _ = channel.send(RoomEvent::Presence(users)); info!(pad_id = pad.id, "pad websocket disconnected"); } -async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error> { - socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +async fn send_pad(socket: &mut WebSocket, message: &PadServerMessage) -> Result<(), axum::Error> { + socket + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await } -async fn send_pad_split(sender:&mut futures_util::stream::SplitSink,message:&PadServerMessage)->Result<(),axum::Error> { - sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +async fn send_pad_split( + sender: &mut futures_util::stream::SplitSink, + message: &PadServerMessage, +) -> Result<(), axum::Error> { + sender + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await } From 27243299cdc90019030f1bb3df939fcdb9b26770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 24 Jul 2026 11:37:04 +0200 Subject: [PATCH 6/6] cleanup code and queries --- src/api.rs | 4 ++-- src/auth.rs | 52 ++++++++++++++++++++++++-------------------------- src/queries.rs | 40 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 29 deletions(-) diff --git a/src/api.rs b/src/api.rs index d3b5f78..7316e5f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1132,7 +1132,7 @@ pub async fn create_resource_access_token( let token = hex::encode(bytes); let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT)) .bind(hash_access_token(&token)) .bind(kind) .bind(slug) @@ -1161,7 +1161,7 @@ pub async fn verify_resource_access_token( { return Ok(true); } - let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?")) + let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT)) .bind(hash_access_token(token)) .bind(kind) .bind(slug) diff --git a/src/auth.rs b/src/auth.rs index 095a446..fb73275 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -466,7 +466,7 @@ pub async fn update_resource( .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), - "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?", + queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(req.kind.as_str()) .bind(req.slug.trim()) @@ -490,7 +490,7 @@ pub async fn delete_resource( }; sqlx::query(queries::get( state.db.kind(), - "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?", + queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(req.kind.as_str()) .bind(req.slug.trim()) @@ -553,14 +553,12 @@ pub async fn set_resource_privacy( ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; - let table = match req.kind.as_str() { - "workspace" => "workspaces", - "pad" => "pads", + let query = match req.kind.as_str() { + "workspace" => queries::USER_SET_WORKSPACE_PRIVACY, + "pad" => queries::USER_SET_PAD_PRIVACY, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; - let query = - format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"); - sqlx::query(&query) + sqlx::query(queries::get(state.db.kind(), query)) .bind(req.private) .bind(req.slug.trim()) .execute(state.db.pool()) @@ -604,16 +602,16 @@ pub async fn share_resource_users( continue; } - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; if state.share_confirmation_required { let token = random_token(); let token_hash = hash_token(&token); let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_INSERT)) .bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at) .execute(state.db.pool()).await.map_err(AuthError::database)?; if let Err(error) = send_share_invitation( @@ -629,7 +627,7 @@ pub async fn share_resource_users( { let _ = sqlx::query(queries::get( state.db.kind(), - "DELETE FROM resource_share_invitations WHERE token_hash = ?", + queries::SHARE_INVITATION_DELETE_TOKEN, )) .bind(&token_hash) .execute(state.db.pool()) @@ -637,7 +635,7 @@ pub async fn share_resource_users( return Err(error); } } else { - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; } } @@ -657,7 +655,7 @@ pub async fn accept_share_invitation( AxumPath(token): AxumPath, ) -> Result { let token_hash = hash_token(token.trim()); - let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?")) + let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN)) .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| { AuthError::bad_request("The sharing invitation is invalid or has expired.") @@ -672,13 +670,13 @@ pub async fn accept_share_invitation( )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) .bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT)) .bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), - "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?", + queries::SHARE_INVITATION_ACCEPT, )) .bind(Utc::now().to_rfc3339()) .bind(&token_hash) @@ -704,9 +702,9 @@ pub async fn remove_resource_user( ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let email = normalize(&req.email); if let Some(user) = find_user_by_email(&state, &email).await? { - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; } Ok(Json(serde_json::json!({"ok":true}))) @@ -725,11 +723,11 @@ pub async fn resource_sharing( .get("slug") .ok_or_else(|| AuthError::bad_request("Missing slug."))?; ensure_owner(&state, owner.id, kind, slug).await?; - let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email")) + let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_USERS)) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - let links: Vec<(String,String,Option,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC")) + let links: Vec<(String,String,Option,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS)) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email")) + let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_PENDING)) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; Ok(Json( serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::>(), "links":links.into_iter().map(|(token,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }), @@ -747,7 +745,7 @@ pub async fn create_share_link( validate_share_expiration(req.expires_at.as_deref())?; let token = random_token(); let token_hash = hash_token(&token); - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) .bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?; let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) @@ -768,7 +766,7 @@ pub async fn update_share_link( ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; validate_share_expiration(req.expires_at.as_deref())?; - let result = sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL")) + let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE)) .bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; if result.rows_affected() == 0 { return Err(AuthError::bad_request( @@ -787,7 +785,7 @@ pub async fn revoke_share_link( ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; - sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) .bind(Utc::now().to_rfc3339()).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } @@ -826,12 +824,12 @@ pub async fn resource_permission( if owns { return Ok(Some("rw".into())); } - let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER)) .bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; return Ok(permission); } let now = Utc::now().to_rfc3339(); - let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)")) + let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::SHARE_LINK_PERMISSION)) .bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; Ok(permission) } diff --git a/src/queries.rs b/src/queries.rs index c23eb9b..c44dc11 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -61,6 +61,46 @@ pub const USER_SET_WORKSPACE_PASSWORD: &str = "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; pub const USER_SET_PAD_PASSWORD: &str = "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const USER_SET_WORKSPACE_PRIVACY: &str = + "UPDATE workspaces SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const USER_SET_PAD_PRIVACY: &str = + "UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: &str = + "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"; +pub const RESOURCE_ACCESS_TOKENS_INSERT: &str = + "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"; +pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: &str = + "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"; +pub const RESOURCE_PERMISSION_DELETE_USER: &str = + "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"; +pub const RESOURCE_PERMISSION_INSERT: &str = + "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"; +pub const SHARE_INVITATION_DELETE_USER: &str = + "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"; +pub const SHARE_INVITATION_INSERT: &str = + "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; +pub const SHARE_INVITATION_DELETE_TOKEN: &str = + "DELETE FROM resource_share_invitations WHERE token_hash = ?"; +pub const SHARE_INVITATION_FIND_TOKEN: &str = + "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?"; +pub const SHARE_INVITATION_ACCEPT: &str = + "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"; +pub const RESOURCE_SHARING_USERS: &str = + "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"; +pub const RESOURCE_SHARING_LINKS: &str = + "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"; +pub const RESOURCE_SHARING_PENDING: &str = + "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"; +pub const SHARE_LINK_INSERT: &str = + "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"; +pub const SHARE_LINK_UPDATE: &str = + "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"; +pub const SHARE_LINK_REVOKE: &str = + "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"; +pub const RESOURCE_PERMISSION_BY_USER: &str = + "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"; +pub const SHARE_LINK_PERMISSION: &str = + "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"; pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = ?"; pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";