From d6c1c52310f4af2f7acf459897def5d861b33a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 27 Jul 2026 22:56:56 +0200 Subject: [PATCH] fix in smtp and split rs files --- .env.example | 1 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- src/api.rs | 1787 ------------------------------------ src/api/access_tokens.rs | 172 ++++ src/api/files.rs | 498 ++++++++++ src/api/mod.rs | 27 + src/api/pads_public.rs | 381 ++++++++ src/api/workspace_notes.rs | 714 ++++++++++++++ src/app.rs | 496 ---------- src/app/mod.rs | 223 +++++ src/app/pages.rs | 284 ++++++ src/auth/mod.rs | 4 +- src/config.rs | 459 --------- src/config/mod.rs | 176 ++++ src/config/smtp.rs | 100 ++ src/config/values.rs | 169 ++++ src/db.rs | 968 ------------------- src/db/files.rs | 287 ++++++ src/db/mod.rs | 15 + src/db/pads_public.rs | 379 ++++++++ src/db/workspace_notes.rs | 291 ++++++ src/ldap_auth.rs | 142 --- src/websocket.rs | 530 ----------- src/websocket/helpers.rs | 73 ++ src/websocket/messages.rs | 57 ++ src/websocket/mod.rs | 20 + src/websocket/note.rs | 236 +++++ src/websocket/pad.rs | 219 +++++ 30 files changed, 4327 insertions(+), 4387 deletions(-) delete mode 100644 src/api.rs create mode 100644 src/api/access_tokens.rs create mode 100644 src/api/files.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/pads_public.rs create mode 100644 src/api/workspace_notes.rs delete mode 100644 src/app.rs create mode 100644 src/app/mod.rs create mode 100644 src/app/pages.rs delete mode 100644 src/config.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/smtp.rs create mode 100644 src/config/values.rs delete mode 100644 src/db.rs create mode 100644 src/db/files.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/pads_public.rs create mode 100644 src/db/workspace_notes.rs delete mode 100644 src/ldap_auth.rs delete mode 100644 src/websocket.rs create mode 100644 src/websocket/helpers.rs create mode 100644 src/websocket/messages.rs create mode 100644 src/websocket/mod.rs create mode 100644 src/websocket/note.rs create mode 100644 src/websocket/pad.rs diff --git a/.env.example b/.env.example index 9140781..d7b94e4 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,7 @@ SHARE_CONFIRMATION_REQUIRED=true # smtp mailing # port 465 → tls # port 587 → starttls +# SMTP_SECURITY not req. PUBLIC_URL=https://pad.example.com # SMTP_HOST=smtp.example.com #SMTP_SECURITY=none diff --git a/Cargo.lock b/Cargo.lock index 7088245..30337cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.9" +version = "0.1.10" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index f572cc6..4b9d36c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.9" +version = "0.1.10" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/README.md b/README.md index fe10e6c..e075491 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Migrations are stored in `migrations/sqlite`, `migrations/postgres`, and `migrat Nicknames can be used anonymously while they remain unregistered. Registering a nickname reserves it and requires a valid login session before it can be used in editor WebSocket connections. -Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURITY`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad ` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. `SMTP_SECURITY` accepts `none` (plain SMTP, typically an internal relay on port 25), `starttls`, or `tls` (implicit TLS, commonly port 465). When omitted, it defaults to `tls` for port 465 and `starttls` for other ports. Reset links expire after 30 minutes and can be used only once. +Configure `PUBLIC_URL`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURITY`, `SMTP_USERNAME`, `SMTP_PASSWORD`, and `SMTP_FROM` to enable password-reset emails. `SMTP_FROM` accepts both `RustPad ` and a value wrapped in one matching pair of single or double quotes, as may be passed literally by container env-file implementations. `SMTP_SECURITY` accepts `none` (plain SMTP, typically an internal relay on port 25), `starttls`, or `tls` (implicit TLS, commonly port 465). When omitted, it defaults to `tls` for port 465, `starttls` for port 587, and `none` for port 25 or any other port. SMTP authentication is enabled only when both `SMTP_USERNAME` and `SMTP_PASSWORD` are non-empty. Reset links expire after 30 minutes and can be used only once. ## Diagnostics and logging diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index d413222..0000000 --- a/src/api.rs +++ /dev/null @@ -1,1787 +0,0 @@ -use axum::{ - Json, - extract::{Multipart, Path, State}, - http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, - response::{IntoResponse, Response}, -}; -use chrono::{Duration, Utc}; -use rand_core::{OsRng, RngCore}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use slug::slugify; - -use crate::{ - db, queries, - state::{NoteUpdate, RoomEvent, SharedState}, -}; - -const MAX_NAME_LENGTH: usize = 80; -const MIN_PASSWORD_LENGTH: usize = 8; -const MAX_PASSWORD_LENGTH: usize = 128; -const MIN_WORKSPACE_SLUG_LENGTH: usize = 6; - -#[derive(Debug, Serialize)] -pub struct PublishResponse { - url: String, -} - -#[derive(Debug, Serialize)] -pub struct PublicPageResponse { - title: String, - content: String, - updated_at: String, - allow_task_updates: bool, -} - -#[derive(Debug, Deserialize)] -pub struct CreateWorkspaceRequest { - name: String, - #[serde(default)] - password: Option, -} - -#[derive(Debug, Serialize)] -pub struct CreateWorkspaceResponse { - slug: String, - url: String, -} - -#[derive(Debug, Deserialize)] -pub struct PasswordRequest { - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, -} - -#[derive(Debug, Deserialize)] -pub struct PublishRequest { - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, - #[serde(default)] - allow_task_updates: bool, -} - -#[derive(Debug, Deserialize)] -pub struct PublicTaskUpdateRequest { - source_line: usize, - checked: bool, -} - -#[derive(Debug, Deserialize)] -pub struct CreateNoteRequest { - name: String, - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, - #[serde(default)] - protect: bool, - #[serde(default)] - created_by: Option, -} - -#[derive(Debug, Deserialize)] -pub struct RestoreRequest { - #[serde(default)] - password: Option, - #[serde(default)] - access_token: Option, - revision_id: i64, -} - -#[derive(Debug, Serialize)] -pub struct WorkspaceInfo { - slug: String, - title: String, - protected: bool, - created_at: String, - updated_at: String, -} - -#[derive(Debug, Serialize)] -pub struct WorkspaceOpenResponse { - workspace: WorkspaceInfo, - notes: Vec, -} - -#[derive(Debug, Serialize)] -pub struct NoteListItem { - slug: String, - title: String, - created_at: String, - updated_at: String, - url: String, - protected: bool, - created_by: Option, -} - -#[derive(Debug, Serialize)] -pub struct NoteInfo { - workspace_slug: String, - workspace_title: String, - slug: String, - title: String, - protected: bool, - note_protected: bool, - allow_public_task_updates: bool, - created_at: String, - updated_at: String, - can_delete_files: bool, - global_color: Option, - note_color: Option, -} - -#[derive(Debug, Deserialize)] -pub struct EditorColorRequest { - color: Option, -} - -pub async fn create_workspace( - State(state): State, - headers: HeaderMap, - Json(payload): Json, -) -> Result<(StatusCode, Json), ApiError> { - let title = validate_name(&payload.name, "Workspace name")?; - let password = validate_password(payload.password.as_deref())?; - 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?; - } - - Ok(( - StatusCode::CREATED, - Json(CreateWorkspaceResponse { - url: format!("/w/{slug}"), - slug, - }), - )) -} - -pub async fn workspace_info( - State(state): State, - headers: HeaderMap, - Path(workspace_slug): Path, -) -> Result, ApiError> { - let workspace = db::find_workspace(&state.db, &workspace_slug) - .await? - .ok_or_else(ApiError::not_found_workspace)?; - ensure_private_resource_access( - &state, - "workspace", - &workspace.slug, - workspace.is_private, - bearer_token(&headers), - ) - .await?; - Ok(Json(workspace_info_from(&workspace))) -} - -pub async fn open_workspace( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .await?; - let notes = db::list_notes(&state.db, workspace.id) - .await? - .into_iter() - .map(|note| NoteListItem { - url: format!("/w/{}/n/{}", workspace.slug, note.slug), - slug: note.slug, - title: note.title, - created_at: db::normalize_timestamp(¬e.created_at), - updated_at: db::normalize_timestamp(¬e.updated_at), - protected: note.protected, - created_by: note.created_by, - }) - .collect(); - - Ok(Json(WorkspaceOpenResponse { - workspace: workspace_info_from(&workspace), - notes, - })) -} - -pub async fn create_note( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .await?; - let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) - || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "workspace", - &workspace_slug, - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - 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", - )); - } - - 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?; - Ok(( - StatusCode::CREATED, - Json(NoteListItem { - url: format!("/w/{workspace_slug}/n/{slug}"), - slug: note.slug, - title: note.title, - created_at: db::normalize_timestamp(¬e.created_at), - updated_at: db::normalize_timestamp(¬e.updated_at), - protected: note.protected, - created_by: note.created_by, - }), - )) -} - -fn clean_editor_color(value: Option<&str>) -> Result, ApiError> { - let Some(value) = value else { - return Ok(None); - }; - let value = value.trim(); - if value.len() == 7 - && value.starts_with('#') - && value[1..].chars().all(|c| c.is_ascii_hexdigit()) - { - Ok(Some(value.to_ascii_lowercase())) - } else { - Err(ApiError::bad_request("Invalid editor color")) - } -} - -async fn editor_colors( - state: &SharedState, - headers: &HeaderMap, - kind: &str, - slug: &str, -) -> Result<(Option, Option), ApiError> { - let Some(user) = crate::auth::optional_user(state, headers) - .await - .map_err(|e| ApiError::forbidden(&e.message))? - else { - return Ok((None, None)); - }; - let global: Option = sqlx::query_scalar(queries::get( - state.db.kind(), - queries::AUTH_EDITOR_COLOR_BY_USER, - )) - .bind(user.id) - .fetch_one(state.db.pool()) - .await?; - let note: Option = sqlx::query_scalar(queries::get( - state.db.kind(), - queries::RESOURCE_COLOR_BY_USER, - )) - .bind(user.id) - .bind(kind) - .bind(slug) - .fetch_optional(state.db.pool()) - .await?; - Ok((global, note)) -} - -async fn save_editor_color( - state: &SharedState, - headers: &HeaderMap, - kind: &str, - slug: &str, - color: Option<&str>, -) -> Result, ApiError> { - let user = crate::auth::optional_user(state, headers) - .await - .map_err(|e| ApiError::forbidden(&e.message))? - .ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?; - let color = clean_editor_color(color)?; - let mut tx = state.db.pool().begin().await?; - sqlx::query(queries::get( - state.db.kind(), - queries::RESOURCE_COLOR_DELETE, - )) - .bind(user.id) - .bind(kind) - .bind(slug) - .execute(&mut *tx) - .await?; - if let Some(value) = color.as_deref() { - sqlx::query(queries::get( - state.db.kind(), - queries::RESOURCE_COLOR_INSERT, - )) - .bind(user.id) - .bind(kind) - .bind(slug) - .bind(value) - .execute(&mut *tx) - .await?; - } - tx.commit().await?; - Ok(Json(serde_json::json!({"color": color}))) -} - -pub async fn note_info( - State(state): State, - headers: HeaderMap, - Path((workspace_slug, note_slug)): Path<(String, String)>, -) -> Result, ApiError> { - let workspace = db::find_workspace(&state.db, &workspace_slug) - .await? - .ok_or_else(ApiError::not_found_workspace)?; - ensure_private_resource_access( - &state, - "workspace", - &workspace.slug, - workspace.is_private, - bearer_token(&headers), - ) - .await?; - let note = db::find_note(&state.db, workspace.id, ¬e_slug) - .await? - .ok_or_else(ApiError::not_found_note)?; - let color_slug = format!("{}/{}", workspace_slug, note_slug); - let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; - - Ok(Json(NoteInfo { - workspace_slug: workspace.slug, - workspace_title: workspace.title, - slug: note.slug, - title: note.title, - protected: workspace.password_hash.is_some(), - note_protected: note.protected, - allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, - created_at: db::normalize_timestamp(¬e.created_at), - updated_at: db::normalize_timestamp(¬e.updated_at), - can_delete_files: { - let workspace_owner = crate::auth::is_resource_owner( - &state, - "workspace", - &workspace_slug, - bearer_token(&headers), - ) - .await - .unwrap_or(false); - let note_owner = crate::auth::optional_user(&state, &headers) - .await - .ok() - .flatten() - .and_then(|user| { - note.created_by - .as_deref() - .map(|creator| creator == user.nickname) - }) - .unwrap_or(false); - workspace_owner || note_owner - }, - global_color, - note_color, - })) -} - -pub async fn history( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .await?; - let _ = workspace; - let revisions = db::list_revisions(&state.db, note.id) - .await? - .into_iter() - .map(|mut revision| { - revision.created_at = db::normalize_timestamp(&revision.created_at); - revision - }) - .collect(); - Ok(Json(revisions)) -} - -pub async fn restore( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .await?; - let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) - || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "workspace", - &workspace_slug, - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - 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 update = NoteUpdate { - content, - revision_id, - updated_at, - author: Some("restore".into()), - owner_map: "[]".into(), - }; - let _ = state - .note_channel(&workspace_slug, ¬e_slug) - .await - .send(RoomEvent::Document(update)); - Ok(Json(serde_json::json!({"ok": true}))) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub enum AccessLevel { - None, - Read, - Write, -} - -fn permission_level(permission: Option<&str>) -> AccessLevel { - match permission { - Some("rw") => AccessLevel::Write, - Some("ro") => AccessLevel::Read, - _ => AccessLevel::None, - } -} - -async fn anonymous_access_token_valid( - state: &SharedState, - kind: &str, - slug: &str, - token: Option<&str>, -) -> Result { - let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(false); - }; - 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) - .bind(Utc::now().to_rfc3339()) - .fetch_one(state.db.pool()) - .await?; - Ok(count > 0) -} - -async fn token_access_level( - state: &SharedState, - kind: &str, - slug: &str, - token: Option<&str>, -) -> Result { - let permission = crate::auth::resource_permission(state, kind, slug, token) - .await - .map_err(|error| ApiError::forbidden(&error.message))?; - let level = permission_level(permission.as_deref()); - if level != AccessLevel::None { - return Ok(level); - } - if anonymous_access_token_valid(state, kind, slug, token).await? { - // A server-issued token created after a correct resource password - // retains the historical read/write semantics of password access. - return Ok(AccessLevel::Write); - } - Ok(AccessLevel::None) -} - -async fn combined_token_access_level( - state: &SharedState, - kind: &str, - slug: &str, - access_token: Option<&str>, - bearer: Option<&str>, -) -> Result { - Ok(std::cmp::max( - token_access_level(state, kind, slug, access_token).await?, - token_access_level(state, kind, slug, bearer).await?, - )) -} - -fn require_write(level: AccessLevel) -> Result<(), ApiError> { - if level >= AccessLevel::Write { - Ok(()) - } else { - Err(ApiError::forbidden("Read-only access.")) - } -} - -async fn ensure_private_resource_access( - state: &SharedState, - kind: &str, - slug: &str, - is_private: i64, - token: Option<&str>, -) -> Result<(), ApiError> { - if is_private == 0 { - return Ok(()); - } - if verify_resource_access_token(state, kind, slug, token).await? { - return Ok(()); - } - Err(ApiError::forbidden("This resource is private.")) -} - -pub async fn authorized_workspace( - state: &SharedState, - slug: &str, - password: Option<&str>, - access_token: Option<&str>, - bearer: Option<&str>, -) -> Result { - let workspace = db::find_workspace(&state.db, slug) - .await? - .ok_or_else(ApiError::not_found_workspace)?; - let token_level = - combined_token_access_level(state, "workspace", slug, access_token, bearer).await?; - if workspace.is_private != 0 && token_level == AccessLevel::None { - return Err(ApiError::forbidden("This workspace is private.")); - } - if workspace.password_hash.is_some() - && !db::verify_workspace_password(&workspace, password) - && token_level == AccessLevel::None - { - return Err(ApiError::unauthorized()); - } - Ok(workspace) -} - -async fn authorized_note( - state: &SharedState, - workspace_slug: &str, - note_slug: &str, - password: Option<&str>, - access_token: Option<&str>, - bearer: Option<&str>, -) -> Result<(db::Workspace, db::Note), ApiError> { - let workspace = - authorized_workspace(state, workspace_slug, password, access_token, bearer).await?; - let note = db::find_note(&state.db, workspace.id, note_slug) - .await? - .ok_or_else(ApiError::not_found_note)?; - Ok((workspace, note)) -} - -fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo { - WorkspaceInfo { - slug: workspace.slug.clone(), - title: workspace.title.clone(), - protected: workspace.password_hash.is_some(), - created_at: db::normalize_timestamp(&workspace.created_at), - updated_at: db::normalize_timestamp(&workspace.updated_at), - } -} - -fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> { - let value = value.trim(); - if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH { - return Err(ApiError::bad_request(&format!( - "{field} must contain between 1 and {MAX_NAME_LENGTH} characters" - ))); - } - Ok(value) -} - -fn validate_password(password: Option<&str>) -> Result, ApiError> { - let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - let length = password.chars().count(); - if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) { - return Err(ApiError::bad_request( - "Password must contain between 8 and 128 characters", - )); - } - Ok(Some(password)) -} - -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", - )); - } - - let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH - || db::find_workspace(&state.db, &base).await?.is_some(); - if !needs_suffix { - return Ok(base); - } - - for _ in 0..8 { - let candidate = format!("{base}-{}", db::random_suffix(8)); - if db::find_workspace(&state.db, &candidate).await?.is_none() { - return Ok(candidate); - } - } - Err(ApiError::internal("Failed to create a unique address")) -} - -async fn unique_note_slug( - state: &SharedState, - workspace_id: i64, - base: &str, -) -> Result { - if db::find_note(&state.db, workspace_id, base) - .await? - .is_none() - { - return Ok(base.to_owned()); - } - for _ in 0..8 { - let candidate = format!("{base}-{}", db::random_suffix(6)); - if db::find_note(&state.db, workspace_id, &candidate) - .await? - .is_none() - { - return Ok(candidate); - } - } - Err(ApiError::internal("Failed to create a unique address")) -} - -#[derive(Debug, Deserialize)] -pub struct CreatePadRequest { - name: String, - #[serde(default)] - password: Option, -} - -#[derive(Debug, Serialize)] -pub struct CreatePadResponse { - slug: String, - url: String, -} - -#[derive(Debug, Serialize)] -pub struct PadInfo { - slug: String, - title: String, - protected: bool, - allow_public_task_updates: bool, - created_at: String, - updated_at: String, - can_delete_files: bool, - global_color: Option, - note_color: Option, -} - -pub async fn create_pad( - State(state): State, - headers: HeaderMap, - Json(payload): Json, -) -> Result<(StatusCode, Json), ApiError> { - let title = validate_name(&payload.name, "Note name")?; - 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", - )); - } - 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?; - } - Ok(( - StatusCode::CREATED, - Json(CreatePadResponse { - url: format!("/p/{slug}"), - slug, - }), - )) -} - -pub async fn pad_info( - State(state): State, - headers: HeaderMap, - Path(slug): Path, -) -> Result, ApiError> { - let pad = db::find_pad(&state.db, &slug) - .await? - .ok_or_else(ApiError::not_found_note)?; - ensure_private_resource_access( - &state, - "pad", - &pad.slug, - pad.is_private, - bearer_token(&headers), - ) - .await?; - let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; - Ok(Json(PadInfo { - slug: pad.slug, - title: pad.title, - protected: pad.password_hash.is_some(), - allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, - created_at: db::normalize_timestamp(&pad.created_at), - updated_at: db::normalize_timestamp(&pad.updated_at), - can_delete_files: crate::auth::is_resource_owner( - &state, - "pad", - &slug, - bearer_token(&headers), - ) - .await - .unwrap_or(false), - global_color, - note_color, - })) -} - -pub async fn pad_editor_color( - State(state): State, - headers: HeaderMap, - Path(slug): Path, -) -> Result, ApiError> { - let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; - Ok(Json( - serde_json::json!({"global_color": global_color, "note_color": note_color}), - )) -} - -pub async fn note_editor_color( - State(state): State, - headers: HeaderMap, - Path((workspace_slug, note_slug)): Path<(String, String)>, -) -> Result, ApiError> { - let (global_color, note_color) = editor_colors( - &state, - &headers, - "note", - &format!("{}/{}", workspace_slug, note_slug), - ) - .await?; - Ok(Json( - serde_json::json!({"global_color": global_color, "note_color": note_color}), - )) -} - -pub async fn set_pad_editor_color( - State(state): State, - headers: HeaderMap, - Path(slug): Path, - Json(payload): Json, -) -> Result, ApiError> { - save_editor_color(&state, &headers, "pad", &slug, payload.color.as_deref()).await -} - -pub async fn set_note_editor_color( - State(state): State, - headers: HeaderMap, - Path((workspace_slug, note_slug)): Path<(String, String)>, - Json(payload): Json, -) -> Result, ApiError> { - save_editor_color( - &state, - &headers, - "note", - &format!("{}/{}", workspace_slug, note_slug), - payload.color.as_deref(), - ) - .await -} - -pub async fn publish_pad_page( - State(state): State, - headers: HeaderMap, - Path(slug): Path, - Json(payload): Json, -) -> Result, ApiError> { - let pad = authorized_pad( - &state, - &slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await?; - let level = if db::verify_pad_password(&pad, payload.password.as_deref()) - || (pad.is_private == 0 && pad.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "pad", - &slug, - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - 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}"), - })) -} - -pub async fn publish_note_page( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .await?; - let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) - || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "workspace", - &workspace_slug, - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - 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}"), - })) -} - -pub async fn public_page( - State(state): State, - Path(token): Path, -) -> Result, ApiError> { - let page = db::find_published_page(&state.db, &token) - .await? - .ok_or_else(ApiError::not_found_note)?; - Ok(Json(PublicPageResponse { - title: page.title, - content: page.content, - updated_at: db::normalize_timestamp(&page.updated_at), - allow_task_updates: page.allow_task_updates, - })) -} - -pub async fn update_public_task( - State(state): State, - 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)?; - Ok(Json(PublicPageResponse { - title: page.title, - content: page.content, - updated_at: db::normalize_timestamp(&page.updated_at), - allow_task_updates: page.allow_task_updates, - })) -} - -pub async fn pad_history( - State(state): State, - headers: HeaderMap, - Path(slug): Path, - Json(payload): Json, -) -> Result>, ApiError> { - let pad = authorized_pad( - &state, - &slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await?; - let revisions = db::list_pad_revisions(&state.db, pad.id) - .await? - .into_iter() - .map(|mut revision| { - revision.created_at = db::normalize_timestamp(&revision.created_at); - revision - }) - .collect(); - Ok(Json(revisions)) -} - -pub async fn pad_restore( - State(state): State, - headers: HeaderMap, - Path(slug): Path, - Json(payload): Json, -) -> Result, ApiError> { - let pad = authorized_pad( - &state, - &slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await?; - let level = if db::verify_pad_password(&pad, payload.password.as_deref()) - || (pad.is_private == 0 && pad.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "pad", - &slug, - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - 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 update = NoteUpdate { - content, - revision_id, - updated_at, - author: Some("restore".into()), - owner_map, - }; - let _ = state - .pad_channel(&slug) - .await - .send(RoomEvent::Document(update)); - Ok(Json(serde_json::json!({"ok": true}))) -} - -async fn authorized_pad( - state: &SharedState, - slug: &str, - password: Option<&str>, - access_token: Option<&str>, - bearer: Option<&str>, -) -> Result { - let pad = db::find_pad(&state.db, slug) - .await? - .ok_or_else(ApiError::not_found_note)?; - let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?; - if pad.is_private != 0 && token_level == AccessLevel::None { - return Err(ApiError::forbidden("This note is private.")); - } - if pad.password_hash.is_some() - && !db::verify_pad_password(&pad, password) - && token_level == AccessLevel::None - { - return Err(ApiError::unauthorized()); - } - Ok(pad) -} - -async fn unique_pad_slug(state: &SharedState, base: &str) -> Result { - if db::find_pad(&state.db, base).await?.is_none() { - return Ok(base.to_owned()); - } - for _ in 0..8 { - let candidate = format!("{base}-{}", db::random_suffix(6)); - if db::find_pad(&state.db, &candidate).await?.is_none() { - return Ok(candidate); - } - } - Err(ApiError::internal("Failed to create a unique address")) -} - -pub async fn upload_pad_file( - State(state): State, - headers: HeaderMap, - Path(slug): Path, - mut multipart: Multipart, -) -> Result, ApiError> { - 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"))? - { - let name = field.name().unwrap_or_default().to_owned(); - if name == "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"))?, - ); - } 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"))?; - 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 pad = authorized_pad( - &state, - &slug, - password.as_deref(), - access_token.as_deref(), - bearer_token(&headers), - ) - .await?; - let level = if db::verify_pad_password(&pad, password.as_deref()) - || (pad.is_private == 0 && pad.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "pad", - &slug, - access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - 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 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(); - 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 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}))) -} - -pub async fn pad_files( - State(state): State, - headers: HeaderMap, - Path(slug): Path, - Json(payload): Json, -) -> Result>, ApiError> { - let pad = authorized_pad( - &state, - &slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .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.created_at = db::normalize_timestamp(&file.created_at); - } - Ok(Json(files)) -} - -pub async fn delete_pad_file( - State(state): State, - headers: HeaderMap, - Path((slug, file_id)): Path<(String, i64)>, - Json(payload): Json, -) -> Result, ApiError> { - let pad = authorized_pad( - &state, - &slug, - payload.password.as_deref(), - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await?; - if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers)) - .await - .unwrap_or(false) - { - return Err(ApiError::forbidden("Only the note owner can delete files")); - } - let file = db::find_pad_file(&state.db, pad.id, file_id) - .await? - .ok_or_else(ApiError::not_found_file)?; - crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url) - .await - .map_err(|_| ApiError::internal("Failed to delete the file"))?; - db::delete_pad_file(&state.db, pad.id, file_id).await?; - Ok(Json(serde_json::json!({"ok": true}))) -} - -pub async fn upload_note_file( - State(state): State, - headers: HeaderMap, - Path((workspace_slug, note_slug)): Path<(String, String)>, - mut multipart: Multipart, -) -> Result, ApiError> { - 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"))? - { - let name = field.name().unwrap_or_default().to_owned(); - if name == "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"))?, - ); - } 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"))?; - 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(), - bearer_token(&headers), - ) - .await?; - - let level = if db::verify_workspace_password(&workspace, password.as_deref()) - || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "workspace", - &workspace_slug, - access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - 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(); - 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 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}))) -} - -pub async fn delete_note( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .await?; - - let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) - || (workspace.is_private == 0 && workspace.password_hash.is_none()) - { - AccessLevel::Write - } else { - combined_token_access_level( - &state, - "workspace", - &workspace_slug, - payload.access_token.as_deref(), - bearer_token(&headers), - ) - .await? - }; - require_write(level)?; - if note.protected { - return Err(ApiError::bad_request( - "This note is protected and cannot be deleted", - )); - } - for file in db::list_note_files(&state.db, note.id).await? { - crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) - .await - .map_err(|_| ApiError::internal("Failed to delete note files"))?; - } - db::delete_note(&state.db, note.id).await?; - Ok(Json(serde_json::json!({"ok": true}))) -} - -pub async fn note_files( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .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.created_at = db::normalize_timestamp(&file.created_at); - } - Ok(Json(files)) -} - -pub async fn delete_note_file( - State(state): State, - headers: HeaderMap, - 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(), - bearer_token(&headers), - ) - .await?; - let workspace_owner = crate::auth::is_resource_owner( - &state, - "workspace", - &workspace.slug, - bearer_token(&headers), - ) - .await - .unwrap_or(false); - let note_owner = crate::auth::optional_user(&state, &headers) - .await - .ok() - .flatten() - .and_then(|user| { - note.created_by - .as_deref() - .map(|creator| creator == user.nickname) - }) - .unwrap_or(false); - if !workspace_owner && !note_owner { - return Err(ApiError::forbidden( - "Only the note owner or workspace owner can delete files", - )); - } - let file = db::find_note_file(&state.db, note.id, file_id) - .await? - .ok_or_else(ApiError::not_found_file)?; - crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) - .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}))) -} - -pub async fn download_file( - State(state): State, - Path((token, filename)): Path<(String, String)>, -) -> Result { - serve_token_file(&state, &token, &filename).await -} - -pub async fn download_legacy_file( - State(state): State, - Path((directory, filename)): Path<(String, String)>, -) -> Result { - let Some((id_part, token)) = directory.split_once('_') else { - 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? - .ok_or_else(ApiError::not_found_file)?; - if owner.id != id { - return Err(ApiError::not_found_file()); - } - serve_token_file(&state, token, &filename).await -} - -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? - .ok_or_else(ApiError::not_found_file)?; - let kind = match owner.kind { - db::FileOwnerKind::Pad => "pads", - db::FileOwnerKind::Note => "notes", - }; - 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( - header::CONTENT_TYPE, - 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( - HeaderName::from_static("x-robots-tag"), - HeaderValue::from_static("noindex, nofollow, noarchive, nosnippet"), - ); - response.headers_mut().insert( - header::REFERRER_POLICY, - HeaderValue::from_static("no-referrer"), - ); - 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"), - ); - Ok(response) -} - -fn bearer_token(headers: &HeaderMap) -> Option<&str> { - headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - -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() - } -} - -#[derive(Debug, Deserialize)] -pub struct AccessTokenRequest { - kind: String, - slug: String, - password: String, -} - -#[derive(Debug, Serialize)] -pub struct AccessTokenResponse { - access_token: String, - expires_at: String, -} - -pub async fn create_resource_access_token( - State(state): State, - Json(payload): Json, -) -> Result, ApiError> { - let kind = payload.kind.trim(); - let slug = payload.slug.trim(); - match kind { - "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)?; - if !db::verify_pad_password(&pad, Some(payload.password.as_str())) { - return Err(ApiError::unauthorized()); - } - } - _ => return Err(ApiError::bad_request("Invalid resource kind")), - } - - 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(); - sqlx::query(queries::get( - state.db.kind(), - queries::RESOURCE_ACCESS_TOKENS_INSERT, - )) - .bind(hash_access_token(&token)) - .bind(kind) - .bind(slug) - .bind(&expires_at) - .execute(state.db.pool()) - .await?; - Ok(Json(AccessTokenResponse { - access_token: token, - expires_at, - })) -} - -pub async fn verify_resource_access_token( - state: &SharedState, - kind: &str, - slug: &str, - token: Option<&str>, -) -> Result { - 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() - { - return Ok(true); - } - 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) - .bind(Utc::now().to_rfc3339()) - .fetch_one(state.db.pool()) - .await?; - Ok(count > 0) -} - -fn hash_access_token(token: &str) -> String { - hex::encode(Sha256::digest(token.as_bytes())) -} - -pub struct ApiError { - status: StatusCode, - message: String, -} - -impl ApiError { - fn bad_request(message: &str) -> Self { - Self { - status: StatusCode::BAD_REQUEST, - message: message.into(), - } - } - fn payload_too_large(max_bytes: usize) -> Self { - let max_mb = max_bytes / (1024 * 1024); - Self { - status: StatusCode::PAYLOAD_TOO_LARGE, - message: format!("The file may be at most {max_mb} MB"), - } - } - fn not_found_file() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "File not found".into(), - } - } - fn unauthorized() -> Self { - Self { - status: StatusCode::UNAUTHORIZED, - message: "Invalid password".into(), - } - } - fn forbidden(message: &str) -> Self { - Self { - status: StatusCode::FORBIDDEN, - message: message.into(), - } - } - fn not_found_workspace() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "Workspace not found".into(), - } - } - fn not_found_note() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "Note not found".into(), - } - } - fn not_found_revision() -> Self { - Self { - status: StatusCode::NOT_FOUND, - message: "Revision not found".into(), - } - } - fn internal(message: &str) -> Self { - Self { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: message.into(), - } - } -} - -impl From for ApiError { - fn from(error: sqlx::Error) -> Self { - tracing::error!(%error, "database error"); - Self::internal("Database error") - } -} - -impl IntoResponse for ApiError { - fn into_response(self) -> Response { - ( - self.status, - Json(serde_json::json!({"error": self.message})), - ) - .into_response() - } -} diff --git a/src/api/access_tokens.rs b/src/api/access_tokens.rs new file mode 100644 index 0000000..741a0b4 --- /dev/null +++ b/src/api/access_tokens.rs @@ -0,0 +1,172 @@ +#[derive(Debug, Deserialize)] +pub struct AccessTokenRequest { + kind: String, + slug: String, + password: String, +} + +#[derive(Debug, Serialize)] +pub struct AccessTokenResponse { + access_token: String, + expires_at: String, +} + +pub async fn create_resource_access_token( + State(state): State, + Json(payload): Json, +) -> Result, ApiError> { + let kind = payload.kind.trim(); + let slug = payload.slug.trim(); + match kind { + "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)?; + if !db::verify_pad_password(&pad, Some(payload.password.as_str())) { + return Err(ApiError::unauthorized()); + } + } + _ => return Err(ApiError::bad_request("Invalid resource kind")), + } + + 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(); + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_ACCESS_TOKENS_INSERT, + )) + .bind(hash_access_token(&token)) + .bind(kind) + .bind(slug) + .bind(&expires_at) + .execute(state.db.pool()) + .await?; + Ok(Json(AccessTokenResponse { + access_token: token, + expires_at, + })) +} + +pub async fn verify_resource_access_token( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result { + 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() + { + return Ok(true); + } + 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) + .bind(Utc::now().to_rfc3339()) + .fetch_one(state.db.pool()) + .await?; + Ok(count > 0) +} + +fn hash_access_token(token: &str) -> String { + hex::encode(Sha256::digest(token.as_bytes())) +} + +pub struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + fn bad_request(message: &str) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: message.into(), + } + } + fn payload_too_large(max_bytes: usize) -> Self { + let max_mb = max_bytes / (1024 * 1024); + Self { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: format!("The file may be at most {max_mb} MB"), + } + } + fn not_found_file() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "File not found".into(), + } + } + fn unauthorized() -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: "Invalid password".into(), + } + } + fn forbidden(message: &str) -> Self { + Self { + status: StatusCode::FORBIDDEN, + message: message.into(), + } + } + fn not_found_workspace() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Workspace not found".into(), + } + } + fn not_found_note() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Note not found".into(), + } + } + fn not_found_revision() -> Self { + Self { + status: StatusCode::NOT_FOUND, + message: "Revision not found".into(), + } + } + fn internal(message: &str) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: message.into(), + } + } +} + +impl From for ApiError { + fn from(error: sqlx::Error) -> Self { + tracing::error!(%error, "database error"); + Self::internal("Database error") + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(serde_json::json!({"error": self.message})), + ) + .into_response() + } +} diff --git a/src/api/files.rs b/src/api/files.rs new file mode 100644 index 0000000..35ffcda --- /dev/null +++ b/src/api/files.rs @@ -0,0 +1,498 @@ +pub async fn upload_pad_file( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + mut multipart: Multipart, +) -> Result, ApiError> { + 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"))? + { + let name = field.name().unwrap_or_default().to_owned(); + if name == "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"))?, + ); + } 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"))?; + 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 pad = authorized_pad( + &state, + &slug, + password.as_deref(), + access_token.as_deref(), + bearer_token(&headers), + ) + .await?; + let level = if db::verify_pad_password(&pad, password.as_deref()) + || (pad.is_private == 0 && pad.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "pad", + &slug, + access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + 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 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(); + 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 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}))) +} + +pub async fn pad_files( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result>, ApiError> { + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .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.created_at = db::normalize_timestamp(&file.created_at); + } + Ok(Json(files)) +} + +pub async fn delete_pad_file( + State(state): State, + headers: HeaderMap, + Path((slug, file_id)): Path<(String, i64)>, + Json(payload): Json, +) -> Result, ApiError> { + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await?; + if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers)) + .await + .unwrap_or(false) + { + return Err(ApiError::forbidden("Only the note owner can delete files")); + } + let file = db::find_pad_file(&state.db, pad.id, file_id) + .await? + .ok_or_else(ApiError::not_found_file)?; + crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url) + .await + .map_err(|_| ApiError::internal("Failed to delete the file"))?; + db::delete_pad_file(&state.db, pad.id, file_id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn upload_note_file( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, + mut multipart: Multipart, +) -> Result, ApiError> { + 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"))? + { + let name = field.name().unwrap_or_default().to_owned(); + if name == "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"))?, + ); + } 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"))?; + 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(), + bearer_token(&headers), + ) + .await?; + + let level = if db::verify_workspace_password(&workspace, password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + 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(); + 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 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}))) +} + +pub async fn delete_note( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .await?; + + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + if note.protected { + return Err(ApiError::bad_request( + "This note is protected and cannot be deleted", + )); + } + for file in db::list_note_files(&state.db, note.id).await? { + crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) + .await + .map_err(|_| ApiError::internal("Failed to delete note files"))?; + } + db::delete_note(&state.db, note.id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +pub async fn note_files( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .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.created_at = db::normalize_timestamp(&file.created_at); + } + Ok(Json(files)) +} + +pub async fn delete_note_file( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .await?; + let workspace_owner = crate::auth::is_resource_owner( + &state, + "workspace", + &workspace.slug, + bearer_token(&headers), + ) + .await + .unwrap_or(false); + let note_owner = crate::auth::optional_user(&state, &headers) + .await + .ok() + .flatten() + .and_then(|user| { + note.created_by + .as_deref() + .map(|creator| creator == user.nickname) + }) + .unwrap_or(false); + if !workspace_owner && !note_owner { + return Err(ApiError::forbidden( + "Only the note owner or workspace owner can delete files", + )); + } + let file = db::find_note_file(&state.db, note.id, file_id) + .await? + .ok_or_else(ApiError::not_found_file)?; + crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) + .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}))) +} + +pub async fn download_file( + State(state): State, + Path((token, filename)): Path<(String, String)>, +) -> Result { + serve_token_file(&state, &token, &filename).await +} + +pub async fn download_legacy_file( + State(state): State, + Path((directory, filename)): Path<(String, String)>, +) -> Result { + let Some((id_part, token)) = directory.split_once('_') else { + 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? + .ok_or_else(ApiError::not_found_file)?; + if owner.id != id { + return Err(ApiError::not_found_file()); + } + serve_token_file(&state, token, &filename).await +} + +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? + .ok_or_else(ApiError::not_found_file)?; + let kind = match owner.kind { + db::FileOwnerKind::Pad => "pads", + db::FileOwnerKind::Note => "notes", + }; + 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( + header::CONTENT_TYPE, + 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( + HeaderName::from_static("x-robots-tag"), + HeaderValue::from_static("noindex, nofollow, noarchive, nosnippet"), + ); + response.headers_mut().insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + 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"), + ); + Ok(response) +} + +fn bearer_token(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +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() + } +} + diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..99bf426 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,27 @@ +use axum::{ + Json, + extract::{Multipart, Path, State}, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use chrono::{Duration, Utc}; +use rand_core::{OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use slug::slugify; + +use crate::{ + db, queries, + state::{NoteUpdate, RoomEvent, SharedState}, +}; + +const MAX_NAME_LENGTH: usize = 80; +const MIN_PASSWORD_LENGTH: usize = 8; +const MAX_PASSWORD_LENGTH: usize = 128; +const MIN_WORKSPACE_SLUG_LENGTH: usize = 6; + + +include!("workspace_notes.rs"); +include!("pads_public.rs"); +include!("files.rs"); +include!("access_tokens.rs"); diff --git a/src/api/pads_public.rs b/src/api/pads_public.rs new file mode 100644 index 0000000..605c22b --- /dev/null +++ b/src/api/pads_public.rs @@ -0,0 +1,381 @@ +#[derive(Debug, Deserialize)] +pub struct CreatePadRequest { + name: String, + #[serde(default)] + password: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreatePadResponse { + slug: String, + url: String, +} + +#[derive(Debug, Serialize)] +pub struct PadInfo { + slug: String, + title: String, + protected: bool, + allow_public_task_updates: bool, + created_at: String, + updated_at: String, + can_delete_files: bool, + global_color: Option, + note_color: Option, +} + +pub async fn create_pad( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let title = validate_name(&payload.name, "Note name")?; + 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", + )); + } + 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?; + } + Ok(( + StatusCode::CREATED, + Json(CreatePadResponse { + url: format!("/p/{slug}"), + slug, + }), + )) +} + +pub async fn pad_info( + State(state): State, + headers: HeaderMap, + Path(slug): Path, +) -> Result, ApiError> { + let pad = db::find_pad(&state.db, &slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + ensure_private_resource_access( + &state, + "pad", + &pad.slug, + pad.is_private, + bearer_token(&headers), + ) + .await?; + let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; + Ok(Json(PadInfo { + slug: pad.slug, + title: pad.title, + protected: pad.password_hash.is_some(), + allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, + created_at: db::normalize_timestamp(&pad.created_at), + updated_at: db::normalize_timestamp(&pad.updated_at), + can_delete_files: crate::auth::is_resource_owner( + &state, + "pad", + &slug, + bearer_token(&headers), + ) + .await + .unwrap_or(false), + global_color, + note_color, + })) +} + +pub async fn pad_editor_color( + State(state): State, + headers: HeaderMap, + Path(slug): Path, +) -> Result, ApiError> { + let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; + Ok(Json( + serde_json::json!({"global_color": global_color, "note_color": note_color}), + )) +} + +pub async fn note_editor_color( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> Result, ApiError> { + let (global_color, note_color) = editor_colors( + &state, + &headers, + "note", + &format!("{}/{}", workspace_slug, note_slug), + ) + .await?; + Ok(Json( + serde_json::json!({"global_color": global_color, "note_color": note_color}), + )) +} + +pub async fn set_pad_editor_color( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + save_editor_color(&state, &headers, "pad", &slug, payload.color.as_deref()).await +} + +pub async fn set_note_editor_color( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, + Json(payload): Json, +) -> Result, ApiError> { + save_editor_color( + &state, + &headers, + "note", + &format!("{}/{}", workspace_slug, note_slug), + payload.color.as_deref(), + ) + .await +} + +pub async fn publish_pad_page( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await?; + let level = if db::verify_pad_password(&pad, payload.password.as_deref()) + || (pad.is_private == 0 && pad.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "pad", + &slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + 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}"), + })) +} + +pub async fn publish_note_page( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .await?; + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + 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}"), + })) +} + +pub async fn public_page( + State(state): State, + Path(token): Path, +) -> Result, ApiError> { + let page = db::find_published_page(&state.db, &token) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok(Json(PublicPageResponse { + title: page.title, + content: page.content, + updated_at: db::normalize_timestamp(&page.updated_at), + allow_task_updates: page.allow_task_updates, + })) +} + +pub async fn update_public_task( + State(state): State, + 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)?; + Ok(Json(PublicPageResponse { + title: page.title, + content: page.content, + updated_at: db::normalize_timestamp(&page.updated_at), + allow_task_updates: page.allow_task_updates, + })) +} + +pub async fn pad_history( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result>, ApiError> { + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await?; + let revisions = db::list_pad_revisions(&state.db, pad.id) + .await? + .into_iter() + .map(|mut revision| { + revision.created_at = db::normalize_timestamp(&revision.created_at); + revision + }) + .collect(); + Ok(Json(revisions)) +} + +pub async fn pad_restore( + State(state): State, + headers: HeaderMap, + Path(slug): Path, + Json(payload): Json, +) -> Result, ApiError> { + let pad = authorized_pad( + &state, + &slug, + payload.password.as_deref(), + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await?; + let level = if db::verify_pad_password(&pad, payload.password.as_deref()) + || (pad.is_private == 0 && pad.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "pad", + &slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + 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 update = NoteUpdate { + content, + revision_id, + updated_at, + author: Some("restore".into()), + owner_map, + }; + let _ = state + .pad_channel(&slug) + .await + .send(RoomEvent::Document(update)); + Ok(Json(serde_json::json!({"ok": true}))) +} + +async fn authorized_pad( + state: &SharedState, + slug: &str, + password: Option<&str>, + access_token: Option<&str>, + bearer: Option<&str>, +) -> Result { + let pad = db::find_pad(&state.db, slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + let token_level = combined_token_access_level(state, "pad", slug, access_token, bearer).await?; + if pad.is_private != 0 && token_level == AccessLevel::None { + return Err(ApiError::forbidden("This note is private.")); + } + if pad.password_hash.is_some() + && !db::verify_pad_password(&pad, password) + && token_level == AccessLevel::None + { + return Err(ApiError::unauthorized()); + } + Ok(pad) +} + +async fn unique_pad_slug(state: &SharedState, base: &str) -> Result { + if db::find_pad(&state.db, base).await?.is_none() { + return Ok(base.to_owned()); + } + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(6)); + if db::find_pad(&state.db, &candidate).await?.is_none() { + return Ok(candidate); + } + } + Err(ApiError::internal("Failed to create a unique address")) +} + diff --git a/src/api/workspace_notes.rs b/src/api/workspace_notes.rs new file mode 100644 index 0000000..79b4b58 --- /dev/null +++ b/src/api/workspace_notes.rs @@ -0,0 +1,714 @@ +#[derive(Debug, Serialize)] +pub struct PublishResponse { + url: String, +} + +#[derive(Debug, Serialize)] +pub struct PublicPageResponse { + title: String, + content: String, + updated_at: String, + allow_task_updates: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateWorkspaceRequest { + name: String, + #[serde(default)] + password: Option, +} + +#[derive(Debug, Serialize)] +pub struct CreateWorkspaceResponse { + slug: String, + url: String, +} + +#[derive(Debug, Deserialize)] +pub struct PasswordRequest { + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PublishRequest { + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, + #[serde(default)] + allow_task_updates: bool, +} + +#[derive(Debug, Deserialize)] +pub struct PublicTaskUpdateRequest { + source_line: usize, + checked: bool, +} + +#[derive(Debug, Deserialize)] +pub struct CreateNoteRequest { + name: String, + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, + #[serde(default)] + protect: bool, + #[serde(default)] + created_by: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RestoreRequest { + #[serde(default)] + password: Option, + #[serde(default)] + access_token: Option, + revision_id: i64, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceInfo { + slug: String, + title: String, + protected: bool, + created_at: String, + updated_at: String, +} + +#[derive(Debug, Serialize)] +pub struct WorkspaceOpenResponse { + workspace: WorkspaceInfo, + notes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct NoteListItem { + slug: String, + title: String, + created_at: String, + updated_at: String, + url: String, + protected: bool, + created_by: Option, +} + +#[derive(Debug, Serialize)] +pub struct NoteInfo { + workspace_slug: String, + workspace_title: String, + slug: String, + title: String, + protected: bool, + note_protected: bool, + allow_public_task_updates: bool, + created_at: String, + updated_at: String, + can_delete_files: bool, + global_color: Option, + note_color: Option, +} + +#[derive(Debug, Deserialize)] +pub struct EditorColorRequest { + color: Option, +} + +pub async fn create_workspace( + State(state): State, + headers: HeaderMap, + Json(payload): Json, +) -> Result<(StatusCode, Json), ApiError> { + let title = validate_name(&payload.name, "Workspace name")?; + let password = validate_password(payload.password.as_deref())?; + 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?; + } + + Ok(( + StatusCode::CREATED, + Json(CreateWorkspaceResponse { + url: format!("/w/{slug}"), + slug, + }), + )) +} + +pub async fn workspace_info( + State(state): State, + headers: HeaderMap, + Path(workspace_slug): Path, +) -> Result, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + ensure_private_resource_access( + &state, + "workspace", + &workspace.slug, + workspace.is_private, + bearer_token(&headers), + ) + .await?; + Ok(Json(workspace_info_from(&workspace))) +} + +pub async fn open_workspace( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .await?; + let notes = db::list_notes(&state.db, workspace.id) + .await? + .into_iter() + .map(|note| NoteListItem { + url: format!("/w/{}/n/{}", workspace.slug, note.slug), + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + protected: note.protected, + created_by: note.created_by, + }) + .collect(); + + Ok(Json(WorkspaceOpenResponse { + workspace: workspace_info_from(&workspace), + notes, + })) +} + +pub async fn create_note( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .await?; + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + 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", + )); + } + + 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?; + Ok(( + StatusCode::CREATED, + Json(NoteListItem { + url: format!("/w/{workspace_slug}/n/{slug}"), + slug: note.slug, + title: note.title, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + protected: note.protected, + created_by: note.created_by, + }), + )) +} + +fn clean_editor_color(value: Option<&str>) -> Result, ApiError> { + let Some(value) = value else { + return Ok(None); + }; + let value = value.trim(); + if value.len() == 7 + && value.starts_with('#') + && value[1..].chars().all(|c| c.is_ascii_hexdigit()) + { + Ok(Some(value.to_ascii_lowercase())) + } else { + Err(ApiError::bad_request("Invalid editor color")) + } +} + +async fn editor_colors( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, +) -> Result<(Option, Option), ApiError> { + let Some(user) = crate::auth::optional_user(state, headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + else { + return Ok((None, None)); + }; + let global: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::AUTH_EDITOR_COLOR_BY_USER, + )) + .bind(user.id) + .fetch_one(state.db.pool()) + .await?; + let note: Option = sqlx::query_scalar(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_BY_USER, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .fetch_optional(state.db.pool()) + .await?; + Ok((global, note)) +} + +async fn save_editor_color( + state: &SharedState, + headers: &HeaderMap, + kind: &str, + slug: &str, + color: Option<&str>, +) -> Result, ApiError> { + let user = crate::auth::optional_user(state, headers) + .await + .map_err(|e| ApiError::forbidden(&e.message))? + .ok_or_else(|| ApiError::forbidden("Log in to save note colors"))?; + let color = clean_editor_color(color)?; + let mut tx = state.db.pool().begin().await?; + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_DELETE, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .execute(&mut *tx) + .await?; + if let Some(value) = color.as_deref() { + sqlx::query(queries::get( + state.db.kind(), + queries::RESOURCE_COLOR_INSERT, + )) + .bind(user.id) + .bind(kind) + .bind(slug) + .bind(value) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(Json(serde_json::json!({"color": color}))) +} + +pub async fn note_info( + State(state): State, + headers: HeaderMap, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> Result, ApiError> { + let workspace = db::find_workspace(&state.db, &workspace_slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + ensure_private_resource_access( + &state, + "workspace", + &workspace.slug, + workspace.is_private, + bearer_token(&headers), + ) + .await?; + let note = db::find_note(&state.db, workspace.id, ¬e_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + let color_slug = format!("{}/{}", workspace_slug, note_slug); + let (global_color, note_color) = editor_colors(&state, &headers, "note", &color_slug).await?; + + Ok(Json(NoteInfo { + workspace_slug: workspace.slug, + workspace_title: workspace.title, + slug: note.slug, + title: note.title, + protected: workspace.password_hash.is_some(), + note_protected: note.protected, + allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, + created_at: db::normalize_timestamp(¬e.created_at), + updated_at: db::normalize_timestamp(¬e.updated_at), + can_delete_files: { + let workspace_owner = crate::auth::is_resource_owner( + &state, + "workspace", + &workspace_slug, + bearer_token(&headers), + ) + .await + .unwrap_or(false); + let note_owner = crate::auth::optional_user(&state, &headers) + .await + .ok() + .flatten() + .and_then(|user| { + note.created_by + .as_deref() + .map(|creator| creator == user.nickname) + }) + .unwrap_or(false); + workspace_owner || note_owner + }, + global_color, + note_color, + })) +} + +pub async fn history( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .await?; + let _ = workspace; + let revisions = db::list_revisions(&state.db, note.id) + .await? + .into_iter() + .map(|mut revision| { + revision.created_at = db::normalize_timestamp(&revision.created_at); + revision + }) + .collect(); + Ok(Json(revisions)) +} + +pub async fn restore( + State(state): State, + headers: HeaderMap, + 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(), + bearer_token(&headers), + ) + .await?; + let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) + || (workspace.is_private == 0 && workspace.password_hash.is_none()) + { + AccessLevel::Write + } else { + combined_token_access_level( + &state, + "workspace", + &workspace_slug, + payload.access_token.as_deref(), + bearer_token(&headers), + ) + .await? + }; + require_write(level)?; + 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 update = NoteUpdate { + content, + revision_id, + updated_at, + author: Some("restore".into()), + owner_map: "[]".into(), + }; + let _ = state + .note_channel(&workspace_slug, ¬e_slug) + .await + .send(RoomEvent::Document(update)); + Ok(Json(serde_json::json!({"ok": true}))) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum AccessLevel { + None, + Read, + Write, +} + +fn permission_level(permission: Option<&str>) -> AccessLevel { + match permission { + Some("rw") => AccessLevel::Write, + Some("ro") => AccessLevel::Read, + _ => AccessLevel::None, + } +} + +async fn anonymous_access_token_valid( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result { + let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(false); + }; + 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) + .bind(Utc::now().to_rfc3339()) + .fetch_one(state.db.pool()) + .await?; + Ok(count > 0) +} + +async fn token_access_level( + state: &SharedState, + kind: &str, + slug: &str, + token: Option<&str>, +) -> Result { + let permission = crate::auth::resource_permission(state, kind, slug, token) + .await + .map_err(|error| ApiError::forbidden(&error.message))?; + let level = permission_level(permission.as_deref()); + if level != AccessLevel::None { + return Ok(level); + } + if anonymous_access_token_valid(state, kind, slug, token).await? { + // A server-issued token created after a correct resource password + // retains the historical read/write semantics of password access. + return Ok(AccessLevel::Write); + } + Ok(AccessLevel::None) +} + +async fn combined_token_access_level( + state: &SharedState, + kind: &str, + slug: &str, + access_token: Option<&str>, + bearer: Option<&str>, +) -> Result { + Ok(std::cmp::max( + token_access_level(state, kind, slug, access_token).await?, + token_access_level(state, kind, slug, bearer).await?, + )) +} + +fn require_write(level: AccessLevel) -> Result<(), ApiError> { + if level >= AccessLevel::Write { + Ok(()) + } else { + Err(ApiError::forbidden("Read-only access.")) + } +} + +async fn ensure_private_resource_access( + state: &SharedState, + kind: &str, + slug: &str, + is_private: i64, + token: Option<&str>, +) -> Result<(), ApiError> { + if is_private == 0 { + return Ok(()); + } + if verify_resource_access_token(state, kind, slug, token).await? { + return Ok(()); + } + Err(ApiError::forbidden("This resource is private.")) +} + +pub async fn authorized_workspace( + state: &SharedState, + slug: &str, + password: Option<&str>, + access_token: Option<&str>, + bearer: Option<&str>, +) -> Result { + let workspace = db::find_workspace(&state.db, slug) + .await? + .ok_or_else(ApiError::not_found_workspace)?; + let token_level = + combined_token_access_level(state, "workspace", slug, access_token, bearer).await?; + if workspace.is_private != 0 && token_level == AccessLevel::None { + return Err(ApiError::forbidden("This workspace is private.")); + } + if workspace.password_hash.is_some() + && !db::verify_workspace_password(&workspace, password) + && token_level == AccessLevel::None + { + return Err(ApiError::unauthorized()); + } + Ok(workspace) +} + +async fn authorized_note( + state: &SharedState, + workspace_slug: &str, + note_slug: &str, + password: Option<&str>, + access_token: Option<&str>, + bearer: Option<&str>, +) -> Result<(db::Workspace, db::Note), ApiError> { + let workspace = + authorized_workspace(state, workspace_slug, password, access_token, bearer).await?; + let note = db::find_note(&state.db, workspace.id, note_slug) + .await? + .ok_or_else(ApiError::not_found_note)?; + Ok((workspace, note)) +} + +fn workspace_info_from(workspace: &db::Workspace) -> WorkspaceInfo { + WorkspaceInfo { + slug: workspace.slug.clone(), + title: workspace.title.clone(), + protected: workspace.password_hash.is_some(), + created_at: db::normalize_timestamp(&workspace.created_at), + updated_at: db::normalize_timestamp(&workspace.updated_at), + } +} + +fn validate_name<'a>(value: &'a str, field: &str) -> Result<&'a str, ApiError> { + let value = value.trim(); + if value.is_empty() || value.chars().count() > MAX_NAME_LENGTH { + return Err(ApiError::bad_request(&format!( + "{field} must contain between 1 and {MAX_NAME_LENGTH} characters" + ))); + } + Ok(value) +} + +fn validate_password(password: Option<&str>) -> Result, ApiError> { + let Some(password) = password.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let length = password.chars().count(); + if !(MIN_PASSWORD_LENGTH..=MAX_PASSWORD_LENGTH).contains(&length) { + return Err(ApiError::bad_request( + "Password must contain between 8 and 128 characters", + )); + } + Ok(Some(password)) +} + +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", + )); + } + + let needs_suffix = base.chars().count() < MIN_WORKSPACE_SLUG_LENGTH + || db::find_workspace(&state.db, &base).await?.is_some(); + if !needs_suffix { + return Ok(base); + } + + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(8)); + if db::find_workspace(&state.db, &candidate).await?.is_none() { + return Ok(candidate); + } + } + Err(ApiError::internal("Failed to create a unique address")) +} + +async fn unique_note_slug( + state: &SharedState, + workspace_id: i64, + base: &str, +) -> Result { + if db::find_note(&state.db, workspace_id, base) + .await? + .is_none() + { + return Ok(base.to_owned()); + } + for _ in 0..8 { + let candidate = format!("{base}-{}", db::random_suffix(6)); + if db::find_note(&state.db, workspace_id, &candidate) + .await? + .is_none() + { + return Ok(candidate); + } + } + Err(ApiError::internal("Failed to create a unique address")) +} + diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 5b80b1b..0000000 --- a/src/app.rs +++ /dev/null @@ -1,496 +0,0 @@ -use axum::{ - Router, - extract::{DefaultBodyLimit, Path, Request, State}, - http::{HeaderName, HeaderValue, StatusCode, header}, - middleware::{self, Next}, - response::{Html, IntoResponse, Response}, - routing::{get, post}, -}; -use tower::{ServiceBuilder, service_fn}; -use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; - -use crate::{api, assets, 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 { - let asset_version = state.asset_version.clone(); - let asset_not_found = service_fn(move |_request| { - let asset_version = asset_version.clone(); - async move { - Ok::<_, Infallible>(error_response( - StatusCode::NOT_FOUND, - "404", - "File not found", - "The requested application asset does not exist.", - "/", - "Home page", - &asset_version, - )) - } - }); - - 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)) - .route("/p/{slug}", get(pad)) - .route("/s/{token}", get(public_page)) - .route("/w/{workspace_slug}", get(workspace)) - .route("/w/{workspace_slug}/n/{note_slug}", get(note)) - .route("/errors/private-workspace", get(private_workspace_error)) - .route("/health", get(health)) - .route("/robots.txt", get(robots_txt)) - .route("/favicon.ico", get(favicon)) - .route("/f/{token}/{filename}", get(api::download_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)) - .route("/api/auth/login", post(auth::login)) - .route("/api/auth/confirm-account", post(auth::confirm_account)) - .route( - "/api/auth/resend-confirmation", - post(auth::resend_confirmation), - ) - .route("/api/auth/me", get(auth::me)) - .route("/api/auth/profile", post(auth::update_profile)) - .route( - "/api/auth/account/delete", - post(auth::request_account_deletion), - ) - .route( - "/api/auth/account-action/confirm", - post(auth::confirm_account_action), - ) - .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/password-reset", post(auth::request_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)) - .route("/api/pads/{slug}", get(api::pad_info)) - .route("/api/pads/{slug}/history", post(api::pad_history)) - .route( - "/api/pads/{slug}/editor-color", - get(api::pad_editor_color).post(api::set_pad_editor_color), - ) - .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/{file_id}", - axum::routing::delete(api::delete_pad_file), - ) - .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}/notes/{note_slug}", - get(api::note_info).delete(api::delete_note), - ) - .route( - "/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color", - get(api::note_editor_color).post(api::set_note_editor_color), - ) - .route( - "/api/workspaces/{workspace_slug}/notes/{note_slug}/publish", - post(api::publish_note_page), - ) - .route( - "/api/workspaces/{workspace_slug}/notes/{note_slug}/history", - post(api::history), - ) - .route( - "/api/workspaces/{workspace_slug}/notes/{note_slug}/restore", - post(api::restore), - ) - .route( - "/api/workspaces/{workspace_slug}/notes/{note_slug}/files", - post(api::upload_note_file).put(api::note_files), - ) - .route( - "/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}", - 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("/static", get(static_not_found)) - .route("/static/{*path}", get(static_not_found)) - .nest_service( - "/assets", - ServiceBuilder::new() - .layer(SetResponseHeaderLayer::overriding( - header::CACHE_CONTROL, - asset_cache_control, - )) - .service(ServeDir::new(static_dir).not_found_service(asset_not_found)), - ) - .fallback(not_found) - .method_not_allowed_fallback(method_not_allowed) - .layer(DefaultBodyLimit::max( - upload_max_size_bytes.saturating_add(1024 * 1024), - )) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("x-frame-options"), - HeaderValue::from_static("DENY"), - )) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("cross-origin-opener-policy"), - HeaderValue::from_static("same-origin"), - )) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("cross-origin-resource-policy"), - HeaderValue::from_static("same-origin"), - )) - .layer(TraceLayer::new_for_http()) - .layer(middleware::from_fn(add_non_asset_security_headers)) - .with_state(state) -} - -async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { - let is_asset = request.uri().path().starts_with("/assets/"); - let mut response = next.run(request).await; - - if !is_asset { - let headers = response.headers_mut(); - headers - .entry("x-content-type-options") - .or_insert(HeaderValue::from_static("nosniff")); - headers - .entry("referrer-policy") - .or_insert(HeaderValue::from_static("strict-origin-when-cross-origin")); - headers - .entry("permissions-policy") - .or_insert(HeaderValue::from_static( - "camera=(), microphone=(), geolocation=(), payment=(), usb=()", - )); - } - - response -} - -async fn private_workspace_error(State(state): State) -> Response { - error_response( - StatusCode::FORBIDDEN, - "403", - "Private workspace", - "You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.", - "/", - "Home page", - &state.asset_version, - ) -} - -async fn health() -> &'static str { - "ok" -} - -async fn favicon() -> StatusCode { - StatusCode::NO_CONTENT -} - -async fn robots_txt() -> Response { - let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("text/plain; charset=utf-8"), - ); - response -} - -async fn home(State(state): State) -> Response { - assets::render_html( - include_str!("../static/home.html"), - &state.asset_version, - state.registration_enabled, - state.ldap.is_some(), - &state.frontend_log_level, - state.upload_max_size_bytes, - "home", - ) -} - -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)); - assets::render_html( - &html, - &state.asset_version, - state.registration_enabled, - state.ldap.is_some(), - &state.frontend_log_level, - state.upload_max_size_bytes, - "pad", - ) - } - Ok(None) => error_response( - StatusCode::NOT_FOUND, - "404", - "Note not found", - "This note does not exist or has been deleted.", - "/", - "Home page", - &state.asset_version, - ), - Err(error) => { - tracing::error!(%error, %slug, "failed to load standalone pad"); - internal_error(&state.asset_version) - } - } -} - -async fn public_page(State(state): State, Path(token): Path) -> Response { - match db::find_published_page(&state.db, &token).await { - Ok(Some(_)) => assets::render_html( - include_str!("../static/public.html"), - &state.asset_version, - state.registration_enabled, - state.ldap.is_some(), - &state.frontend_log_level, - state.upload_max_size_bytes, - "public", - ), - Ok(None) => error_response( - StatusCode::NOT_FOUND, - "404", - "Published page not found", - "The link is invalid or the published page has been removed.", - "/", - "Home page", - &state.asset_version, - ), - Err(error) => { - tracing::error!(%error, %token, "failed to load published page"); - internal_error(&state.asset_version) - } - } -} - -async fn workspace( - State(state): State, - Path(workspace_slug): Path, -) -> Response { - match db::find_workspace(&state.db, &workspace_slug).await { - Ok(Some(workspace)) => { - let html = include_str!("../static/workspace.html") - .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)); - assets::render_html( - &html, - &state.asset_version, - state.registration_enabled, - state.ldap.is_some(), - &state.frontend_log_level, - state.upload_max_size_bytes, - "workspace", - ) - } - Ok(None) => error_response( - StatusCode::NOT_FOUND, - "404", - "Workspace not found", - "This workspace does not exist or has been deleted.", - "/", - "Home page", - &state.asset_version, - ), - Err(error) => { - tracing::error!(%error, %workspace_slug, "failed to load workspace page"); - internal_error(&state.asset_version) - } - } -} - -async fn note( - State(state): State, - Path((workspace_slug, note_slug)): Path<(String, String)>, -) -> Response { - let workspace = match db::find_workspace(&state.db, &workspace_slug).await { - Ok(Some(workspace)) => workspace, - Ok(None) => { - return error_response( - StatusCode::NOT_FOUND, - "404", - "Workspace not found", - "The workspace for this note does not exist or has been deleted.", - "/", - "Home page", - &state.asset_version, - ); - } - Err(error) => { - tracing::error!(%error, %workspace_slug, "failed to load note workspace"); - return internal_error(&state.asset_version); - } - }; - - match db::find_note(&state.db, workspace.id, ¬e_slug).await { - Ok(Some(note)) => { - let html = include_str!("../static/note.html") - .replace("__NOTE_TITLE__", &escape_html(¬e.title)) - .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) - .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); - assets::render_html( - &html, - &state.asset_version, - state.registration_enabled, - state.ldap.is_some(), - &state.frontend_log_level, - state.upload_max_size_bytes, - "note", - ) - } - Ok(None) => error_response( - StatusCode::NOT_FOUND, - "404", - "Note not found", - "This note does not exist or has been deleted.", - &format!("/w/{workspace_slug}"), - "Back do workspace", - &state.asset_version, - ), - Err(error) => { - tracing::error!(%error, %workspace_slug, %note_slug, "failed to load note page"); - internal_error(&state.asset_version) - } - } -} - -async fn static_not_found(State(state): State) -> Response { - error_response( - StatusCode::NOT_FOUND, - "404", - "File not found", - "The requested static file does not exist.", - "/", - "Home page", - &state.asset_version, - ) -} - -async fn method_not_allowed(State(state): State) -> Response { - error_response( - StatusCode::METHOD_NOT_ALLOWED, - "405", - "Method not allowed", - "This address does not support the requested operation.", - "/", - "Home page", - &state.asset_version, - ) -} - -async fn not_found(State(state): State) -> Response { - error_response( - StatusCode::NOT_FOUND, - "404", - "Page not found", - "Check the address or return to the home page.", - "/", - "Home page", - &state.asset_version, - ) -} - -fn internal_error(asset_version: &str) -> Response { - error_response( - StatusCode::INTERNAL_SERVER_ERROR, - "500", - "Server error", - "The page could not be loaded. Please try again shortly.", - "/", - "Home page", - asset_version, - ) -} - -fn error_response( - status: StatusCode, - code: &str, - title: &str, - message: &str, - primary_url: &str, - primary_label: &str, - asset_version: &str, -) -> Response { - let html = include_str!("../static/error.html") - .replace( - "__APP_STYLESHEET__", - &assets::stylesheet_tag(asset_version, "styles"), - ) - .replace("__ERROR_CODE__", &escape_html(code)) - .replace("__ERROR_TITLE__", &escape_html(title)) - .replace("__ERROR_MESSAGE__", &escape_html(message)) - .replace("__PRIMARY_URL__", &escape_html(primary_url)) - .replace("__PRIMARY_LABEL__", &escape_html(primary_label)); - - let mut response = (status, Html(html)).into_response(); - no_store(&mut response); - response -} - -fn no_store(response: &mut Response) { - response.headers_mut().insert( - header::CACHE_CONTROL, - HeaderValue::from_static("private, no-store"), - ); -} - -fn escape_html(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..482466d --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,223 @@ +mod pages; + +use pages::*; +use axum::{ + Router, + extract::{DefaultBodyLimit, Request}, + http::{HeaderName, HeaderValue, StatusCode, header}, + middleware::{self, Next}, + response::Response, + routing::{get, post}, +}; +use tower::{ServiceBuilder, service_fn}; +use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer, trace::TraceLayer}; + +use crate::{api, auth, 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 { + let asset_version = state.asset_version.clone(); + let asset_not_found = service_fn(move |_request| { + let asset_version = asset_version.clone(); + async move { + Ok::<_, Infallible>(error_response( + StatusCode::NOT_FOUND, + "404", + "File not found", + "The requested application asset does not exist.", + "/", + "Home page", + &asset_version, + )) + } + }); + + 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)) + .route("/p/{slug}", get(pad)) + .route("/s/{token}", get(public_page)) + .route("/w/{workspace_slug}", get(workspace)) + .route("/w/{workspace_slug}/n/{note_slug}", get(note)) + .route("/errors/private-workspace", get(private_workspace_error)) + .route("/health", get(health)) + .route("/robots.txt", get(robots_txt)) + .route("/favicon.ico", get(favicon)) + .route("/f/{token}/{filename}", get(api::download_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)) + .route("/api/auth/login", post(auth::login)) + .route("/api/auth/confirm-account", post(auth::confirm_account)) + .route( + "/api/auth/resend-confirmation", + post(auth::resend_confirmation), + ) + .route("/api/auth/me", get(auth::me)) + .route("/api/auth/profile", post(auth::update_profile)) + .route( + "/api/auth/account/delete", + post(auth::request_account_deletion), + ) + .route( + "/api/auth/account-action/confirm", + post(auth::confirm_account_action), + ) + .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/password-reset", post(auth::request_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)) + .route("/api/pads/{slug}", get(api::pad_info)) + .route("/api/pads/{slug}/history", post(api::pad_history)) + .route( + "/api/pads/{slug}/editor-color", + get(api::pad_editor_color).post(api::set_pad_editor_color), + ) + .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/{file_id}", + axum::routing::delete(api::delete_pad_file), + ) + .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}/notes/{note_slug}", + get(api::note_info).delete(api::delete_note), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/editor-color", + get(api::note_editor_color).post(api::set_note_editor_color), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/publish", + post(api::publish_note_page), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/history", + post(api::history), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/restore", + post(api::restore), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/files", + post(api::upload_note_file).put(api::note_files), + ) + .route( + "/api/workspaces/{workspace_slug}/notes/{note_slug}/files/{file_id}", + 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("/static", get(static_not_found)) + .route("/static/{*path}", get(static_not_found)) + .nest_service( + "/assets", + ServiceBuilder::new() + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + asset_cache_control, + )) + .service(ServeDir::new(static_dir).not_found_service(asset_not_found)), + ) + .fallback(not_found) + .method_not_allowed_fallback(method_not_allowed) + .layer(DefaultBodyLimit::max( + upload_max_size_bytes.saturating_add(1024 * 1024), + )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("cross-origin-opener-policy"), + HeaderValue::from_static("same-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("cross-origin-resource-policy"), + HeaderValue::from_static("same-origin"), + )) + .layer(TraceLayer::new_for_http()) + .layer(middleware::from_fn(add_non_asset_security_headers)) + .with_state(state) +} + +async fn add_non_asset_security_headers(request: Request, next: Next) -> Response { + let is_asset = request.uri().path().starts_with("/assets/"); + let mut response = next.run(request).await; + + if !is_asset { + let headers = response.headers_mut(); + headers + .entry("x-content-type-options") + .or_insert(HeaderValue::from_static("nosniff")); + headers + .entry("referrer-policy") + .or_insert(HeaderValue::from_static("strict-origin-when-cross-origin")); + headers + .entry("permissions-policy") + .or_insert(HeaderValue::from_static( + "camera=(), microphone=(), geolocation=(), payment=(), usb=()", + )); + } + + response +} + diff --git a/src/app/pages.rs b/src/app/pages.rs new file mode 100644 index 0000000..17c13eb --- /dev/null +++ b/src/app/pages.rs @@ -0,0 +1,284 @@ +use axum::{ + extract::{Path, State}, + http::{HeaderValue, StatusCode, header}, + response::{Html, IntoResponse, Response}, +}; + +use crate::{assets, db, state::SharedState}; + +pub(super) async fn private_workspace_error(State(state): State) -> Response { + error_response( + StatusCode::FORBIDDEN, + "403", + "Private workspace", + "You do not have permission to access this private workspace. Ask the owner to share it with your account or use a valid share link.", + "/", + "Home page", + &state.asset_version, + ) +} + +pub(super) async fn health() -> &'static str { + "ok" +} + +pub(super) async fn favicon() -> StatusCode { + StatusCode::NO_CONTENT +} + +pub(super) async fn robots_txt() -> Response { + let mut response = "User-agent: *\nDisallow: /f/\nDisallow: /files/\n".into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + ); + response +} + +pub(super) async fn home(State(state): State) -> Response { + assets::render_html( + include_str!("../../static/home.html"), + &state.asset_version, + state.registration_enabled, + state.ldap.is_some(), + &state.frontend_log_level, + state.upload_max_size_bytes, + "home", + ) +} + +pub(super) 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)); + assets::render_html( + &html, + &state.asset_version, + state.registration_enabled, + state.ldap.is_some(), + &state.frontend_log_level, + state.upload_max_size_bytes, + "pad", + ) + } + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Note not found", + "This note does not exist or has been deleted.", + "/", + "Home page", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %slug, "failed to load standalone pad"); + internal_error(&state.asset_version) + } + } +} + +pub(super) async fn public_page(State(state): State, Path(token): Path) -> Response { + match db::find_published_page(&state.db, &token).await { + Ok(Some(_)) => assets::render_html( + include_str!("../../static/public.html"), + &state.asset_version, + state.registration_enabled, + state.ldap.is_some(), + &state.frontend_log_level, + state.upload_max_size_bytes, + "public", + ), + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Published page not found", + "The link is invalid or the published page has been removed.", + "/", + "Home page", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %token, "failed to load published page"); + internal_error(&state.asset_version) + } + } +} + +pub(super) async fn workspace( + State(state): State, + Path(workspace_slug): Path, +) -> Response { + match db::find_workspace(&state.db, &workspace_slug).await { + Ok(Some(workspace)) => { + let html = include_str!("../../static/workspace.html") + .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)); + assets::render_html( + &html, + &state.asset_version, + state.registration_enabled, + state.ldap.is_some(), + &state.frontend_log_level, + state.upload_max_size_bytes, + "workspace", + ) + } + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Workspace not found", + "This workspace does not exist or has been deleted.", + "/", + "Home page", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %workspace_slug, "failed to load workspace page"); + internal_error(&state.asset_version) + } + } +} + +pub(super) async fn note( + State(state): State, + Path((workspace_slug, note_slug)): Path<(String, String)>, +) -> Response { + let workspace = match db::find_workspace(&state.db, &workspace_slug).await { + Ok(Some(workspace)) => workspace, + Ok(None) => { + return error_response( + StatusCode::NOT_FOUND, + "404", + "Workspace not found", + "The workspace for this note does not exist or has been deleted.", + "/", + "Home page", + &state.asset_version, + ); + } + Err(error) => { + tracing::error!(%error, %workspace_slug, "failed to load note workspace"); + return internal_error(&state.asset_version); + } + }; + + match db::find_note(&state.db, workspace.id, ¬e_slug).await { + Ok(Some(note)) => { + let html = include_str!("../../static/note.html") + .replace("__NOTE_TITLE__", &escape_html(¬e.title)) + .replace("__WORKSPACE_TITLE__", &escape_html(&workspace.title)) + .replace("__WORKSPACE_SLUG__", &escape_html(&workspace_slug)); + assets::render_html( + &html, + &state.asset_version, + state.registration_enabled, + state.ldap.is_some(), + &state.frontend_log_level, + state.upload_max_size_bytes, + "note", + ) + } + Ok(None) => error_response( + StatusCode::NOT_FOUND, + "404", + "Note not found", + "This note does not exist or has been deleted.", + &format!("/w/{workspace_slug}"), + "Back do workspace", + &state.asset_version, + ), + Err(error) => { + tracing::error!(%error, %workspace_slug, %note_slug, "failed to load note page"); + internal_error(&state.asset_version) + } + } +} + +pub(super) async fn static_not_found(State(state): State) -> Response { + error_response( + StatusCode::NOT_FOUND, + "404", + "File not found", + "The requested static file does not exist.", + "/", + "Home page", + &state.asset_version, + ) +} + +pub(super) async fn method_not_allowed(State(state): State) -> Response { + error_response( + StatusCode::METHOD_NOT_ALLOWED, + "405", + "Method not allowed", + "This address does not support the requested operation.", + "/", + "Home page", + &state.asset_version, + ) +} + +pub(super) async fn not_found(State(state): State) -> Response { + error_response( + StatusCode::NOT_FOUND, + "404", + "Page not found", + "Check the address or return to the home page.", + "/", + "Home page", + &state.asset_version, + ) +} + +fn internal_error(asset_version: &str) -> Response { + error_response( + StatusCode::INTERNAL_SERVER_ERROR, + "500", + "Server error", + "The page could not be loaded. Please try again shortly.", + "/", + "Home page", + asset_version, + ) +} + +pub(super) fn error_response( + status: StatusCode, + code: &str, + title: &str, + message: &str, + primary_url: &str, + primary_label: &str, + asset_version: &str, +) -> Response { + let html = include_str!("../../static/error.html") + .replace( + "__APP_STYLESHEET__", + &assets::stylesheet_tag(asset_version, "styles"), + ) + .replace("__ERROR_CODE__", &escape_html(code)) + .replace("__ERROR_TITLE__", &escape_html(title)) + .replace("__ERROR_MESSAGE__", &escape_html(message)) + .replace("__PRIMARY_URL__", &escape_html(primary_url)) + .replace("__PRIMARY_LABEL__", &escape_html(primary_label)); + + let mut response = (status, Html(html)).into_response(); + no_store(&mut response); + response +} + +fn no_store(response: &mut Response) { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); +} + +fn escape_html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 462e0e8..0780ac9 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -20,7 +20,7 @@ use lettre::{ use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use sqlx::{any::AnyRow, Row}; +use sqlx::{Row, any::AnyRow}; use tracing::{debug, info, warn}; use crate::{ @@ -2113,7 +2113,7 @@ async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Resul } .port(smtp.port); - if !smtp.username.is_empty() { + if !smtp.username.trim().is_empty() && !smtp.password.is_empty() { builder = builder.credentials(Credentials::new( smtp.username.clone(), smtp.password.clone(), diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index c7f49c3..0000000 --- a/src/config.rs +++ /dev/null @@ -1,459 +0,0 @@ -use lettre::message::Mailbox; -use std::{collections::HashMap, env, net::IpAddr, path::Path}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthorizationType { - Local, - Ldap, - Ad, -} - -impl AuthorizationType { - fn from_values(values: &ConfigValues) -> Result> { - match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() { - "local" => Ok(Self::Local), - "ldap" => Ok(Self::Ldap), - "ad" => Ok(Self::Ad), - _ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()), - } - } - - pub fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Ldap => "ldap", - Self::Ad => "ad", - } - } -} - -#[derive(Debug, Clone)] -pub struct Config { - pub host: IpAddr, - pub port: u16, - pub database_url: String, - 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, - pub file_cache_max_age_seconds: u64, - 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, - pub unconfirmed_account_ttl_days: i64, - pub authorization_type: AuthorizationType, - pub ldap: Option, -} - -impl Config { - pub fn load(path: Option<&Path>) -> Result> { - let values = ConfigValues::load(path)?; - let host = values.get("APP_HOST", "127.0.0.1").parse()?; - let port = values.get("APP_PORT", "3000").parse()?; - let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?; - let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?; - let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; - let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?; - let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?; - let files_dir = values.get("FILES_DIR", "data/files"); - - let storage = match values.get("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: values.optional("S3_ENDPOINT"), - region: values.get("S3_REGION", "us-east-1"), - bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?, - access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?, - secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?, - force_path_style: values.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()); - } - - let authorization_type = AuthorizationType::from_values(&values)?; - let ldap = match authorization_type { - AuthorizationType::Local => None, - AuthorizationType::Ldap | AuthorizationType::Ad => { - let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str()); - let (default_filter, default_username_attribute) = match authorization_type { - AuthorizationType::Ldap => ("(uid={username})", "uid"), - AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"), - AuthorizationType::Local => unreachable!(), - }; - Some(crate::auth::ldap::LdapConfig { - url: values.required("LDAP_URL", &context)?, - starttls: values.bool("LDAP_STARTTLS", false)?, - bind_dn: values.get("LDAP_BIND_DN", ""), - bind_password: values.get("LDAP_BIND_PASSWORD", ""), - base_dn: values.required("LDAP_BASE_DN", &context)?, - user_filter: values.get("LDAP_USER_FILTER", default_filter), - username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute), - email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"), - display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"), - external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type { - AuthorizationType::Ldap => "entryUUID", - AuthorizationType::Ad => "objectGUID", - AuthorizationType::Local => unreachable!(), - }), - organization: values.get("LDAP_ORGANIZATION", "organization"), - provider: authorization_type.as_str().to_owned(), - email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?, - link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?, - tls_verify: values.bool("LDAP_TLS_VERIFY", true)?, - connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?, - operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?, - }) - } - }; - - let smtp = if let Some(host) = values.optional("SMTP_HOST") { - Some(crate::state::SmtpConfig { - host, - port: values.get("SMTP_PORT", "587").parse()?, - security: parse_smtp_security(&values.get( - "SMTP_SECURITY", - if values.get("SMTP_PORT", "587") == "465" { "tls" } else { "starttls" }, - ))?, - username: values.get("SMTP_USERNAME", ""), - password: values.get("SMTP_PASSWORD", ""), - from: normalize_smtp_from( - values.required("SMTP_FROM", "SMTP_HOST is set")?, - )?, - public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?, - }) - } else { - None - }; - - let config = Self { - host, - port, - database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"), - database_max_connections, - static_dir: values.get("STATIC_DIR", "static"), - files_dir, - storage, - upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?, - asset_version: env!("CARGO_PKG_VERSION").to_owned(), - asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, - file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, - smtp, - registration_enabled: values.bool("REGISTRATION_ENABLED", false)?, - account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, - share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?, - frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?, - anonymous_access_token_ttl_days, - user_session_ttl_days, - unconfirmed_account_ttl_days, - authorization_type, - ldap, - }; - config.validate()?; - Ok(config) - } - - fn validate(&self) -> Result<(), Box> { - if !(self.database_url.starts_with("sqlite:") - || self.database_url.starts_with("postgres:") - || self.database_url.starts_with("postgresql:") - || self.database_url.starts_with("mysql:")) - { - return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into()); - } - if self.database_max_connections == 0 { - return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into()); - } - if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() { - return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); - } - if let Some(smtp) = &self.smtp { - smtp.from - .parse::() - .map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?; - if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) { - return Err("PUBLIC_URL must start with http:// or https://".into()); - } - } - Ok(()) - } -} - - - -fn parse_smtp_security(value: &str) -> Result> { - match value.trim().to_ascii_lowercase().as_str() { - "none" | "plain" => Ok(crate::state::SmtpSecurity::None), - "starttls" => Ok(crate::state::SmtpSecurity::StartTls), - "tls" | "ssl" | "smtps" => Ok(crate::state::SmtpSecurity::Tls), - _ => Err("SMTP_SECURITY must be one of: none, starttls, tls".into()), - } -} - -fn normalize_smtp_from(value: String) -> Result> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err("SMTP_FROM cannot be empty".into()); - } - - if trimmed.parse::().is_ok() { - return Ok(trimmed.to_owned()); - } - - let unquoted = match (trimmed.as_bytes().first(), trimmed.as_bytes().last()) { - (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) if trimmed.len() >= 2 => { - trimmed[1..trimmed.len() - 1].trim() - } - _ => { - return Err(format!("SMTP_FROM is not a valid mailbox: {trimmed}").into()); - } - }; - - if unquoted.is_empty() { - return Err("SMTP_FROM cannot be empty".into()); - } - - unquoted - .parse::() - .map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?; - - Ok(unquoted.to_owned()) -} - -const KNOWN_CONFIG_KEYS: &[&str] = &[ - "APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", - "STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", - "ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS", - "REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED", - "FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS", - "UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE", - "S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", - "S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_SECURITY", "SMTP_USERNAME", "SMTP_PASSWORD", - "SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN", - "LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE", - "LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE", - "LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL", - "LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS", -]; - -#[derive(Default)] -struct ConfigValues { - file: HashMap, -} - -impl ConfigValues { - fn load(path: Option<&Path>) -> Result> { - let Some(path) = path else { return Ok(Self::default()); }; - let content = std::fs::read_to_string(path) - .map_err(|error| format!("cannot read config file {}: {error}", path.display()))?; - let file = parse_yaml_config(&content) - .map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?; - for key in file.keys() { - if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) { - return Err(format!("unknown configuration key: {key}").into()); - } - } - Ok(Self { file }) - } - - fn get(&self, name: &str, default: &str) -> String { - env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned()) - } - - fn optional(&self, name: &str) -> Option { - env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty()) - } - - fn required(&self, name: &str, context: &str) -> Result> { - self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into()) - } - - fn bool(&self, name: &str, default: bool) -> Result> { - match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Ok(true), - "0" | "false" | "no" | "off" => Ok(false), - _ => Err(format!("{name} must be true or false").into()), - } - } - - fn log_level(&self, name: &str, default: &str) -> Result> { - let value = self.get(name, default).trim().to_ascii_lowercase(); - match value.as_str() { - "off" | "error" | "warn" | "info" | "debug" => Ok(value), - _ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()), - } - } - - fn positive_i64(&self, name: &str, default: i64) -> Result> { - let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?; - if value <= 0 { return Err(format!("{name} must be greater than 0").into()); } - Ok(value) - } - - fn positive_u64(&self, name: &str, default: u64) -> Result> { - let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?; - if value == 0 { return Err(format!("{name} must be greater than 0").into()); } - Ok(value) - } - - fn nonnegative_u64(&self, name: &str, default: u64) -> Result> { - self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into()) - } -} - -fn parse_yaml_config(content: &str) -> Result, String> { - let mut output = HashMap::new(); - let mut sections: Vec<(usize, String)> = Vec::new(); - - for (index, original) in content.lines().enumerate() { - let line_number = index + 1; - if original.contains('\t') { - return Err(format!("line {line_number}: tabs are not allowed for indentation")); - } - let without_comment = strip_yaml_comment(original); - if without_comment.trim().is_empty() || without_comment.trim() == "---" { - continue; - } - let indent = without_comment.len() - without_comment.trim_start().len(); - let line = without_comment.trim(); - let (raw_key, raw_value) = line - .split_once(':') - .ok_or_else(|| format!("line {line_number}: expected key: value"))?; - let key = raw_key.trim(); - if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { - return Err(format!("line {line_number}: invalid key {key:?}")); - } - while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) { - sections.pop(); - } - let normalized = key.to_ascii_uppercase().replace('-', "_"); - let value = raw_value.trim(); - if value.is_empty() { - sections.push((indent, normalized)); - continue; - } - if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) { - return Err(format!("line {line_number}: only scalar values and nested mappings are supported")); - } - let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect(); - path.push(&normalized); - let full_key = path.join("_"); - let parsed_value = parse_yaml_scalar(value) - .map_err(|error| format!("line {line_number}: {error}"))?; - if output.insert(full_key.clone(), parsed_value).is_some() { - return Err(format!("line {line_number}: duplicate key {full_key}")); - } - } - Ok(output) -} - -fn strip_yaml_comment(line: &str) -> &str { - let mut single = false; - let mut double = false; - let mut escaped = false; - for (index, character) in line.char_indices() { - if escaped { - escaped = false; - continue; - } - match character { - '\\' if double => escaped = true, - '\'' if !double => single = !single, - '"' if !single => double = !double, - '#' if !single && !double => return &line[..index], - _ => {} - } - } - line -} - -fn parse_yaml_scalar(value: &str) -> Result { - if value.starts_with('"') { - if !value.ends_with('"') || value.len() < 2 { - return Err("unterminated double-quoted value".to_owned()); - } - return serde_json::from_str::(value) - .map_err(|error| format!("invalid double-quoted value: {error}")); - } - if value.starts_with('\'') { - if !value.ends_with('\'') || value.len() < 2 { - return Err("unterminated single-quoted value".to_owned()); - } - return Ok(value[1..value.len() - 1].replace("''", "'")); - } - if value.eq_ignore_ascii_case("null") || value == "~" { - return Ok(String::new()); - } - Ok(value.to_owned()) -} - - -#[cfg(test)] -mod tests { - use super::{normalize_smtp_from, parse_smtp_security}; - use crate::state::SmtpSecurity; - - #[test] - fn smtp_security_accepts_supported_modes() { - assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None); - assert_eq!(parse_smtp_security("starttls").unwrap(), SmtpSecurity::StartTls); - assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls); - } - - #[test] - fn smtp_security_rejects_unknown_mode() { - assert!(parse_smtp_security("auto").is_err()); - } - - #[test] - fn smtp_from_accepts_unquoted_value() { - assert_eq!( - normalize_smtp_from("RustPad ".to_owned()).unwrap(), - "RustPad " - ); - } - - #[test] - fn smtp_from_removes_matching_double_quotes() { - assert_eq!( - normalize_smtp_from(" \"RustPad \" ".to_owned()).unwrap(), - "RustPad " - ); - } - - #[test] - fn smtp_from_removes_matching_single_quotes() { - assert_eq!( - normalize_smtp_from(" 'RustPad ' ".to_owned()).unwrap(), - "RustPad " - ); - } - - #[test] - fn smtp_from_preserves_valid_quoted_display_name() { - assert_eq!( - normalize_smtp_from("\"Rust, Pad\" ".to_owned()).unwrap(), - "\"Rust, Pad\" " - ); - } - - #[test] - fn smtp_from_rejects_unmatched_quotes() { - assert!(normalize_smtp_from("\"RustPad ".to_owned()).is_err()); - } - - #[test] - fn smtp_from_rejects_invalid_mailbox() { - assert!(normalize_smtp_from("RustPad".to_owned()).is_err()); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..688ee11 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,176 @@ +mod smtp; +mod values; + +use std::{net::IpAddr, path::Path}; +use smtp::load_smtp; +use values::ConfigValues; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthorizationType { + Local, + Ldap, + Ad, +} + +impl AuthorizationType { + fn from_values(values: &ConfigValues) -> Result> { + match values.get("AUTHORIZATION_TYPE", "local").trim().to_ascii_lowercase().as_str() { + "local" => Ok(Self::Local), + "ldap" => Ok(Self::Ldap), + "ad" => Ok(Self::Ad), + _ => Err("AUTHORIZATION_TYPE must be one of: local, ldap, ad".into()), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Local => "local", + Self::Ldap => "ldap", + Self::Ad => "ad", + } + } +} + +#[derive(Debug, Clone)] +pub struct Config { + pub host: IpAddr, + pub port: u16, + pub database_url: String, + 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, + pub file_cache_max_age_seconds: u64, + 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, + pub unconfirmed_account_ttl_days: i64, + pub authorization_type: AuthorizationType, + pub ldap: Option, +} + +impl Config { + pub fn load(path: Option<&Path>) -> Result> { + let values = ConfigValues::load(path)?; + let host = values.get("APP_HOST", "127.0.0.1").parse()?; + let port = values.get("APP_PORT", "3000").parse()?; + let database_max_connections = values.get("DATABASE_MAX_CONNECTIONS", "8").parse()?; + let upload_max_size_mb: usize = values.get("UPLOAD_MAX_SIZE_MB", "20").parse()?; + let anonymous_access_token_ttl_days = values.positive_i64("ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", 7)?; + let user_session_ttl_days = values.positive_i64("USER_SESSION_TTL_DAYS", 3)?; + let unconfirmed_account_ttl_days = values.positive_i64("UNCONFIRMED_ACCOUNT_TTL_DAYS", 3)?; + let files_dir = values.get("FILES_DIR", "data/files"); + + let storage = match values.get("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: values.optional("S3_ENDPOINT"), + region: values.get("S3_REGION", "us-east-1"), + bucket: values.required("S3_BUCKET", "STORAGE_DRIVER=s3")?, + access_key: values.required("S3_ACCESS_KEY", "STORAGE_DRIVER=s3")?, + secret_key: values.required("S3_SECRET_KEY", "STORAGE_DRIVER=s3")?, + force_path_style: values.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()); + } + + let authorization_type = AuthorizationType::from_values(&values)?; + let ldap = match authorization_type { + AuthorizationType::Local => None, + AuthorizationType::Ldap | AuthorizationType::Ad => { + let context = format!("AUTHORIZATION_TYPE={}", authorization_type.as_str()); + let (default_filter, default_username_attribute) = match authorization_type { + AuthorizationType::Ldap => ("(uid={username})", "uid"), + AuthorizationType::Ad => ("(|(sAMAccountName={username})(userPrincipalName={username}))", "sAMAccountName"), + AuthorizationType::Local => unreachable!(), + }; + Some(crate::auth::ldap::LdapConfig { + url: values.required("LDAP_URL", &context)?, + starttls: values.bool("LDAP_STARTTLS", false)?, + bind_dn: values.get("LDAP_BIND_DN", ""), + bind_password: values.get("LDAP_BIND_PASSWORD", ""), + base_dn: values.required("LDAP_BASE_DN", &context)?, + user_filter: values.get("LDAP_USER_FILTER", default_filter), + username_attribute: values.get("LDAP_USERNAME_ATTRIBUTE", default_username_attribute), + email_attribute: values.get("LDAP_EMAIL_ATTRIBUTE", "mail"), + display_name_attribute: values.get("LDAP_DISPLAY_NAME_ATTRIBUTE", "displayName"), + external_id_attribute: values.get("LDAP_EXTERNAL_ID_ATTRIBUTE", match authorization_type { + AuthorizationType::Ldap => "entryUUID", + AuthorizationType::Ad => "objectGUID", + AuthorizationType::Local => unreachable!(), + }), + organization: values.get("LDAP_ORGANIZATION", "organization"), + provider: authorization_type.as_str().to_owned(), + email_required: values.bool("LDAP_EMAIL_REQUIRED", true)?, + link_existing_by_email: values.bool("LDAP_LINK_EXISTING_BY_EMAIL", false)?, + tls_verify: values.bool("LDAP_TLS_VERIFY", true)?, + connect_timeout_seconds: values.positive_u64("LDAP_CONNECT_TIMEOUT_SECONDS", 5)?, + operation_timeout_seconds: values.positive_u64("LDAP_OPERATION_TIMEOUT_SECONDS", 10)?, + }) + } + }; + + let smtp = load_smtp(&values)?; + + let config = Self { + host, + port, + database_url: values.get("DATABASE_URL", "sqlite:///data/db/rustpad.db?mode=rwc"), + database_max_connections, + static_dir: values.get("STATIC_DIR", "static"), + files_dir, + storage, + upload_max_size_bytes: upload_max_size_mb.checked_mul(1024 * 1024).ok_or("UPLOAD_MAX_SIZE_MB is too large")?, + asset_version: env!("CARGO_PKG_VERSION").to_owned(), + asset_cache_max_age_seconds: values.nonnegative_u64("ASSET_CACHE_MAX_AGE_SECONDS", 600)?, + file_cache_max_age_seconds: values.nonnegative_u64("FILE_CACHE_MAX_AGE_SECONDS", 600)?, + smtp, + registration_enabled: values.bool("REGISTRATION_ENABLED", false)?, + account_confirmation_required: values.bool("ACCOUNT_CONFIRMATION_REQUIRED", false)?, + share_confirmation_required: values.bool("SHARE_CONFIRMATION_REQUIRED", false)?, + frontend_log_level: values.log_level("FRONTEND_LOG_LEVEL", "warn")?, + anonymous_access_token_ttl_days, + user_session_ttl_days, + unconfirmed_account_ttl_days, + authorization_type, + ldap, + }; + config.validate()?; + Ok(config) + } + + fn validate(&self) -> Result<(), Box> { + if !(self.database_url.starts_with("sqlite:") + || self.database_url.starts_with("postgres:") + || self.database_url.starts_with("postgresql:") + || self.database_url.starts_with("mysql:")) + { + return Err("DATABASE_URL must use sqlite, postgres/postgresql, or mysql".into()); + } + if self.database_max_connections == 0 { + return Err("DATABASE_MAX_CONNECTIONS must be greater than 0".into()); + } + if self.static_dir.trim().is_empty() || self.files_dir.trim().is_empty() { + return Err("STATIC_DIR and FILES_DIR cannot be empty".into()); + } + if let Some(smtp) = &self.smtp { + if !(smtp.public_url.starts_with("http://") || smtp.public_url.starts_with("https://")) { + return Err("PUBLIC_URL must start with http:// or https://".into()); + } + } + Ok(()) + } +} + + diff --git a/src/config/smtp.rs b/src/config/smtp.rs new file mode 100644 index 0000000..39ebb4c --- /dev/null +++ b/src/config/smtp.rs @@ -0,0 +1,100 @@ +use lettre::message::Mailbox; + +use crate::state::{SmtpConfig, SmtpSecurity}; + +use super::values::ConfigValues; + +pub(super) fn load_smtp( + values: &ConfigValues, +) -> Result, Box> { + let Some(host) = values.optional("SMTP_HOST") else { + return Ok(None); + }; + + let port = values.get("SMTP_PORT", "587").parse()?; + let security = match values.optional("SMTP_SECURITY") { + Some(value) => parse_smtp_security(&value)?, + None => smtp_security_for_port(port), + }; + + Ok(Some(SmtpConfig { + host, + port, + security, + username: values.get("SMTP_USERNAME", ""), + password: values.get("SMTP_PASSWORD", ""), + from: normalize_smtp_from(values.required("SMTP_FROM", "SMTP_HOST is set")?)?, + public_url: values.required("PUBLIC_URL", "SMTP_HOST is set")?, + })) +} + +fn smtp_security_for_port(port: u16) -> SmtpSecurity { + match port { + 465 => SmtpSecurity::Tls, + 587 => SmtpSecurity::StartTls, + _ => SmtpSecurity::None, + } +} + +fn parse_smtp_security(value: &str) -> Result> { + match value.trim().to_ascii_lowercase().as_str() { + "none" | "plain" => Ok(SmtpSecurity::None), + "starttls" => Ok(SmtpSecurity::StartTls), + "tls" | "ssl" | "smtps" => Ok(SmtpSecurity::Tls), + _ => Err("SMTP_SECURITY must be one of: none, starttls, tls".into()), + } +} + +fn normalize_smtp_from(value: String) -> Result> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("SMTP_FROM cannot be empty".into()); + } + if trimmed.parse::().is_ok() { + return Ok(trimmed.to_owned()); + } + + let unquoted = match (trimmed.as_bytes().first(), trimmed.as_bytes().last()) { + (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) if trimmed.len() >= 2 => { + trimmed[1..trimmed.len() - 1].trim() + } + _ => return Err(format!("SMTP_FROM is not a valid mailbox: {trimmed}").into()), + }; + if unquoted.is_empty() { + return Err("SMTP_FROM cannot be empty".into()); + } + unquoted + .parse::() + .map_err(|error| format!("SMTP_FROM is not a valid mailbox: {error}"))?; + Ok(unquoted.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_security_from_standard_ports() { + assert_eq!(smtp_security_for_port(25), SmtpSecurity::None); + assert_eq!(smtp_security_for_port(465), SmtpSecurity::Tls); + assert_eq!(smtp_security_for_port(587), SmtpSecurity::StartTls); + assert_eq!(smtp_security_for_port(2525), SmtpSecurity::None); + } + + #[test] + fn accepts_supported_security_modes() { + assert_eq!(parse_smtp_security("none").unwrap(), SmtpSecurity::None); + assert_eq!(parse_smtp_security("starttls").unwrap(), SmtpSecurity::StartTls); + assert_eq!(parse_smtp_security("tls").unwrap(), SmtpSecurity::Tls); + assert!(parse_smtp_security("auto").is_err()); + } + + #[test] + fn normalizes_smtp_from() { + assert_eq!( + normalize_smtp_from(" \"RustPad \" ".to_owned()).unwrap(), + "RustPad " + ); + assert!(normalize_smtp_from("RustPad".to_owned()).is_err()); + } +} diff --git a/src/config/values.rs b/src/config/values.rs new file mode 100644 index 0000000..83e8e43 --- /dev/null +++ b/src/config/values.rs @@ -0,0 +1,169 @@ +use std::{collections::HashMap, env, path::Path}; + +const KNOWN_CONFIG_KEYS: &[&str] = &[ + "APP_HOST", "APP_PORT", "DATABASE_URL", "DATABASE_MAX_CONNECTIONS", + "STATIC_DIR", "FILES_DIR", "STORAGE_DRIVER", "UPLOAD_MAX_SIZE_MB", + "ASSET_CACHE_MAX_AGE_SECONDS", "FILE_CACHE_MAX_AGE_SECONDS", + "REGISTRATION_ENABLED", "ACCOUNT_CONFIRMATION_REQUIRED", "SHARE_CONFIRMATION_REQUIRED", + "FRONTEND_LOG_LEVEL", "ANONYMOUS_ACCESS_TOKEN_TTL_DAYS", "USER_SESSION_TTL_DAYS", + "UNCONFIRMED_ACCOUNT_TTL_DAYS", "AUTHORIZATION_TYPE", + "S3_ENDPOINT", "S3_REGION", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", + "S3_FORCE_PATH_STYLE", "SMTP_HOST", "SMTP_PORT", "SMTP_SECURITY", "SMTP_USERNAME", "SMTP_PASSWORD", + "SMTP_FROM", "PUBLIC_URL", "LDAP_URL", "LDAP_STARTTLS", "LDAP_BIND_DN", + "LDAP_BIND_PASSWORD", "LDAP_BASE_DN", "LDAP_USER_FILTER", "LDAP_USERNAME_ATTRIBUTE", + "LDAP_EMAIL_ATTRIBUTE", "LDAP_DISPLAY_NAME_ATTRIBUTE", "LDAP_EXTERNAL_ID_ATTRIBUTE", + "LDAP_ORGANIZATION", "LDAP_EMAIL_REQUIRED", "LDAP_LINK_EXISTING_BY_EMAIL", + "LDAP_TLS_VERIFY", "LDAP_CONNECT_TIMEOUT_SECONDS", "LDAP_OPERATION_TIMEOUT_SECONDS", +]; + +#[derive(Default)] +pub(super) struct ConfigValues { + file: HashMap, +} + +impl ConfigValues { + pub(super) fn load(path: Option<&Path>) -> Result> { + let Some(path) = path else { return Ok(Self::default()); }; + let content = std::fs::read_to_string(path) + .map_err(|error| format!("cannot read config file {}: {error}", path.display()))?; + let file = parse_yaml_config(&content) + .map_err(|error| format!("invalid YAML in {}: {error}", path.display()))?; + for key in file.keys() { + if !KNOWN_CONFIG_KEYS.contains(&key.as_str()) { + return Err(format!("unknown configuration key: {key}").into()); + } + } + Ok(Self { file }) + } + + pub(super) fn get(&self, name: &str, default: &str) -> String { + env::var(name).ok().or_else(|| self.file.get(name).cloned()).unwrap_or_else(|| default.to_owned()) + } + + pub(super) fn optional(&self, name: &str) -> Option { + env::var(name).ok().or_else(|| self.file.get(name).cloned()).filter(|value| !value.trim().is_empty()) + } + + pub(super) fn required(&self, name: &str, context: &str) -> Result> { + self.optional(name).ok_or_else(|| format!("{name} is required when {context}").into()) + } + + pub(super) fn bool(&self, name: &str, default: bool) -> Result> { + match self.get(name, if default { "true" } else { "false" }).trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Ok(true), + "0" | "false" | "no" | "off" => Ok(false), + _ => Err(format!("{name} must be true or false").into()), + } + } + + pub(super) fn log_level(&self, name: &str, default: &str) -> Result> { + let value = self.get(name, default).trim().to_ascii_lowercase(); + match value.as_str() { + "off" | "error" | "warn" | "info" | "debug" => Ok(value), + _ => Err(format!("{name} must be one of: off, error, warn, info, debug").into()), + } + } + + pub(super) fn positive_i64(&self, name: &str, default: i64) -> Result> { + let value: i64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be an integer"))?; + if value <= 0 { return Err(format!("{name} must be greater than 0").into()); } + Ok(value) + } + + pub(super) fn positive_u64(&self, name: &str, default: u64) -> Result> { + let value: u64 = self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer"))?; + if value == 0 { return Err(format!("{name} must be greater than 0").into()); } + Ok(value) + } + + pub(super) fn nonnegative_u64(&self, name: &str, default: u64) -> Result> { + self.get(name, &default.to_string()).parse().map_err(|_| format!("{name} must be a non-negative integer").into()) + } +} + +fn parse_yaml_config(content: &str) -> Result, String> { + let mut output = HashMap::new(); + let mut sections: Vec<(usize, String)> = Vec::new(); + + for (index, original) in content.lines().enumerate() { + let line_number = index + 1; + if original.contains('\t') { + return Err(format!("line {line_number}: tabs are not allowed for indentation")); + } + let without_comment = strip_yaml_comment(original); + if without_comment.trim().is_empty() || without_comment.trim() == "---" { + continue; + } + let indent = without_comment.len() - without_comment.trim_start().len(); + let line = without_comment.trim(); + let (raw_key, raw_value) = line + .split_once(':') + .ok_or_else(|| format!("line {line_number}: expected key: value"))?; + let key = raw_key.trim(); + if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return Err(format!("line {line_number}: invalid key {key:?}")); + } + while sections.last().is_some_and(|(section_indent, _)| *section_indent >= indent) { + sections.pop(); + } + let normalized = key.to_ascii_uppercase().replace('-', "_"); + let value = raw_value.trim(); + if value.is_empty() { + sections.push((indent, normalized)); + continue; + } + if matches!(value.chars().next(), Some('[' | '{' | '|' | '>' | '&' | '*' | '!')) { + return Err(format!("line {line_number}: only scalar values and nested mappings are supported")); + } + let mut path: Vec<&str> = sections.iter().map(|(_, key)| key.as_str()).collect(); + path.push(&normalized); + let full_key = path.join("_"); + let parsed_value = parse_yaml_scalar(value) + .map_err(|error| format!("line {line_number}: {error}"))?; + if output.insert(full_key.clone(), parsed_value).is_some() { + return Err(format!("line {line_number}: duplicate key {full_key}")); + } + } + Ok(output) +} + +fn strip_yaml_comment(line: &str) -> &str { + let mut single = false; + let mut double = false; + let mut escaped = false; + for (index, character) in line.char_indices() { + if escaped { + escaped = false; + continue; + } + match character { + '\\' if double => escaped = true, + '\'' if !double => single = !single, + '"' if !single => double = !double, + '#' if !single && !double => return &line[..index], + _ => {} + } + } + line +} + +fn parse_yaml_scalar(value: &str) -> Result { + if value.starts_with('"') { + if !value.ends_with('"') || value.len() < 2 { + return Err("unterminated double-quoted value".to_owned()); + } + return serde_json::from_str::(value) + .map_err(|error| format!("invalid double-quoted value: {error}")); + } + if value.starts_with('\'') { + if !value.ends_with('\'') || value.len() < 2 { + return Err("unterminated single-quoted value".to_owned()); + } + return Ok(value[1..value.len() - 1].replace("''", "'")); + } + if value.eq_ignore_ascii_case("null") || value == "~" { + return Ok(String::new()); + } + Ok(value.to_owned()) +} + diff --git a/src/db.rs b/src/db.rs deleted file mode 100644 index 1aa4e85..0000000 --- a/src/db.rs +++ /dev/null @@ -1,968 +0,0 @@ -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 sqlx::{any::AnyRow, Any, Row, Transaction}; - -async fn inserted_id( - kind: DatabaseKind, - tx: &mut Transaction<'_, Any>, - table: &str, -) -> Result { - let query = match kind { - DatabaseKind::Sqlite => queries::get(kind, queries::SQLITE_LAST_INSERT_ID), - DatabaseKind::MySql => queries::get(kind, queries::MYSQL_LAST_INSERT_ID), - DatabaseKind::Postgres => match table { - "note_revisions" => queries::get(kind, queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID), - "revisions" => queries::get(kind, queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID), - _ => unreachable!("unsupported identity table"), - }, - }; - sqlx::query_scalar(query).fetch_one(&mut **tx).await -} - -#[derive(Debug, Clone)] -pub struct Workspace { - pub id: i64, - pub slug: String, - pub title: String, - pub password_hash: Option, - pub created_at: String, - pub updated_at: String, - pub is_private: i64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct Note { - pub id: i64, - #[serde(skip_serializing)] - pub _workspace_id: i64, - pub slug: String, - pub title: String, - #[serde(skip_serializing)] - pub content: String, - pub created_at: String, - pub updated_at: String, - pub owner_map: String, - pub protected: bool, - pub created_by: Option, -} - -#[derive(Debug, Clone, FromRow)] -struct SqliteNote { - id: i64, - workspace_id: i64, - slug: String, - title: String, - content: String, - created_at: String, - updated_at: String, - owner_map: String, - protected: i64, - created_by: Option, -} - -impl From for Note { - fn from(value: SqliteNote) -> Self { - Self { - id: value.id, - _workspace_id: value.workspace_id, - slug: value.slug, - title: value.title, - content: value.content, - created_at: value.created_at, - updated_at: value.updated_at, - owner_map: value.owner_map, - protected: value.protected != 0, - created_by: value.created_by, - } - } -} - -#[derive(Debug, Serialize)] -pub struct Revision { - pub id: i64, - pub content: String, - pub created_at: String, - pub author: Option, - pub owner_map: String, -} - -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 -} - -pub async fn create_workspace( - pool: &Database, - slug: &str, - title: &str, - password: Option<&str>, -) -> Result { - 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?; - - sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) - .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()), - ) { - (None, _) => true, - (Some(hash), Some(password)) => PasswordHash::new(hash) - .ok() - .and_then(|parsed| { - Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .ok() - }) - .is_some(), - (Some(_), None) => false, - } -} - -pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, sqlx::Error> { - if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q003)) - .bind(workspace_id) - .fetch_all(pool.pool()) - .await? - .into_iter() - .map(Note::from) - .collect()); - } - sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003)) - .bind(workspace_id) - .fetch_all(pool.pool()) - .await -} - -pub async fn find_note( - pool: &Database, - workspace_id: i64, - slug: &str, -) -> Result, sqlx::Error> { - if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q004)) - .bind(workspace_id) - .bind(slug) - .fetch_optional(pool.pool()) - .await? - .map(Note::from)); - } - sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) - .bind(workspace_id) - .bind(slug) - .fetch_optional(pool.pool()) - .await -} - -pub async fn create_note( - pool: &Database, - workspace_id: i64, - slug: &str, - title: &str, - protected: bool, - 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?; - - find_note(pool, workspace_id, slug) - .await? - .ok_or(sqlx::Error::RowNotFound) -} - -pub async fn save_revision( - pool: &Database, - note_id: i64, - workspace_id: i64, - content: &str, - author: Option<&str>, - owner_map: &str, -) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.pool().begin().await?; - sqlx::query(queries::get(pool.kind(), queries::Q006)) - .bind(content) - .bind(owner_map) - .bind(note_id) - .execute(&mut *tx) - .await?; - sqlx::query(queries::get(pool.kind(), queries::Q007)) - .bind(workspace_id) - .execute(&mut *tx) - .await?; - sqlx::query(queries::get(pool.kind(), queries::Q008)) - .bind(note_id) - .bind(content) - .bind(author) - .bind(owner_map) - .execute(&mut *tx) - .await?; - let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?; - let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009)) - .bind(note_id) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - Ok((revision_id, updated_at)) -} - -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 -} - -pub fn random_suffix(length: usize) -> String { - const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; - let mut bytes = vec![0_u8; length]; - let mut rng = OsRng; - rng.fill_bytes(&mut bytes); - bytes - .into_iter() - .map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char) - .collect() -} - -fn hash_password(password: &str) -> String { - let salt = SaltString::generate(&mut OsRng); - Argon2::default() - .hash_password(password.as_bytes(), &salt) - .expect("Argon2 hashing should succeed") - .to_string() -} - -pub fn normalize_timestamp(value: &str) -> String { - let value = value.trim(); - - if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) { - return timestamp.with_timezone(&Utc).to_rfc3339(); - } - - // PostgreSQL renders TEXT timestamps as e.g. `2026-07-20 14:32:10.123456+00`. - // RFC 3339 requires `T` and a colon in the numeric offset. - let mut postgres = value.replacen(' ', "T", 1); - if postgres.len() >= 3 { - 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()) - { - postgres.push_str(":00"); - } - } - if let Ok(timestamp) = DateTime::parse_from_rfc3339(&postgres) { - return timestamp.with_timezone(&Utc).to_rfc3339(); - } - - for format in ["%Y-%m-%d %H:%M:%S%.f%:z", "%Y-%m-%dT%H:%M:%S%.f%:z"] { - if let Ok(timestamp) = DateTime::parse_from_str(value, format) { - return timestamp.with_timezone(&Utc).to_rfc3339(); - } - } - - for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] { - if let Ok(timestamp) = NaiveDateTime::parse_from_str(value, format) { - return timestamp.and_utc().to_rfc3339(); - } - } - - // Existing rows should still be readable even if they were stored without a zone. - value.to_string() -} - -#[derive(Debug, Clone)] -pub struct Pad { - pub id: i64, - pub slug: String, - pub title: String, - pub content: String, - pub password_hash: Option, - pub created_at: String, - pub updated_at: String, - pub owner_map: String, - pub is_private: i64, -} - -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 -} - -pub async fn create_pad( - pool: &Database, - slug: &str, - title: &str, - password: Option<&str>, -) -> Result { - 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?; - - sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) - .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()), - ) { - (None, _) => true, - (Some(hash), Some(password)) => PasswordHash::new(hash) - .ok() - .and_then(|parsed| { - Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .ok() - }) - .is_some(), - (Some(_), None) => false, - } -} - -pub async fn save_pad_revision( - pool: &Database, - pad_id: i64, - content: &str, - author: Option<&str>, - owner_map: &str, -) -> Result<(i64, String), sqlx::Error> { - let mut tx = pool.pool().begin().await?; - sqlx::query(queries::get(pool.kind(), queries::Q013)) - .bind(content) - .bind(owner_map) - .bind(pad_id) - .execute(&mut *tx) - .await?; - sqlx::query(queries::get(pool.kind(), queries::Q014)) - .bind(pad_id) - .bind(content) - .bind(author) - .bind(owner_map) - .execute(&mut *tx) - .await?; - let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?; - let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015)) - .bind(pad_id) - .fetch_one(&mut *tx) - .await?; - tx.commit().await?; - Ok((revision_id, updated_at)) -} - -pub async fn list_pad_revisions( - pool: &Database, - pad_id: i64, -) -> Result, sqlx::Error> { - sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) - .bind(pad_id) - .fetch_all(pool.pool()) - .await -} - -#[derive(Debug, Clone, Serialize)] -pub struct PublishedPage { - pub token: String, - pub pad_id: Option, - pub note_id: Option, - pub allow_task_updates: bool, - pub title: String, - pub content: String, - pub updated_at: String, -} - -#[derive(Debug, Clone)] -struct PublishedPageRow { - token: String, - pad_id: Option, - note_id: Option, - allow_task_updates: i64, - title: String, - content: String, - updated_at: String, -} - -#[derive(Debug, Clone)] -struct PostgresPublishedPageRow { - token: String, - pad_id: Option, - note_id: Option, - allow_task_updates: bool, - title: String, - content: String, - updated_at: String, -} - -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, - } - } -} -impl From for PublishedPage { - fn from(value: PublishedPageRow) -> Self { - Self { - token: value.token, - pad_id: value.pad_id, - note_id: value.note_id, - allow_task_updates: value.allow_task_updates != 0, - title: value.title, - content: value.content, - updated_at: value.updated_at, - } - } -} - -pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) - .bind(pad_id) - .fetch_optional(pool.pool()) - .await? - { - return Ok(token); - } - let token = random_suffix(18); - sqlx::query(queries::get(pool.kind(), queries::Q018)) - .bind(&token) - .bind(pad_id) - .execute(pool.pool()) - .await?; - Ok(token) -} - -pub async fn publish_note(pool: &Database, note_id: i64) -> Result { - if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019)) - .bind(note_id) - .fetch_optional(pool.pool()) - .await? - { - return Ok(token); - } - let token = random_suffix(18); - sqlx::query(queries::get(pool.kind(), queries::Q020)) - .bind(&token) - .bind(note_id) - .execute(pool.pool()) - .await?; - Ok(token) -} - -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::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::get(pool.kind(), queries::Q044)) - .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); - 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::get(pool.kind(), queries::Q045)) - .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); - Ok(value != 0) -} - -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.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> { - 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.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)); - } - 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 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)); - } - 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)); - } - 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?; - } 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?; - } - 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? - { - return Ok(token); - } - - let token = format!("p_{}", random_suffix(24)); - sqlx::query(queries::get(pool.kind(), queries::Q023)) - .bind(&token) - .bind(pad_id) - .execute(pool.pool()) - .await?; - - sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q022)) - .bind(pad_id) - .fetch_one(pool.pool()) - .await -} - -pub async fn note_file_token(pool: &Database, note_id: i64) -> Result { - 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); - } - - let token = format!("n_{}", random_suffix(24)); - sqlx::query(queries::get(pool.kind(), queries::Q025)) - .bind(&token) - .bind(note_id) - .execute(pool.pool()) - .await?; - - sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q024)) - .bind(note_id) - .fetch_one(pool.pool()) - .await -} - -#[derive(Debug, Clone, Copy)] -pub enum FileOwnerKind { - Pad, - Note, -} - -#[derive(Debug, Clone, Copy)] -pub struct FileOwner { - pub kind: FileOwnerKind, - pub id: i64, -} - -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, - })); - } - 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, - })); - } - Ok(None) -} - -#[derive(Debug, Clone, Serialize)] -pub struct NoteFile { - pub id: i64, - pub filename: String, - pub url: String, - pub mime_type: String, - pub size_bytes: i64, - pub created_at: String, - pub is_attached: bool, - pub detached_at: Option, -} - -#[derive(Debug, Clone, FromRow)] -struct SqliteNoteFile { - id: i64, - filename: String, - url: String, - mime_type: String, - size_bytes: i64, - created_at: String, - is_attached: i64, - detached_at: Option, -} - -impl From for NoteFile { - fn from(value: SqliteNoteFile) -> Self { - Self { - id: value.id, - filename: value.filename, - url: value.url, - mime_type: value.mime_type, - size_bytes: value.size_bytes, - created_at: value.created_at, - is_attached: value.is_attached != 0, - detached_at: value.detached_at, - } - } -} - -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?; - 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> { - 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?; - Ok(()) -} - -pub async fn list_note_files(pool: &Database, note_id: i64) -> Result, sqlx::Error> { - list_files(pool, queries::Q033, note_id).await -} - -async fn list_files( - pool: &Database, - query: queries::Query, - owner_id: i64, -) -> Result, sqlx::Error> { - if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query)) - .bind(owner_id) - .fetch_all(pool.pool()) - .await? - .into_iter() - .map(NoteFile::from) - .collect()); - } - sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query)) - .bind(owner_id) - .fetch_all(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> { - 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?; - Ok(()) -} - -pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result, sqlx::Error> { - list_files(pool, queries::Q036, pad_id).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> { - if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038)) - .bind(file_id) - .bind(note_id) - .fetch_optional(pool.pool()) - .await? - .map(NoteFile::from)); - } - sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038)) - .bind(file_id) - .bind(note_id) - .fetch_optional(pool.pool()) - .await -} - -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) - .execute(pool.pool()) - .await?; - Ok(()) -} - -pub async fn find_pad_file( - pool: &Database, - pad_id: i64, - file_id: i64, -) -> Result, sqlx::Error> { - if pool.kind() == DatabaseKind::Sqlite { - return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046)) - .bind(file_id) - .bind(pad_id) - .fetch_optional(pool.pool()) - .await? - .map(NoteFile::from)); - } - sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046)) - .bind(file_id) - .bind(pad_id) - .fetch_optional(pool.pool()) - .await -} - -pub async fn delete_pad_file( - pool: &Database, - pad_id: i64, - file_id: i64, -) -> Result<(), sqlx::Error> { - sqlx::query(queries::get(pool.kind(), queries::Q047)) - .bind(file_id) - .bind(pad_id) - .execute(pool.pool()) - .await?; - Ok(()) -} - - -impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace { - fn from_row(row: &'r AnyRow) -> Result { - Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, is_private: row.try_get("is_private")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for Note { - fn from_row(row: &'r AnyRow) -> Result { - let protected: i64 = row.try_get("protected")?; - Ok(Self { id: row.try_get("id")?, _workspace_id: row.try_get("workspace_id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, protected: protected != 0, created_by: crate::row_decode::optional_text(row, "created_by")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for Revision { - fn from_row(row: &'r AnyRow) -> Result { - Ok(Self { id: row.try_get("id")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, author: crate::row_decode::optional_text(row, "author")?, owner_map: crate::row_decode::text(row, "owner_map")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for Pad { - fn from_row(row: &'r AnyRow) -> Result { - Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, is_private: row.try_get("is_private")? }) - } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for PublishedPageRow { - fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow { - fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) } -} -impl<'r> sqlx::FromRow<'r, AnyRow> for NoteFile { - fn from_row(row: &'r AnyRow) -> Result { let attached:i64=row.try_get("is_attached")?; Ok(Self { id:row.try_get("id")?, filename:crate::row_decode::text(row,"filename")?, url:crate::row_decode::text(row,"url")?, mime_type:crate::row_decode::text(row,"mime_type")?, size_bytes:row.try_get("size_bytes")?, created_at:crate::row_decode::text(row,"created_at")?, is_attached:attached!=0, detached_at:crate::row_decode::optional_text(row,"detached_at")? }) } -} diff --git a/src/db/files.rs b/src/db/files.rs new file mode 100644 index 0000000..f4b0a33 --- /dev/null +++ b/src/db/files.rs @@ -0,0 +1,287 @@ +#[derive(Debug, Clone, Copy)] +pub enum FileOwnerKind { + Pad, + Note, +} + +#[derive(Debug, Clone, Copy)] +pub struct FileOwner { + pub kind: FileOwnerKind, + pub id: i64, +} + +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, + })); + } + 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, + })); + } + Ok(None) +} + +#[derive(Debug, Clone, Serialize)] +pub struct NoteFile { + pub id: i64, + pub filename: String, + pub url: String, + pub mime_type: String, + pub size_bytes: i64, + pub created_at: String, + pub is_attached: bool, + pub detached_at: Option, +} + +#[derive(Debug, Clone, FromRow)] +struct SqliteNoteFile { + id: i64, + filename: String, + url: String, + mime_type: String, + size_bytes: i64, + created_at: String, + is_attached: i64, + detached_at: Option, +} + +impl From for NoteFile { + fn from(value: SqliteNoteFile) -> Self { + Self { + id: value.id, + filename: value.filename, + url: value.url, + mime_type: value.mime_type, + size_bytes: value.size_bytes, + created_at: value.created_at, + is_attached: value.is_attached != 0, + detached_at: value.detached_at, + } + } +} + +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?; + 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> { + 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?; + Ok(()) +} + +pub async fn list_note_files(pool: &Database, note_id: i64) -> Result, sqlx::Error> { + list_files(pool, queries::Q033, note_id).await +} + +async fn list_files( + pool: &Database, + query: queries::Query, + owner_id: i64, +) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), query)) + .bind(owner_id) + .fetch_all(pool.pool()) + .await? + .into_iter() + .map(NoteFile::from) + .collect()); + } + sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), query)) + .bind(owner_id) + .fetch_all(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> { + 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?; + Ok(()) +} + +pub async fn list_pad_files(pool: &Database, pad_id: i64) -> Result, sqlx::Error> { + list_files(pool, queries::Q036, pad_id).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> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q038)) + .bind(file_id) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + .map(NoteFile::from)); + } + sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q038)) + .bind(file_id) + .bind(note_id) + .fetch_optional(pool.pool()) + .await +} + +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) + .execute(pool.pool()) + .await?; + Ok(()) +} + +pub async fn find_pad_file( + pool: &Database, + pad_id: i64, + file_id: i64, +) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNoteFile>(queries::get(pool.kind(), queries::Q046)) + .bind(file_id) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + .map(NoteFile::from)); + } + sqlx::query_as::<_, NoteFile>(queries::get(pool.kind(), queries::Q046)) + .bind(file_id) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await +} + +pub async fn delete_pad_file( + pool: &Database, + pad_id: i64, + file_id: i64, +) -> Result<(), sqlx::Error> { + sqlx::query(queries::get(pool.kind(), queries::Q047)) + .bind(file_id) + .bind(pad_id) + .execute(pool.pool()) + .await?; + Ok(()) +} + + +impl<'r> sqlx::FromRow<'r, AnyRow> for Workspace { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, is_private: row.try_get("is_private")? }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for Note { + fn from_row(row: &'r AnyRow) -> Result { + let protected: i64 = row.try_get("protected")?; + Ok(Self { id: row.try_get("id")?, _workspace_id: row.try_get("workspace_id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, protected: protected != 0, created_by: crate::row_decode::optional_text(row, "created_by")? }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for Revision { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { id: row.try_get("id")?, content: crate::row_decode::text(row, "content")?, created_at: crate::row_decode::text(row, "created_at")?, author: crate::row_decode::optional_text(row, "author")?, owner_map: crate::row_decode::text(row, "owner_map")? }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for Pad { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { id: row.try_get("id")?, slug: crate::row_decode::text(row, "slug")?, title: crate::row_decode::text(row, "title")?, content: crate::row_decode::text(row, "content")?, password_hash: crate::row_decode::optional_text(row, "password_hash")?, created_at: crate::row_decode::text(row, "created_at")?, updated_at: crate::row_decode::text(row, "updated_at")?, owner_map: crate::row_decode::text(row, "owner_map")?, is_private: row.try_get("is_private")? }) + } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for PublishedPageRow { + fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for PostgresPublishedPageRow { + fn from_row(row: &'r AnyRow) -> Result { Ok(Self { token: crate::row_decode::text(row,"token")?, pad_id: row.try_get("pad_id")?, note_id: row.try_get("note_id")?, allow_task_updates: row.try_get("allow_task_updates")?, title: crate::row_decode::text(row,"title")?, content: crate::row_decode::text(row,"content")?, updated_at: crate::row_decode::text(row,"updated_at")? }) } +} +impl<'r> sqlx::FromRow<'r, AnyRow> for NoteFile { + fn from_row(row: &'r AnyRow) -> Result { let attached:i64=row.try_get("is_attached")?; Ok(Self { id:row.try_get("id")?, filename:crate::row_decode::text(row,"filename")?, url:crate::row_decode::text(row,"url")?, mime_type:crate::row_decode::text(row,"mime_type")?, size_bytes:row.try_get("size_bytes")?, created_at:crate::row_decode::text(row,"created_at")?, is_attached:attached!=0, detached_at:crate::row_decode::optional_text(row,"detached_at")? }) } +} diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..5503017 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,15 @@ +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 sqlx::{any::AnyRow, Any, Row, Transaction}; + + +include!("workspace_notes.rs"); +include!("pads_public.rs"); +include!("files.rs"); diff --git a/src/db/pads_public.rs b/src/db/pads_public.rs new file mode 100644 index 0000000..e5f36d3 --- /dev/null +++ b/src/db/pads_public.rs @@ -0,0 +1,379 @@ +#[derive(Debug, Clone)] +pub struct Pad { + pub id: i64, + pub slug: String, + pub title: String, + pub content: String, + pub password_hash: Option, + pub created_at: String, + pub updated_at: String, + pub owner_map: String, + pub is_private: i64, +} + +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 +} + +pub async fn create_pad( + pool: &Database, + slug: &str, + title: &str, + password: Option<&str>, +) -> Result { + 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?; + + sqlx::query_as::<_, Pad>(queries::get(pool.kind(), queries::Q011)) + .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()), + ) { + (None, _) => true, + (Some(hash), Some(password)) => PasswordHash::new(hash) + .ok() + .and_then(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .ok() + }) + .is_some(), + (Some(_), None) => false, + } +} + +pub async fn save_pad_revision( + pool: &Database, + pad_id: i64, + content: &str, + author: Option<&str>, + owner_map: &str, +) -> Result<(i64, String), sqlx::Error> { + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q013)) + .bind(content) + .bind(owner_map) + .bind(pad_id) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get(pool.kind(), queries::Q014)) + .bind(pad_id) + .bind(content) + .bind(author) + .bind(owner_map) + .execute(&mut *tx) + .await?; + let revision_id = inserted_id(pool.kind(), &mut tx, "revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q015)) + .bind(pad_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok((revision_id, updated_at)) +} + +pub async fn list_pad_revisions( + pool: &Database, + pad_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Revision>(queries::get(pool.kind(), queries::Q016)) + .bind(pad_id) + .fetch_all(pool.pool()) + .await +} + +#[derive(Debug, Clone, Serialize)] +pub struct PublishedPage { + pub token: String, + pub pad_id: Option, + pub note_id: Option, + pub allow_task_updates: bool, + pub title: String, + pub content: String, + pub updated_at: String, +} + +#[derive(Debug, Clone)] +struct PublishedPageRow { + token: String, + pad_id: Option, + note_id: Option, + allow_task_updates: i64, + title: String, + content: String, + updated_at: String, +} + +#[derive(Debug, Clone)] +struct PostgresPublishedPageRow { + token: String, + pad_id: Option, + note_id: Option, + allow_task_updates: bool, + title: String, + content: String, + updated_at: String, +} + +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, + } + } +} +impl From for PublishedPage { + fn from(value: PublishedPageRow) -> Self { + Self { + token: value.token, + pad_id: value.pad_id, + note_id: value.note_id, + allow_task_updates: value.allow_task_updates != 0, + title: value.title, + content: value.content, + updated_at: value.updated_at, + } + } +} + +pub async fn publish_pad(pool: &Database, pad_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q017)) + .bind(pad_id) + .fetch_optional(pool.pool()) + .await? + { + return Ok(token); + } + let token = random_suffix(18); + sqlx::query(queries::get(pool.kind(), queries::Q018)) + .bind(&token) + .bind(pad_id) + .execute(pool.pool()) + .await?; + Ok(token) +} + +pub async fn publish_note(pool: &Database, note_id: i64) -> Result { + if let Some(token) = sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q019)) + .bind(note_id) + .fetch_optional(pool.pool()) + .await? + { + return Ok(token); + } + let token = random_suffix(18); + sqlx::query(queries::get(pool.kind(), queries::Q020)) + .bind(&token) + .bind(note_id) + .execute(pool.pool()) + .await?; + Ok(token) +} + +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::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::get(pool.kind(), queries::Q044)) + .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); + 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::get(pool.kind(), queries::Q045)) + .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); + Ok(value != 0) +} + +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.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> { + 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.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)); + } + 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 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)); + } + 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)); + } + 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?; + } 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?; + } + 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? + { + return Ok(token); + } + + let token = format!("p_{}", random_suffix(24)); + sqlx::query(queries::get(pool.kind(), queries::Q023)) + .bind(&token) + .bind(pad_id) + .execute(pool.pool()) + .await?; + + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q022)) + .bind(pad_id) + .fetch_one(pool.pool()) + .await +} + +pub async fn note_file_token(pool: &Database, note_id: i64) -> Result { + 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); + } + + let token = format!("n_{}", random_suffix(24)); + sqlx::query(queries::get(pool.kind(), queries::Q025)) + .bind(&token) + .bind(note_id) + .execute(pool.pool()) + .await?; + + sqlx::query_scalar::<_, String>(queries::get(pool.kind(), queries::Q024)) + .bind(note_id) + .fetch_one(pool.pool()) + .await +} + diff --git a/src/db/workspace_notes.rs b/src/db/workspace_notes.rs new file mode 100644 index 0000000..75b1f24 --- /dev/null +++ b/src/db/workspace_notes.rs @@ -0,0 +1,291 @@ +async fn inserted_id( + kind: DatabaseKind, + tx: &mut Transaction<'_, Any>, + table: &str, +) -> Result { + let query = match kind { + DatabaseKind::Sqlite => queries::get(kind, queries::SQLITE_LAST_INSERT_ID), + DatabaseKind::MySql => queries::get(kind, queries::MYSQL_LAST_INSERT_ID), + DatabaseKind::Postgres => match table { + "note_revisions" => queries::get(kind, queries::POSTGRES_NOTE_REVISION_LAST_INSERT_ID), + "revisions" => queries::get(kind, queries::POSTGRES_PAD_REVISION_LAST_INSERT_ID), + _ => unreachable!("unsupported identity table"), + }, + }; + sqlx::query_scalar(query).fetch_one(&mut **tx).await +} + +#[derive(Debug, Clone)] +pub struct Workspace { + pub id: i64, + pub slug: String, + pub title: String, + pub password_hash: Option, + pub created_at: String, + pub updated_at: String, + pub is_private: i64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Note { + pub id: i64, + #[serde(skip_serializing)] + pub _workspace_id: i64, + pub slug: String, + pub title: String, + #[serde(skip_serializing)] + pub content: String, + pub created_at: String, + pub updated_at: String, + pub owner_map: String, + pub protected: bool, + pub created_by: Option, +} + +#[derive(Debug, Clone, FromRow)] +struct SqliteNote { + id: i64, + workspace_id: i64, + slug: String, + title: String, + content: String, + created_at: String, + updated_at: String, + owner_map: String, + protected: i64, + created_by: Option, +} + +impl From for Note { + fn from(value: SqliteNote) -> Self { + Self { + id: value.id, + _workspace_id: value.workspace_id, + slug: value.slug, + title: value.title, + content: value.content, + created_at: value.created_at, + updated_at: value.updated_at, + owner_map: value.owner_map, + protected: value.protected != 0, + created_by: value.created_by, + } + } +} + +#[derive(Debug, Serialize)] +pub struct Revision { + pub id: i64, + pub content: String, + pub created_at: String, + pub author: Option, + pub owner_map: String, +} + +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 +} + +pub async fn create_workspace( + pool: &Database, + slug: &str, + title: &str, + password: Option<&str>, +) -> Result { + 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?; + + sqlx::query_as::<_, Workspace>(queries::get(pool.kind(), queries::Q001)) + .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()), + ) { + (None, _) => true, + (Some(hash), Some(password)) => PasswordHash::new(hash) + .ok() + .and_then(|parsed| { + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .ok() + }) + .is_some(), + (Some(_), None) => false, + } +} + +pub async fn list_notes(pool: &Database, workspace_id: i64) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q003)) + .bind(workspace_id) + .fetch_all(pool.pool()) + .await? + .into_iter() + .map(Note::from) + .collect()); + } + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q003)) + .bind(workspace_id) + .fetch_all(pool.pool()) + .await +} + +pub async fn find_note( + pool: &Database, + workspace_id: i64, + slug: &str, +) -> Result, sqlx::Error> { + if pool.kind() == DatabaseKind::Sqlite { + return Ok(sqlx::query_as::<_, SqliteNote>(queries::get(pool.kind(), queries::Q004)) + .bind(workspace_id) + .bind(slug) + .fetch_optional(pool.pool()) + .await? + .map(Note::from)); + } + sqlx::query_as::<_, Note>(queries::get(pool.kind(), queries::Q004)) + .bind(workspace_id) + .bind(slug) + .fetch_optional(pool.pool()) + .await +} + +pub async fn create_note( + pool: &Database, + workspace_id: i64, + slug: &str, + title: &str, + protected: bool, + 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?; + + find_note(pool, workspace_id, slug) + .await? + .ok_or(sqlx::Error::RowNotFound) +} + +pub async fn save_revision( + pool: &Database, + note_id: i64, + workspace_id: i64, + content: &str, + author: Option<&str>, + owner_map: &str, +) -> Result<(i64, String), sqlx::Error> { + let mut tx = pool.pool().begin().await?; + sqlx::query(queries::get(pool.kind(), queries::Q006)) + .bind(content) + .bind(owner_map) + .bind(note_id) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get(pool.kind(), queries::Q007)) + .bind(workspace_id) + .execute(&mut *tx) + .await?; + sqlx::query(queries::get(pool.kind(), queries::Q008)) + .bind(note_id) + .bind(content) + .bind(author) + .bind(owner_map) + .execute(&mut *tx) + .await?; + let revision_id = inserted_id(pool.kind(), &mut tx, "note_revisions").await?; + let updated_at: String = sqlx::query_scalar(queries::get(pool.kind(), queries::Q009)) + .bind(note_id) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok((revision_id, updated_at)) +} + +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 +} + +pub fn random_suffix(length: usize) -> String { + const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; + let mut bytes = vec![0_u8; length]; + let mut rng = OsRng; + rng.fill_bytes(&mut bytes); + bytes + .into_iter() + .map(|value| ALPHABET[(value as usize) % ALPHABET.len()] as char) + .collect() +} + +fn hash_password(password: &str) -> String { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .expect("Argon2 hashing should succeed") + .to_string() +} + +pub fn normalize_timestamp(value: &str) -> String { + let value = value.trim(); + + if let Ok(timestamp) = DateTime::parse_from_rfc3339(value) { + return timestamp.with_timezone(&Utc).to_rfc3339(); + } + + // PostgreSQL renders TEXT timestamps as e.g. `2026-07-20 14:32:10.123456+00`. + // RFC 3339 requires `T` and a colon in the numeric offset. + let mut postgres = value.replacen(' ', "T", 1); + if postgres.len() >= 3 { + 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()) + { + postgres.push_str(":00"); + } + } + if let Ok(timestamp) = DateTime::parse_from_rfc3339(&postgres) { + return timestamp.with_timezone(&Utc).to_rfc3339(); + } + + for format in ["%Y-%m-%d %H:%M:%S%.f%:z", "%Y-%m-%dT%H:%M:%S%.f%:z"] { + if let Ok(timestamp) = DateTime::parse_from_str(value, format) { + return timestamp.with_timezone(&Utc).to_rfc3339(); + } + } + + for format in ["%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M:%S%.f"] { + if let Ok(timestamp) = NaiveDateTime::parse_from_str(value, format) { + return timestamp.and_utc().to_rfc3339(); + } + } + + // Existing rows should still be readable even if they were stored without a zone. + value.to_string() +} + diff --git a/src/ldap_auth.rs b/src/ldap_auth.rs deleted file mode 100644 index 6632436..0000000 --- a/src/ldap_auth.rs +++ /dev/null @@ -1,142 +0,0 @@ -use ldap3::{LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; - -#[derive(Debug, Clone)] -pub struct LdapConfig { - pub url: String, - pub starttls: bool, - pub bind_dn: String, - pub bind_password: String, - pub base_dn: String, - pub user_filter: String, - pub username_attribute: String, - pub email_attribute: String, - pub display_name_attribute: String, - pub organization: String, -} - -#[derive(Debug, Clone)] -pub struct LdapIdentity { - pub username: String, - pub email: String, - pub nickname: String, -} - -pub async fn authenticate( - config: &LdapConfig, - login: &str, - password: &str, -) -> Result, String> { - if login.trim().is_empty() || password.is_empty() { - return Ok(None); - } - - let settings = LdapConnSettings::new().set_starttls(config.starttls); - let (connection, mut ldap) = LdapConnAsync::with_settings(settings, &config.url) - .await - .map_err(|error| format!("LDAP connection failed: {error}"))?; - ldap3::drive!(connection); - - if !config.bind_dn.trim().is_empty() { - let result = ldap - .simple_bind(&config.bind_dn, &config.bind_password) - .await - .map_err(|error| format!("LDAP service bind failed: {error}"))?; - result - .success() - .map_err(|error| format!("LDAP service bind rejected: {error}"))?; - } - - let escaped = escape_filter(login.trim()); - let filter = config.user_filter.replace("{username}", &escaped); - let attributes = vec![ - config.username_attribute.as_str(), - config.email_attribute.as_str(), - config.display_name_attribute.as_str(), - ]; - let (mut entries, _) = ldap - .search(&config.base_dn, Scope::Subtree, &filter, attributes.clone()) - .await - .map_err(|error| format!("LDAP search failed: {error}"))? - .success() - .map_err(|error| format!("LDAP search rejected: {error}"))?; - - // Allow users to sign in with their directory e-mail even when the configured - // primary filter searches by uid/sAMAccountName only. - if entries.is_empty() && login.trim().contains('@') { - let email_filter = format!("({}={})", config.email_attribute, escaped); - let (email_entries, _) = ldap - .search(&config.base_dn, Scope::Subtree, &email_filter, attributes) - .await - .map_err(|error| format!("LDAP e-mail search failed: {error}"))? - .success() - .map_err(|error| format!("LDAP e-mail search rejected: {error}"))?; - entries = email_entries; - } - - if entries.len() != 1 { - let _ = ldap.unbind().await; - return Ok(None); - } - - let entry = SearchEntry::construct(entries.into_iter().next().unwrap()); - let user_dn = entry.dn.clone(); - let username = first_attr(&entry, &config.username_attribute) - .unwrap_or_else(|| login.trim().to_owned()); - let email = first_attr(&entry, &config.email_attribute) - .filter(|value| value.contains('@')) - .unwrap_or_else(|| format!("{}@ldap.local", safe_identifier(&username))); - let display_name = first_attr(&entry, &config.display_name_attribute) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| username.clone()); - - let result = ldap - .simple_bind(&user_dn, password) - .await - .map_err(|error| format!("LDAP user bind failed: {error}"))?; - if result.success().is_err() { - let _ = ldap.unbind().await; - return Ok(None); - } - let _ = ldap.unbind().await; - - let organization = config.organization.trim(); - let nickname = if organization.is_empty() { - display_name - } else { - format!("{organization}/{display_name}") - }; - - Ok(Some(LdapIdentity { - username, - email, - nickname, - })) -} - -fn first_attr(entry: &SearchEntry, name: &str) -> Option { - entry.attrs.get(name).and_then(|values| values.first()).cloned() -} - -fn safe_identifier(value: &str) -> String { - let result: String = value - .chars() - .map(|ch| if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { ch } else { '_' }) - .collect(); - if result.is_empty() { "user".into() } else { result } -} - -fn escape_filter(value: &str) -> String { - let mut result = String::with_capacity(value.len()); - for byte in value.bytes() { - match byte { - b'*' => result.push_str("\\2a"), - b'(' => result.push_str("\\28"), - b')' => result.push_str("\\29"), - b'\\' => result.push_str("\\5c"), - 0 => result.push_str("\\00"), - value if value < 0x20 || value >= 0x7f => result.push_str(&format!("\\{value:02x}")), - value => result.push(value as char), - } - } - result -} diff --git a/src/websocket.rs b/src/websocket.rs deleted file mode 100644 index aa7bcca..0000000 --- a/src/websocket.rs +++ /dev/null @@ -1,530 +0,0 @@ -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 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, - guest_id: 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, - access_level: 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 { - 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, -) { - 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 (password, access_token, nickname, session_token, guest_id, 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, - guest_id, - color, - }) => ( - password, - access_token, - clean_nickname(nickname), - session_token, - clean_guest_id(guest_id), - 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 presence_identity = match session_token.as_deref() { - Some(token) => auth::user_from_token(&state, token) - .await - .ok() - .flatten() - .map(|user| format!("user:{}", user.id)), - None => guest_id.as_ref().and_then(|id| { - nickname - .as_ref() - .map(|name| format!("guest:{id}:{}", name.to_lowercase())) - }), - }; - let supplied_token = session_token.as_deref().or(access_token.as_deref()); - let permission = auth::resource_permission( - &state, - "workspace", - &workspace_slug, - supplied_token, - ) - .await - .ok() - .flatten(); - let anonymous_token_ok = permission.is_none() - && crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, supplied_token) - .await - .unwrap_or(false); - let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); - if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok { - let _ = send_error(&mut socket, "This workspace is private").await; - return; - } - if workspace.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok { - 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 = permission.as_deref() == Some("rw") - || anonymous_token_ok - || password_ok - || (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none()); - 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(), - access_level: if write_allowed { "full".into() } else { "read_only".into() }, - }, - ) - .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 display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); - let (connection_id, users) = state - .join_room(&room_key, display_name.clone(), color, presence_identity) - .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"), - } - } - 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;} - }, - 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" - ); -} -fn clean_nickname(value: Option) -> Option { - value - .map(|v| v.trim().chars().take(40).collect::()) - .filter(|v| !v.is_empty()) -} -fn clean_guest_id(value: Option) -> Option { - value - .map(|v| v.trim().chars().take(64).collect::()) - .filter(|v| { - v.len() >= 16 - && v.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - }) -} -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()) - }) -} -fn clean_chat(value: String) -> String { - 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(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 -} - -#[derive(Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum PadServerMessage { - Authenticated { - title: String, - content: String, - owner_map: String, - access_level: 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)) -} -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, guest_id, 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, - guest_id, - color, - }) => ( - password, - access_token, - clean_nickname(nickname), - session_token, - clean_guest_id(guest_id), - 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 presence_identity = match session_token.as_deref() { - Some(token) => auth::user_from_token(&state, token) - .await - .ok() - .flatten() - .map(|user| format!("user:{}", user.id)), - None => guest_id.as_ref().and_then(|id| { - nickname - .as_ref() - .map(|name| format!("guest:{id}:{}", name.to_lowercase())) - }), - }; - let supplied_token = session_token.as_deref().or(access_token.as_deref()); - let permission = auth::resource_permission( - &state, - "pad", - &slug, - supplied_token, - ) - .await - .ok() - .flatten(); - let anonymous_token_ok = permission.is_none() - && crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token) - .await - .unwrap_or(false); - let password_ok = db::verify_pad_password(&pad, password.as_deref()); - if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok { - 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() && !anonymous_token_ok { - 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 = permission.as_deref() == Some("rw") - || anonymous_token_ok - || password_ok - || (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none()); - 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(), - access_level: if write_allowed { "full".into() } else { "read_only".into() }, - }, - ) - .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 display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); - let (connection_id, users) = state - .join_room(&room_key, display_name.clone(), color, presence_identity) - .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})); - } - } - 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;} - }, - 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_split( - sender: &mut futures_util::stream::SplitSink, - message: &PadServerMessage, -) -> Result<(), axum::Error> { - sender - .send(Message::Text( - serde_json::to_string(message).unwrap().into(), - )) - .await -} diff --git a/src/websocket/helpers.rs b/src/websocket/helpers.rs new file mode 100644 index 0000000..e3bc461 --- /dev/null +++ b/src/websocket/helpers.rs @@ -0,0 +1,73 @@ + let _ = channel.send(RoomEvent::Presence(users)); + 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_guest_id(value: Option) -> Option { + value + .map(|v| v.trim().chars().take(64).collect::()) + .filter(|v| { + v.len() >= 16 + && v.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) +} +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()) + }) +} +fn clean_chat(value: String) -> String { + 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(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 +} + +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PadServerMessage { + Authenticated { diff --git a/src/websocket/messages.rs b/src/websocket/messages.rs new file mode 100644 index 0000000..d68a295 --- /dev/null +++ b/src/websocket/messages.rs @@ -0,0 +1,57 @@ +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientMessage { + Authenticate { + password: Option, + access_token: Option, + nickname: Option, + session_token: Option, + guest_id: 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, + access_level: 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, + }, +} diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs new file mode 100644 index 0000000..232865a --- /dev/null +++ b/src/websocket/mod.rs @@ -0,0 +1,20 @@ +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 std::time::{Duration, Instant}; +use tracing::{debug, info, warn}; + + +include!("messages.rs"); +include!("note.rs"); +include!("pad.rs"); diff --git a/src/websocket/note.rs b/src/websocket/note.rs new file mode 100644 index 0000000..6f33bd3 --- /dev/null +++ b/src/websocket/note.rs @@ -0,0 +1,236 @@ +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, +) { + 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 (password, access_token, nickname, session_token, guest_id, 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, + guest_id, + color, + }) => ( + password, + access_token, + clean_nickname(nickname), + session_token, + clean_guest_id(guest_id), + 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 presence_identity = match session_token.as_deref() { + Some(token) => auth::user_from_token(&state, token) + .await + .ok() + .flatten() + .map(|user| format!("user:{}", user.id)), + None => guest_id.as_ref().and_then(|id| { + nickname + .as_ref() + .map(|name| format!("guest:{id}:{}", name.to_lowercase())) + }), + }; + let supplied_token = session_token.as_deref().or(access_token.as_deref()); + let permission = auth::resource_permission( + &state, + "workspace", + &workspace_slug, + supplied_token, + ) + .await + .ok() + .flatten(); + let anonymous_token_ok = permission.is_none() + && crate::api::verify_resource_access_token(&state, "workspace", &workspace_slug, supplied_token) + .await + .unwrap_or(false); + let password_ok = db::verify_workspace_password(&workspace, password.as_deref()); + if workspace.is_private != 0 && permission.is_none() && !anonymous_token_ok { + let _ = send_error(&mut socket, "This workspace is private").await; + return; + } + if workspace.password_hash.is_some() && !password_ok && permission.is_none() && !anonymous_token_ok { + 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 = permission.as_deref() == Some("rw") + || anonymous_token_ok + || password_ok + || (workspace.is_private == 0 && workspace.password_hash.is_none() && permission.is_none()); + 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(), + access_level: if write_allowed { "full".into() } else { "read_only".into() }, + }, + ) + .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 display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); + let (connection_id, users) = state + .join_room(&room_key, display_name.clone(), color, presence_identity) + .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"), + } + } + 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;} + }, + 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" + ); +} +fn clean_nickname(value: Option) -> Option { + value + .map(|v| v.trim().chars().take(40).collect::()) + .filter(|v| !v.is_empty()) +} +fn clean_guest_id(value: Option) -> Option { + value + .map(|v| v.trim().chars().take(64).collect::()) + .filter(|v| { + v.len() >= 16 + && v.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) +} +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()) + }) +} +fn clean_chat(value: String) -> String { + 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(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 +} diff --git a/src/websocket/pad.rs b/src/websocket/pad.rs new file mode 100644 index 0000000..9aa7726 --- /dev/null +++ b/src/websocket/pad.rs @@ -0,0 +1,219 @@ +#[derive(Debug, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PadServerMessage { + Authenticated { + title: String, + content: String, + owner_map: String, + access_level: 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)) +} +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, guest_id, 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, + guest_id, + color, + }) => ( + password, + access_token, + clean_nickname(nickname), + session_token, + clean_guest_id(guest_id), + 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 presence_identity = match session_token.as_deref() { + Some(token) => auth::user_from_token(&state, token) + .await + .ok() + .flatten() + .map(|user| format!("user:{}", user.id)), + None => guest_id.as_ref().and_then(|id| { + nickname + .as_ref() + .map(|name| format!("guest:{id}:{}", name.to_lowercase())) + }), + }; + let supplied_token = session_token.as_deref().or(access_token.as_deref()); + let permission = auth::resource_permission( + &state, + "pad", + &slug, + supplied_token, + ) + .await + .ok() + .flatten(); + let anonymous_token_ok = permission.is_none() + && crate::api::verify_resource_access_token(&state, "pad", &slug, supplied_token) + .await + .unwrap_or(false); + let password_ok = db::verify_pad_password(&pad, password.as_deref()); + if pad.is_private != 0 && permission.is_none() && !anonymous_token_ok { + 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() && !anonymous_token_ok { + 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 = permission.as_deref() == Some("rw") + || anonymous_token_ok + || password_ok + || (pad.is_private == 0 && pad.password_hash.is_none() && permission.is_none()); + 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(), + access_level: if write_allowed { "full".into() } else { "read_only".into() }, + }, + ) + .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 display_name = nickname.clone().unwrap_or_else(|| "Guest".into()); + let (connection_id, users) = state + .join_room(&room_key, display_name.clone(), color, presence_identity) + .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})); + } + } + 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;} + }, + 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_split( + sender: &mut futures_util::stream::SplitSink, + message: &PadServerMessage, +) -> Result<(), axum::Error> { + sender + .send(Message::Text( + serde_json::to_string(message).unwrap().into(), + )) + .await +}