/* * Copyright (C) 2026 Mateusz GruszczyƄski @linuxiarz.pl * Source-Available Code / Dual-Licensed. * * Free for non-commercial and evaluation use under terms of BSL/GPLv3. * Commercial or production use requires a valid paid license. * See LICENSE file in repository root for details. */ mod access_tokens; mod error; mod files; mod pads_public; pub use access_tokens::*; pub use error::ApiError; pub use files::*; pub use pads_public::*; use axum::{ Json, extract::{Multipart, Path, Query, 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::{ collab::{self, AppliedOperation}, 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; const MAX_DOCUMENT_SIZE_BYTES: usize = 2_000_000; fn bearer_token(headers: &HeaderMap) -> Option<&str> { crate::security::session_token(headers) } fn authorization_token(headers: &HeaderMap) -> Option<&str> { crate::security::bearer_token(headers) } fn user_session_token(headers: &HeaderMap) -> Option<&str> { crate::security::session_token(headers) } fn resource_request_token<'a>( headers: &'a HeaderMap, kind: &str, slug: &str, supplied: Option<&'a str>, ) -> Option<&'a str> { supplied .map(str::trim) .filter(|value| !value.is_empty() && *value != "cookie") .or_else(|| crate::security::resource_token(headers, kind, slug)) .or_else(|| authorization_token(headers)) } async fn session_user( state: &SharedState, headers: &HeaderMap, ) -> Result, ApiError> { let Some(token) = user_session_token(headers) else { return Ok(None); }; crate::auth::user_from_token(state, token) .await .map_err(|error| ApiError::forbidden(&error.message)) } fn requester_guest_id(headers: &HeaderMap) -> Option<&str> { crate::security::cookie_value(headers, "rustpad_guest_id") .map(str::trim) .filter(|value| { (16..=64).contains(&value.len()) && value.chars().all(|character| { character.is_ascii_alphanumeric() || matches!(character, '-' | '_') }) }) } async fn note_creator_is_requester( state: &SharedState, headers: &HeaderMap, note: &db::Note, ) -> Result { if let Some(owner_guest_id) = note.created_by_guest_id.as_deref() { return Ok(requester_guest_id(headers) .is_some_and(|requester_guest_id| requester_guest_id == owner_guest_id)); } let Some(user) = session_user(state, headers).await? else { return Ok(false); }; Ok(note .created_by .as_deref() .is_some_and(|creator| creator == user.nickname)) } fn pad_creator_is_requester(headers: &HeaderMap, pad: &db::Pad) -> bool { pad.created_by_guest_id .as_deref() .zip(requester_guest_id(headers)) .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) } fn workspace_creator_is_requester(headers: &HeaderMap, workspace: &db::Workspace) -> bool { workspace .created_by_guest_id .as_deref() .zip(requester_guest_id(headers)) .is_some_and(|(owner_guest_id, requester_guest_id)| owner_guest_id == requester_guest_id) } async fn can_set_workspace_password( state: &SharedState, headers: &HeaderMap, workspace: &db::Workspace, ) -> bool { if workspace.password_hash.is_some() { return false; } let account_owner = crate::auth::is_resource_owner( state, "workspace", &workspace.slug, user_session_token(headers), ) .await .unwrap_or(false); account_owner || workspace_creator_is_requester(headers, workspace) } async fn has_write_permission( state: &SharedState, headers: &HeaderMap, kind: &str, slug: &str, ) -> Result { if request_access_level( state, headers, kind, slug, None, crate::security::session_cookie_token(headers), ) .await? >= AccessLevel::Write { return Ok(true); } match kind { "workspace" => Ok(db::find_workspace(&state.db, slug) .await? .is_some_and(|workspace| { workspace.is_private == 0 && workspace.password_hash.is_none() })), "pad" => Ok(db::find_pad(&state.db, slug) .await? .is_some_and(|pad| pad.is_private == 0 && pad.password_hash.is_none())), _ => Ok(false), } } #[derive(Debug, Serialize)] pub struct PublishResponse { url: Option, enabled: bool, } #[derive(Debug, Clone, Serialize)] pub struct MarkdownFileReference { filename: String, url: String, mime_type: String, } pub(crate) async fn markdown_file_references( state: &SharedState, pad_id: Option, note_id: Option, content: Option<&str>, ) -> Result, ApiError> { let file_rows = if let Some(id) = pad_id { db::list_pad_files(&state.db, id).await? } else if let Some(id) = note_id { db::list_note_files(&state.db, id).await? } else { Vec::new() }; Ok(file_rows .into_iter() .filter(|file| { content .map(|value| { files::content_references_stored_file( value, &file.filename, &file.url, state.files_public_url.as_deref(), ) }) .unwrap_or(true) }) .map(|file| MarkdownFileReference { filename: file.filename, url: crate::file_urls::public_file_url(state.files_public_url.as_deref(), &file.url), mime_type: file.mime_type, }) .collect()) } #[derive(Debug, Serialize)] pub struct PublicPageResponse { title: String, content: String, updated_at: String, allow_task_updates: bool, files: Vec, } #[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, #[serde(default)] unprotect_page: bool, enabled: Option, } #[derive(Debug, Deserialize)] pub struct PublicTaskUpdateRequest { source_line: usize, checked: bool, } #[derive(Debug, Deserialize)] pub struct CreateNoteRequest { name: String, #[serde(default)] content: Option, #[serde(default)] password: Option, #[serde(default)] access_token: Option, #[serde(default)] protect: bool, #[serde(default)] created_by: Option, } #[derive(Debug, Deserialize)] pub struct SetWorkspacePasswordRequest { password: String, } #[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, access_level: String, can_set_password: bool, created_at: String, updated_at: String, } #[derive(Debug, Serialize)] pub struct WorkspaceOpenResponse { workspace: WorkspaceInfo, notes: Vec, pagination: ListPaginationMeta, } #[derive(Debug, Deserialize)] pub struct WorkspaceNotesQuery { #[serde(default)] q: String, #[serde(default = "default_list_page")] page: usize, #[serde(default = "default_list_per_page")] per_page: usize, } #[derive(Debug, Serialize)] pub struct ListPaginationMeta { page: usize, per_page: usize, total: usize, total_pages: usize, } fn default_list_page() -> usize { 1 } fn default_list_per_page() -> usize { 25 } fn normalize_list_per_page(value: usize) -> usize { match value { 25 | 50 | 100 => value, _ => 25, } } #[derive(Debug, Serialize)] pub struct NoteListItem { slug: String, title: String, created_at: String, updated_at: String, url: String, protected: bool, created_by: Option, participant_count: i64, file_count: i64, file_size_bytes: i64, revision_count: i64, } #[derive(Debug, Serialize)] pub struct NoteInfo { workspace_slug: String, workspace_title: String, slug: String, title: String, protected: bool, access_level: String, note_protected: bool, allow_public_task_updates: bool, public_page_unprotected: bool, public_page_enabled: bool, private: bool, created_at: String, updated_at: String, can_delete_files: bool, can_upload_files: bool, global_color: Option, note_color: Option, authorship_mode: String, colors_enabled: bool, compact_view: bool, editor_line_numbers: bool, preview_line_numbers: bool, line_links: bool, font_family: String, font_size: i64, personal_editor_settings: bool, can_save_editor_settings: bool, can_manage_authorship: bool, can_set_password: bool, files: Vec, } #[derive(Debug, Deserialize)] pub struct EditorColorRequest { color: Option, } #[derive(Debug, Deserialize)] pub struct EditorSettingsRequest { #[serde(default)] authorship_mode: Option, #[serde(default)] colors_enabled: Option, #[serde(default)] compact_view: Option, #[serde(default)] editor_line_numbers: Option, #[serde(default)] preview_line_numbers: Option, #[serde(default)] line_links: Option, #[serde(default)] font_family: Option, #[serde(default)] font_size: Option, } async fn user_editor_preferences( state: &SharedState, headers: &HeaderMap, resource: db::EditorPreferenceResource, ) -> Result<(db::EditorPreferences, bool), ApiError> { let Some(user) = session_user(state, headers).await? else { return Ok((db::EditorPreferences::default(), false)); }; Ok(( db::load_editor_preferences(&state.db, user.id, resource) .await? .unwrap_or_default(), true, )) } async fn save_editor_settings( state: &SharedState, headers: &HeaderMap, permission_kind: &str, permission_slug: &str, settings_kind: &str, settings_slug: &str, resource: db::EditorPreferenceResource, creator_can_manage_authorship: bool, payload: EditorSettingsRequest, ) -> Result, ApiError> { if !has_write_permission(state, headers, permission_kind, permission_slug).await? { return Err(ApiError::forbidden( "Read and write access is required to save editor preferences", )); } let wants_personal_update = payload.compact_view.is_some() || payload.editor_line_numbers.is_some() || payload.preview_line_numbers.is_some() || payload.line_links.is_some() || payload.font_family.is_some() || payload.font_size.is_some(); let wants_global_update = payload.authorship_mode.is_some() || payload.colors_enabled.is_some(); if !wants_personal_update && !wants_global_update { return Err(ApiError::bad_request("No editor settings were provided")); } let user = session_user(state, headers).await?; if wants_personal_update && user.is_none() { return Err(ApiError::forbidden( "Log in to save personal editor preferences", )); } let can_manage_authorship = if wants_global_update { creator_can_manage_authorship || crate::auth::is_resource_owner( state, permission_kind, permission_slug, user_session_token(headers), ) .await .unwrap_or(false) } else { false }; if wants_global_update && !can_manage_authorship { return Err(ApiError::forbidden( "Only the resource owner can change authorship settings", )); } let preferences = if wants_personal_update { let user_id = user .as_ref() .expect("personal preferences require a user") .id; let mut preferences = db::load_editor_preferences(&state.db, user_id, resource) .await? .unwrap_or_default(); if let Some(value) = payload.compact_view { preferences.compact_view = value; } if let Some(value) = payload.editor_line_numbers { preferences.editor_line_numbers = value; } if let Some(value) = payload.preview_line_numbers { preferences.preview_line_numbers = value; } if let Some(value) = payload.line_links { preferences.line_links = value; } if let Some(value) = payload.font_family { preferences.font_family = match value.as_str() { "mono" | "system" | "serif" | "arial" | "georgia" => value, _ => return Err(ApiError::bad_request("Invalid editor font")), }; } if let Some(value) = payload.font_size { if !matches!(value, 14 | 16 | 18 | 20 | 22) { return Err(ApiError::bad_request("Invalid editor font size")); } preferences.font_size = value; } Some(preferences) } else { None }; let resource_settings = if wants_global_update { let mut settings = db::load_resource_editor_settings(&state.db, settings_kind, settings_slug).await?; if let Some(mode) = payload.authorship_mode { settings.authorship_mode = match mode.as_str() { "simple" => "simple".into(), "full" | "advanced" => "full".into(), _ => return Err(ApiError::bad_request("Invalid authorship mode")), }; } if let Some(value) = payload.colors_enabled { settings.colors_enabled = value; } Some(settings) } else { None }; db::save_editor_configuration( &state.db, user.as_ref().map(|value| value.id), resource, preferences.as_ref(), resource_settings .as_ref() .map(|settings| (settings_kind, settings_slug, settings)), ) .await?; Ok(Json(serde_json::json!({ "preferences": preferences, "resource_settings": resource_settings, }))) } 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 account_user = crate::auth::optional_user(&state, &headers) .await .map_err(|e| ApiError::forbidden(&e.message))?; let created_by_guest_id = if account_user.is_none() { requester_guest_id(&headers) } else { None }; let workspace = db::create_workspace( &state.db, &slug, title, password, created_by_guest_id, ) .await?; if let Some(user) = account_user { 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, &headers, "workspace", &workspace.slug, workspace.is_private, ) .await?; let access_level = effective_header_access_level( &state, &headers, "workspace", &workspace.slug, workspace.is_private, workspace.password_hash.is_some(), ) .await?; let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; Ok(Json(workspace_info_from( &workspace, access_level, can_set_password, ))) } pub async fn set_workspace_password( State(state): State, headers: HeaderMap, Path(workspace_slug): Path, Json(payload): Json, ) -> Result, ApiError> { let workspace = db::find_workspace(&state.db, &workspace_slug) .await? .ok_or_else(ApiError::not_found_workspace)?; if workspace.password_hash.is_some() { return Err(ApiError::bad_request( "This workspace already has a password.", )); } if !can_set_workspace_password(&state, &headers, &workspace).await { return Err(ApiError::forbidden( "Only the workspace owner can set its password.", )); } let password = validate_password(Some(payload.password.as_str()))? .ok_or_else(|| ApiError::bad_request("Password is required."))?; db::set_workspace_password(&state.db, &workspace_slug, password).await?; Ok(Json(serde_json::json!({"ok": true, "protected": true}))) } pub async fn open_workspace( State(state): State, headers: HeaderMap, Path(workspace_slug): Path, Query(query): Query, Json(payload): Json, ) -> Result, ApiError> { let workspace = authorized_workspace( &state, &workspace_slug, payload.password.as_deref(), resource_request_token( &headers, "workspace", &workspace_slug, payload.access_token.as_deref(), ), bearer_token(&headers), &headers, ) .await?; let stats = db::list_note_stats(&state.db, workspace.id) .await? .into_iter() .map(|stats| (stats.note_id, stats)) .collect::>(); let search = query.q.trim().to_lowercase(); let mut notes = db::list_notes(&state.db, workspace.id) .await? .into_iter() .filter(|note| { search.is_empty() || note.title.to_lowercase().contains(&search) || note.slug.to_lowercase().contains(&search) || note .created_by .as_deref() .unwrap_or_default() .to_lowercase() .contains(&search) }) .map(|note| { let stats = stats.get(¬e.id); 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, participant_count: stats.map_or(0, |value| value.participant_count), file_count: stats.map_or(0, |value| value.file_count), file_size_bytes: stats.map_or(0, |value| value.file_size_bytes), revision_count: stats.map_or(0, |value| value.revision_count), } }) .collect::>(); notes.sort_by(|left, right| right.updated_at.cmp(&left.updated_at)); let page = query.page.max(1); let per_page = normalize_list_per_page(query.per_page); let total = notes.len(); let total_pages = ((total + per_page - 1) / per_page).max(1); let page = page.min(total_pages); let start = (page - 1) * per_page; let notes = notes.into_iter().skip(start).take(per_page).collect(); let mut access_level = effective_header_access_level( &state, &headers, "workspace", &workspace.slug, workspace.is_private, workspace.password_hash.is_some(), ) .await?; if db::verify_workspace_password(&workspace, payload.password.as_deref()) { access_level = AccessLevel::Write; } let can_set_password = can_set_workspace_password(&state, &headers, &workspace).await; Ok(Json(WorkspaceOpenResponse { workspace: workspace_info_from(&workspace, access_level, can_set_password), notes, pagination: ListPaginationMeta { page, per_page, total, total_pages, }, })) } 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(), resource_request_token( &headers, "workspace", &workspace_slug, payload.access_token.as_deref(), ), bearer_token(&headers), &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 { request_access_level( &state, &headers, "workspace", &workspace_slug, resource_request_token( &headers, "workspace", &workspace_slug, payload.access_token.as_deref(), ), bearer_token(&headers), ) .await? }; require_write(level)?; let title = validate_name(&payload.name, "Note name")?; let initial_content = validate_initial_content(payload.content.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_note_slug(&state, workspace.id, &base).await?; let account_user = session_user(&state, &headers).await?; let created_by = account_user .as_ref() .map(|user| user.nickname.clone()) .or_else(|| { payload .created_by .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(|value| value.chars().take(40).collect::()) }); let created_by_guest_id = if account_user.is_none() { requester_guest_id(&headers) } else { None }; let note = db::create_note( &state.db, workspace.id, &slug, title, payload.protect, created_by.as_deref(), created_by_guest_id.as_deref(), ) .await?; if let Some(content) = initial_content { db::save_revision( &state.db, note.id, workspace.id, content, 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, participant_count: 0, file_count: 0, file_size_bytes: 0, revision_count: 0, }), )) } 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) = session_user(state, headers).await? 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 = session_user(state, headers) .await? .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, &headers, "workspace", &workspace.slug, workspace.is_private, ) .await?; let access_level = effective_header_access_level( &state, &headers, "workspace", &workspace.slug, workspace.is_private, workspace.password_hash.is_some(), ) .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?; let (editor_preferences, personal_editor_settings) = user_editor_preferences( &state, &headers, db::EditorPreferenceResource::Note(note.id), ) .await?; let resource_editor_settings = db::load_resource_editor_settings(&state.db, "note", &color_slug).await?; let workspace_owner = crate::auth::is_resource_owner( &state, "workspace", &workspace_slug, user_session_token(&headers), ) .await .unwrap_or(false); let workspace_guest_owner = workspace_creator_is_requester(&headers, &workspace); let note_owner = note_creator_is_requester(&state, &headers, ¬e).await?; let password_write_access = has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; let can_manage_authorship = workspace_owner || note_owner || password_write_access; let can_delete_files = can_manage_authorship; let can_upload_files = session_user(&state, &headers).await?.is_some() && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) && has_write_permission(&state, &headers, "workspace", &workspace_slug).await?; if workspace.is_private == 0 && workspace.password_hash.is_some() && !db::note_public_page_disabled(&state.db, note.id).await? && !db::note_public_page_enabled(&state.db, note.id).await? { db::publish_note(&state.db, note.id).await?; } Ok(Json(NoteInfo { workspace_slug: workspace.slug, workspace_title: workspace.title, slug: note.slug, title: note.title, protected: workspace.password_hash.is_some(), access_level: access_level_name(access_level).into(), note_protected: note.protected, allow_public_task_updates: db::note_public_task_updates(&state.db, note.id).await?, public_page_unprotected: db::note_public_page_unprotected(&state.db, note.id).await?, public_page_enabled: db::note_public_page_enabled(&state.db, note.id).await?, private: workspace.is_private != 0, created_at: db::normalize_timestamp(¬e.created_at), updated_at: db::normalize_timestamp(¬e.updated_at), can_delete_files, can_upload_files, global_color, note_color, authorship_mode: resource_editor_settings.authorship_mode, colors_enabled: resource_editor_settings.colors_enabled, compact_view: editor_preferences.compact_view, editor_line_numbers: editor_preferences.editor_line_numbers, preview_line_numbers: editor_preferences.preview_line_numbers, line_links: editor_preferences.line_links, font_family: editor_preferences.font_family, font_size: editor_preferences.font_size, personal_editor_settings, can_save_editor_settings, can_manage_authorship, can_set_password: workspace.password_hash.is_none() && (workspace_owner || workspace_guest_owner), files: markdown_file_references(&state, None, Some(note.id), None).await?, })) } pub async fn set_note_editor_settings( State(state): State, headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { let workspace = db::find_workspace(&state.db, &workspace_slug) .await? .ok_or_else(ApiError::not_found_workspace)?; let note = db::find_note(&state.db, workspace.id, ¬e_slug) .await? .ok_or_else(ApiError::not_found_note)?; let creator_can_manage_authorship = note_creator_is_requester(&state, &headers, ¬e).await? || has_password_write_access(&state, &headers, "workspace", &workspace_slug).await?; save_editor_settings( &state, &headers, "workspace", &workspace_slug, "note", &format!("{workspace_slug}/{note_slug}"), db::EditorPreferenceResource::Note(note.id), creator_can_manage_authorship, payload, ) .await } 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(), resource_request_token( &headers, "workspace", &workspace_slug, payload.access_token.as_deref(), ), bearer_token(&headers), &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(), resource_request_token( &headers, "workspace", &workspace_slug, payload.access_token.as_deref(), ), bearer_token(&headers), &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 { request_access_level( &state, &headers, "workspace", &workspace_slug, resource_request_token( &headers, "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 room_key = crate::state::AppState::note_room_key(&workspace_slug, ¬e_slug); let channel = state.note_channel(&workspace_slug, ¬e_slug).await; let collaboration_snapshot = db::note_collaboration_snapshot(&state.db, note.id).await?; let collaborative_document = state .collaborative_document( &room_key, collaboration_snapshot.content, collaboration_snapshot.owner_map, collaboration_snapshot.revision_id, ) .await; let mut document = collaborative_document.lock().await; let base_revision_id = document.revision_id; let operation = collab::replace_operation(document.content.encode_utf16().count(), content, Vec::new()); let (content, owner_map) = collab::apply_operation_to_document( &document.content, &document.owner_map, &operation, &[], ) .map_err(|_| ApiError::bad_request("The selected revision could not be restored"))?; let (revision_id, updated_at) = db::save_revision( &state.db, note.id, workspace.id, &content, Some("restore"), &owner_map, ) .await?; let update_id = u64::try_from(revision_id).unwrap_or_default().max(1); let applied = AppliedOperation { base_revision_id, revision_id, client_id: "server_restore".into(), update_id, operation: operation.clone(), owner_replacements: Vec::new(), }; document.content.clone_from(&content); document.owner_map.clone_from(&owner_map); document.revision_id = revision_id; document.record(applied); let update = NoteUpdate { base_revision_id, revision_id, updated_at, author: Some("restore".into()), client_id: "server_restore".into(), update_id, operation, owner_replacements: Vec::new(), }; let _ = channel.send(RoomEvent::Document(update)); drop(document); 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(access_tokens::hash_access_token(token)) .bind(kind) .bind(slug) .bind(Utc::now().to_rfc3339()) .fetch_one(state.db.pool()) .await?; Ok(count > 0) } async fn has_password_write_access( state: &SharedState, headers: &HeaderMap, kind: &str, slug: &str, ) -> Result { for token in [ crate::security::resource_token(headers, kind, slug), authorization_token(headers), ] { if anonymous_access_token_valid(state, kind, slug, token).await? { return Ok(true); } } Ok(false) } async fn external_token_access_level( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result { let permission = crate::auth::share_access_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 account_token_access_level( state: &SharedState, kind: &str, slug: &str, token: Option<&str>, ) -> Result { let permission = crate::auth::account_resource_permission(state, kind, slug, token) .await .map_err(|error| ApiError::forbidden(&error.message))?; Ok(permission_level(permission.as_deref())) } async fn request_access_level( state: &SharedState, headers: &HeaderMap, kind: &str, slug: &str, supplied_access_token: Option<&str>, account_token: Option<&str>, ) -> Result { let mut level = account_token_access_level(state, kind, slug, account_token).await?; if level == AccessLevel::Write { return Ok(level); } let mut checked_tokens = Vec::with_capacity(4); for token in [ supplied_access_token, crate::security::share_session_token(headers, kind, slug), crate::security::resource_token(headers, kind, slug), authorization_token(headers), ] { let Some(token) = token.map(str::trim).filter(|value| !value.is_empty()) else { continue; }; if checked_tokens.contains(&token) { continue; } checked_tokens.push(token); level = std::cmp::max( level, external_token_access_level(state, kind, slug, Some(token)).await?, ); if level == AccessLevel::Write { break; } } Ok(level) } fn access_level_name(level: AccessLevel) -> &'static str { match level { AccessLevel::None => "none", AccessLevel::Read => "read", AccessLevel::Write => "write", } } async fn effective_header_access_level( state: &SharedState, headers: &HeaderMap, kind: &str, slug: &str, is_private: i64, password_protected: bool, ) -> Result { let mut level = request_access_level(state, headers, kind, slug, None, bearer_token(headers)).await?; if is_private == 0 && !password_protected { level = std::cmp::max(level, AccessLevel::Write); } Ok(level) } fn require_write(level: AccessLevel) -> Result<(), ApiError> { if level >= AccessLevel::Write { Ok(()) } else { Err(ApiError::forbidden("Read-only access.")) } } async fn has_header_resource_access( state: &SharedState, headers: &HeaderMap, kind: &str, slug: &str, ) -> Result { Ok(request_access_level( state, headers, kind, slug, None, crate::security::session_cookie_token(headers), ) .await? != AccessLevel::None) } async fn ensure_private_resource_access( state: &SharedState, headers: &HeaderMap, kind: &str, slug: &str, is_private: i64, ) -> Result<(), ApiError> { if is_private == 0 { return Ok(()); } if has_header_resource_access(state, headers, kind, slug).await? { return Ok(()); } Err(ApiError::not_found_workspace()) } async fn check_resource_password_attempt( state: &SharedState, headers: &HeaderMap, kind: &str, slug: &str, password: Option<&str>, password_ok: bool, ) -> Result<(), ApiError> { let Some(_) = password.map(str::trim).filter(|value| !value.is_empty()) else { return Ok(()); }; let client_key = crate::security::client_key(headers); let window = std::time::Duration::from_secs(15 * 60); state .check_rate_limit(format!("resource-password-client:{client_key}"), 50, window) .await .map_err(|seconds| { ApiError::rate_limited(&format!( "Too many password attempts. Try again in {seconds} seconds." )) })?; let limit_key = format!("resource-password:{client_key}:{kind}:{slug}"); state .check_rate_limit(limit_key.clone(), 10, window) .await .map_err(|seconds| { ApiError::rate_limited(&format!( "Too many password attempts. Try again in {seconds} seconds." )) })?; if password_ok { state.clear_rate_limit(&limit_key).await; } Ok(()) } pub async fn authorized_workspace( state: &SharedState, slug: &str, password: Option<&str>, access_token: Option<&str>, bearer: Option<&str>, headers: &HeaderMap, ) -> Result { let workspace = db::find_workspace(&state.db, slug) .await? .ok_or_else(ApiError::not_found_workspace)?; let token_level = request_access_level(state, headers, "workspace", slug, access_token, bearer).await?; if workspace.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::not_found_workspace()); } if workspace.password_hash.is_some() && token_level < AccessLevel::Write { let password_ok = db::verify_workspace_password(&workspace, password); check_resource_password_attempt(state, headers, "workspace", slug, password, password_ok) .await?; if token_level == AccessLevel::None && !password_ok { 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>, headers: &HeaderMap, ) -> Result<(db::Workspace, db::Note), ApiError> { let workspace = authorized_workspace( state, workspace_slug, password, access_token, bearer, headers, ) .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, access_level: AccessLevel, can_set_password: bool, ) -> WorkspaceInfo { WorkspaceInfo { slug: workspace.slug.clone(), title: workspace.title.clone(), protected: workspace.password_hash.is_some(), access_level: access_level_name(access_level).into(), can_set_password, 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)) } fn validate_initial_content(content: Option<&str>) -> Result, ApiError> { let Some(content) = content.filter(|value| !value.is_empty()) else { return Ok(None); }; if content.len() > MAX_DOCUMENT_SIZE_BYTES { return Err(ApiError::bad_request("The document is too large")); } Ok(Some(content)) } 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")) }