/* * 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. */ use super::*; #[derive(Debug, Deserialize)] pub struct CreatePadRequest { name: String, #[serde(default)] password: Option, #[serde(default)] content: Option, } #[derive(Debug, Deserialize)] pub struct SetPadPasswordRequest { password: String, #[serde(default)] client_id: Option, } #[derive(Debug, Serialize)] pub struct CreatePadResponse { slug: String, url: String, } #[derive(Debug, Serialize)] pub struct PadInfo { slug: String, title: String, protected: bool, access_level: String, 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, upload_max_size_bytes: Option, 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, toolbar_collapsed: bool, navbar_collapsed: 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, } 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 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_pad_slug(&state, &base).await?; let account_user = crate::auth::optional_user(&state, &headers) .await .map_err(|e| ApiError::forbidden(&e.message))?; let author = account_user.as_ref().map(|user| user.nickname.clone()); let created_by_guest_id = if account_user.is_none() { requester_guest_id(&headers) } else { None }; let pad = db::create_pad(&state.db, &slug, title, password, created_by_guest_id).await?; if let Some(user) = account_user.as_ref() { sqlx::query(queries::get(state.db.kind(), queries::USER_ATTACH_PAD)) .bind(user.id) .bind(&pad.slug) .execute(state.db.pool()) .await?; } if let Some(content) = initial_content { db::save_pad_revision(&state.db, pad.id, content, author.as_deref(), "[]").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, &headers, "pad", &pad.slug, pad.is_private).await?; let access_level = effective_header_access_level( &state, &headers, "pad", &pad.slug, pad.is_private, pad.password_hash.is_some(), ) .await?; let (global_color, note_color) = editor_colors(&state, &headers, "pad", &slug).await?; let (editor_preferences, personal_editor_settings) = user_editor_preferences(&state, &headers, db::EditorPreferenceResource::Pad(pad.id)) .await?; let resource_editor_settings = db::load_resource_editor_settings(&state.db, "pad", &slug).await?; let account_owner = crate::auth::is_resource_owner(&state, "pad", &slug, user_session_token(&headers)) .await .unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); let password_write_access = has_password_write_access(&state, &headers, "pad", &slug).await?; let can_manage_authorship = can_manage_resource_settings(account_owner, guest_owner, password_write_access); let upload_max_size_bytes = resource_upload_limit(&state, &headers, "pad", &slug).await?; let can_upload_files = upload_max_size_bytes.is_some(); let can_save_editor_settings = (personal_editor_settings || can_manage_authorship) && has_write_permission(&state, &headers, "pad", &slug).await?; if pad.is_private == 0 && pad.password_hash.is_some() && !db::pad_public_page_disabled(&state.db, pad.id).await? && !db::pad_public_page_enabled(&state.db, pad.id).await? { db::publish_pad(&state.db, pad.id).await?; } Ok(Json(PadInfo { slug: pad.slug, title: pad.title, protected: pad.password_hash.is_some(), access_level: access_level_name(access_level).into(), allow_public_task_updates: db::pad_public_task_updates(&state.db, pad.id).await?, public_page_unprotected: db::pad_public_page_unprotected(&state.db, pad.id).await?, public_page_enabled: db::pad_public_page_enabled(&state.db, pad.id).await?, private: pad.is_private != 0, created_at: db::normalize_timestamp(&pad.created_at), updated_at: db::normalize_timestamp(&pad.updated_at), can_delete_files: can_manage_authorship, can_upload_files, upload_max_size_bytes, 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, toolbar_collapsed: editor_preferences.toolbar_collapsed, navbar_collapsed: editor_preferences.navbar_collapsed, 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: can_set_resource_password( pad.password_hash.is_some(), account_owner, guest_owner, ), files: markdown_file_references(&state, Some(pad.id), None, None).await?, })) } pub async fn set_pad_password( State(state): State, headers: HeaderMap, Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; if pad.password_hash.is_some() { return Err(ApiError::bad_request("This note already has a password.")); } let account_owner = crate::auth::is_resource_owner( &state, "pad", &slug, user_session_token(&headers), ).await.unwrap_or(false); let guest_owner = pad_creator_is_requester(&headers, &pad); if !can_set_resource_password(pad.password_hash.is_some(), account_owner, guest_owner) { return Err(ApiError::forbidden("Only the note owner can set its password.")); } let except_client_id = crate::websocket::clean_collaboration_client_id(payload.client_id); let password = validate_password(Some(payload.password.as_str()))? .ok_or_else(|| ApiError::bad_request("Password is required."))?; db::set_pad_password(&state.db, &slug, password).await?; state .notify_pad_password_required(&slug, except_client_id) .await; Ok(Json(serde_json::json!({"ok": true, "protected": true}))) } pub async fn set_pad_editor_settings( State(state): State, headers: HeaderMap, Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; let creator_can_manage_authorship = can_manage_resource_settings( false, pad_creator_is_requester(&headers, &pad), has_password_write_access(&state, &headers, "pad", &slug).await?, ); save_editor_settings( &state, &headers, "pad", &slug, "pad", &slug, db::EditorPreferenceResource::Pad(pad.id), creator_can_manage_authorship, payload, ) .await } 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(), resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), bearer_token(&headers), &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 { request_access_level( &state, &headers, "pad", &slug, resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), bearer_token(&headers), ) .await? }; require_write(level)?; let enabled = payload.enabled.unwrap_or(true); if enabled && pad.password_hash.is_none() { return Err(ApiError::bad_request( "Set a resource password before enabling the published page.", )); } if !enabled { db::unpublish_pad(&state.db, pad.id).await?; db::set_pad_public_page_disabled(&state.db, pad.id, true).await?; return Ok(Json(PublishResponse { url: None, enabled: false, })); } db::set_pad_public_page_disabled(&state.db, pad.id, false).await?; let token = db::publish_pad(&state.db, pad.id).await?; db::set_pad_public_task_updates(&state.db, pad.id, payload.allow_task_updates).await?; db::set_pad_public_page_unprotected(&state.db, pad.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { url: Some(format!("/s/{token}")), enabled: true, })) } 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(), 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 enabled = payload.enabled.unwrap_or(true); if enabled && workspace.password_hash.is_none() { return Err(ApiError::bad_request( "Set a workspace password before enabling the published page.", )); } if !enabled { db::unpublish_note(&state.db, note.id).await?; db::set_note_public_page_disabled(&state.db, note.id, true).await?; return Ok(Json(PublishResponse { url: None, enabled: false, })); } db::set_note_public_page_disabled(&state.db, note.id, false).await?; let token = db::publish_note(&state.db, note.id).await?; db::set_note_public_task_updates(&state.db, note.id, payload.allow_task_updates).await?; db::set_note_public_page_unprotected(&state.db, note.id, payload.unprotect_page).await?; Ok(Json(PublishResponse { url: Some(format!("/s/{token}")), enabled: true, })) } fn page_password(headers: &HeaderMap) -> Option<&str> { headers .get("x-rustpad-page-password") .and_then(|value| value.to_str().ok()) .map(str::trim) .filter(|value| !value.is_empty()) } async fn ensure_public_page_access( state: &SharedState, headers: &HeaderMap, page: &db::PublishedPage, ) -> Result<(), ApiError> { let password = page_password(headers); if let Some(pad_id) = page.pad_id { let page_unprotected = db::pad_public_page_unprotected(&state.db, pad_id).await?; let sql = match state.db.kind() { crate::database::DatabaseKind::Postgres => "SELECT slug FROM pads WHERE id = $1", _ => "SELECT slug FROM pads WHERE id = ?", }; let slug: Option = sqlx::query_scalar(sql) .bind(pad_id) .fetch_optional(state.db.pool()) .await?; let Some(slug) = slug else { return Err(ApiError::not_found_note()); }; let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; if pad.password_hash.is_none() { return Err(ApiError::forbidden( "This published page is unavailable until a resource password is set.", )); } if page_unprotected || has_header_resource_access(state, headers, "pad", &slug).await? { return Ok(()); } let password_ok = db::verify_pad_password(&pad, password); check_resource_password_attempt(state, headers, "pad", &slug, password, password_ok) .await?; if password_ok { return Ok(()); } return if pad.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) }; } if let Some(note_id) = page.note_id { let page_unprotected = db::note_public_page_unprotected(&state.db, note_id).await?; let sql = match state.db.kind() { crate::database::DatabaseKind::Postgres => { "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = $1" } _ => { "SELECT w.slug FROM notes n JOIN workspaces w ON w.id = n.workspace_id WHERE n.id = ?" } }; let slug: Option = sqlx::query_scalar(sql) .bind(note_id) .fetch_optional(state.db.pool()) .await?; let Some(slug) = slug else { return Err(ApiError::not_found_note()); }; let workspace = db::find_workspace(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_workspace)?; if workspace.password_hash.is_none() { return Err(ApiError::forbidden( "This published page is unavailable until a workspace password is set.", )); } if page_unprotected || has_header_resource_access(state, headers, "workspace", &slug).await? { return Ok(()); } let password_ok = db::verify_workspace_password(&workspace, password); check_resource_password_attempt(state, headers, "workspace", &slug, password, password_ok) .await?; if password_ok { return Ok(()); } return if workspace.password_hash.is_some() { Err(ApiError::forbidden("Password required or incorrect.")) } else { Err(ApiError::forbidden("This published page is protected.")) }; } Err(ApiError::not_found_note()) } pub async fn public_page( State(state): State, headers: HeaderMap, Path(token): Path, ) -> Result, ApiError> { let page = db::find_published_page(&state.db, &token) .await? .ok_or_else(ApiError::not_found_note)?; ensure_public_page_access(&state, &headers, &page).await?; let files = markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, updated_at: db::normalize_timestamp(&page.updated_at), allow_task_updates: page.allow_task_updates, files, })) } async fn commit_public_pad_task_update( state: &SharedState, page: &db::PublishedPage, source_line: usize, checked: bool, ) -> Result<(), ApiError> { let pad_id = page.pad_id.ok_or_else(ApiError::not_found_note)?; let room_key = crate::state::AppState::pad_room_key(&page.resource_slug); let channel = state.pad_channel(&page.resource_slug).await; let collaboration_snapshot = db::pad_collaboration_snapshot(&state.db, pad_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 Some(next_content) = db::updated_public_task_content(&document.content, source_line, checked) else { return Ok(()); }; let base_revision_id = document.revision_id; let operation = collab::operation_from_edit(&document.content, &next_content, &document.owner_map); let (content, owner_map) = collab::apply_operation_to_document( &document.content, &document.owner_map, &operation, &[], ) .map_err(|_| ApiError::bad_request("The task could not be updated"))?; let (revision_id, updated_at) = db::save_pad_revision(&state.db, pad_id, &content, Some("public"), &owner_map).await?; let update_id = u64::try_from(revision_id).unwrap_or_default().max(1); document.content.clone_from(&content); document.owner_map.clone_from(&owner_map); document.revision_id = revision_id; document.record(AppliedOperation { base_revision_id, revision_id, client_id: "public_task".into(), update_id, operation: operation.clone(), owner_replacements: Vec::new(), }); let _ = channel.send(RoomEvent::Document(NoteUpdate { base_revision_id, revision_id, updated_at, author: Some("public".into()), client_id: "public_task".into(), update_id, operation, owner_replacements: Vec::new(), })); drop(document); Ok(()) } async fn commit_public_note_task_update( state: &SharedState, page: &db::PublishedPage, source_line: usize, checked: bool, ) -> Result<(), ApiError> { let note_id = page.note_id.ok_or_else(ApiError::not_found_note)?; let workspace_id = page.workspace_id.ok_or_else(ApiError::not_found_note)?; let workspace_slug = page .workspace_slug .as_deref() .ok_or_else(ApiError::not_found_note)?; let room_key = crate::state::AppState::note_room_key(workspace_slug, &page.resource_slug); let channel = state .note_channel(workspace_slug, &page.resource_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 Some(next_content) = db::updated_public_task_content(&document.content, source_line, checked) else { return Ok(()); }; let base_revision_id = document.revision_id; let operation = collab::operation_from_edit(&document.content, &next_content, &document.owner_map); let (content, owner_map) = collab::apply_operation_to_document( &document.content, &document.owner_map, &operation, &[], ) .map_err(|_| ApiError::bad_request("The task could not be updated"))?; let (revision_id, updated_at) = db::save_revision( &state.db, note_id, workspace_id, &content, Some("public"), &owner_map, ) .await?; let update_id = u64::try_from(revision_id).unwrap_or_default().max(1); document.content.clone_from(&content); document.owner_map.clone_from(&owner_map); document.revision_id = revision_id; document.record(AppliedOperation { base_revision_id, revision_id, client_id: "public_task".into(), update_id, operation: operation.clone(), owner_replacements: Vec::new(), }); let _ = channel.send(RoomEvent::Document(NoteUpdate { base_revision_id, revision_id, updated_at, author: Some("public".into()), client_id: "public_task".into(), update_id, operation, owner_replacements: Vec::new(), })); drop(document); Ok(()) } pub async fn update_public_task( State(state): State, headers: HeaderMap, 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)?; ensure_public_page_access(&state, &headers, ¤t).await?; if !current.allow_task_updates { return Err(ApiError::forbidden( "Task updates are disabled for this page", )); } if current.pad_id.is_some() { commit_public_pad_task_update(&state, ¤t, payload.source_line, payload.checked) .await?; } else { commit_public_note_task_update(&state, ¤t, payload.source_line, payload.checked) .await?; } let page = db::find_published_page(&state.db, &token) .await? .ok_or_else(ApiError::not_found_note)?; let files = markdown_file_references(&state, page.pad_id, page.note_id, Some(&page.content)).await?; Ok(Json(PublicPageResponse { title: page.title, content: page.content, updated_at: db::normalize_timestamp(&page.updated_at), allow_task_updates: page.allow_task_updates, files, })) } 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(), resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), bearer_token(&headers), &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(), resource_request_token(&headers, "pad", &slug, payload.access_token.as_deref()), bearer_token(&headers), &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 { request_access_level( &state, &headers, "pad", &slug, resource_request_token(&headers, "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 room_key = crate::state::AppState::pad_room_key(&slug); let channel = state.pad_channel(&slug).await; let collaboration_snapshot = db::pad_collaboration_snapshot(&state.db, pad.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 restored_owners = collab::owner_spans_from_map(&content, &owner_map); let operation = collab::replace_operation( document.content.encode_utf16().count(), content, restored_owners, ); 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_pad_revision(&state.db, pad.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}))) } pub(super) async fn authorized_pad( state: &SharedState, slug: &str, password: Option<&str>, access_token: Option<&str>, bearer: Option<&str>, headers: &HeaderMap, ) -> Result { let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; let token_level = request_access_level(state, headers, "pad", slug, access_token, bearer).await?; if pad.is_private != 0 && token_level == AccessLevel::None { return Err(ApiError::not_found_note()); } if pad.password_hash.is_some() && token_level < AccessLevel::Write { let password_ok = db::verify_pad_password(&pad, password); check_resource_password_attempt(state, headers, "pad", slug, password, password_ok).await?; if token_level == AccessLevel::None && !password_ok { return Err(ApiError::forbidden("Password required or incorrect.")); } } 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")) }