use axum::{ extract::{Multipart, Path, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde::{Deserialize, Serialize}; use chrono::{Duration, Utc}; use rand_core::{OsRng, RngCore}; 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, } 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, Path(workspace_slug): Path, ) -> Result, ApiError> { let workspace = db::find_workspace(&state.db, &workspace_slug) .await? .ok_or_else(ApiError::not_found_workspace)?; Ok(Json(workspace_info_from(&workspace))) } pub async fn open_workspace( State(state): State, Path(workspace_slug): Path, Json(payload): Json, ) -> Result, ApiError> { let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let 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, Path(workspace_slug): Path, Json(payload): Json, ) -> Result<(StatusCode, Json), ApiError> { let workspace = authorized_workspace(&state, &workspace_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let 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, }), )) } pub async fn note_info( State(state): State, 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)?; let note = db::find_note(&state.db, workspace.id, ¬e_slug) .await? .ok_or_else(ApiError::not_found_note)?; 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), })) } pub async fn history( State(state): State, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; 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, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q028)) .bind(payload.revision_id) .bind(note.id) .fetch_optional(state.db.pool()) .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}))) } pub async fn authorized_workspace( state: &SharedState, slug: &str, password: Option<&str>, access_token: Option<&str>, ) -> Result { let workspace = db::find_workspace(&state.db, slug) .await? .ok_or_else(ApiError::not_found_workspace)?; let token_access = verify_resource_access_token(state, "workspace", slug, access_token).await?; if workspace.is_private != 0 && !token_access { return Err(ApiError::forbidden("This workspace is private.")); } if workspace.password_hash.is_some() && !db::verify_workspace_password(&workspace, password) && !token_access { 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>, ) -> Result<(db::Workspace, db::Note), ApiError> { let workspace = authorized_workspace(state, workspace_slug, password, access_token).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, } 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, Path(slug): Path, ) -> Result, ApiError> { let pad = db::find_pad(&state.db, &slug) .await? .ok_or_else(ApiError::not_found_note)?; 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), })) } pub async fn publish_pad_page( State(state): State, Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let 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, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { let (_, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let 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, Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let 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, Path(slug): Path, Json(payload): Json, ) -> Result, ApiError> { let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let content: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::Q029)) .bind(payload.revision_id) .bind(pad.id) .fetch_optional(state.db.pool()) .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>, ) -> Result { let pad = db::find_pad(&state.db, slug) .await? .ok_or_else(ApiError::not_found_note)?; let token_access = verify_resource_access_token(state, "pad", slug, access_token).await?; if pad.is_private != 0 && !token_access { return Err(ApiError::forbidden("This note is private.")); } if pad.password_hash.is_some() && !db::verify_pad_password(&pad, password) && !token_access { 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, 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()).await?; 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, Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { let pad = authorized_pad(&state, &slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; let 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 upload_note_file( State(state): State, 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()).await?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::note_file_token(&state.db, note.id).await?; let mut stored = safe.clone(); let mut key = crate::storage::object_key("notes", note.id, &file_token, &stored); if state.storage.exists(&key).await.map_err(|_| ApiError::internal("Failed to check file storage"))? { let stem = std::path::Path::new(&safe).file_stem().and_then(|v| v.to_str()).unwrap_or("plik"); let ext = std::path::Path::new(&safe).extension().and_then(|v| v.to_str()).map(|v| format!(".{v}")).unwrap_or_default(); 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, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; if note.protected { return Err(ApiError::bad_request("This note is protected and cannot be deleted")); } db::delete_note(&state.db, note.id).await?; Ok(Json(serde_json::json!({"ok": true}))) } pub async fn note_files( State(state): State, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { let (_workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; 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, Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>, Json(payload): Json, ) -> Result, ApiError> { let (workspace, note) = authorized_note(&state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref()).await?; if workspace.password_hash.is_none() || payload.password.as_deref().unwrap_or_default().is_empty() { return Err(ApiError::unauthorized()); } let file = db::find_note_file(&state.db, note.id, file_id).await? .ok_or_else(ApiError::not_found_file)?; let relative = file.url.trim_start_matches('/').split('/').collect::>(); if relative.len() == 3 && relative[0] == "f" { let key = crate::storage::object_key("notes", note.id, relative[1], &sanitize_filename(relative[2])); state.storage.delete(&key).await.map_err(|_| ApiError::internal("Failed to delete the file"))?; } 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( 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 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(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)")) .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(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?")) .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() } }