/* * 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::pads_public::authorized_pad; use super::*; pub async fn upload_pad_file( State(state): State, headers: HeaderMap, Path(slug): Path, mut multipart: Multipart, ) -> Result, ApiError> { let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; while let Some(field) = multipart .next_field() .await .map_err(|_| ApiError::bad_request("Invalid form data"))? { let name = field.name().unwrap_or_default().to_owned(); if name == "password" { password = Some( field .text() .await .map_err(|_| ApiError::bad_request("Invalid password"))?, ); } else if name == "access_token" { access_token = Some( field .text() .await .map_err(|_| ApiError::bad_request("Invalid access token"))?, ); } else if name == "file" { let filename = field.file_name().unwrap_or("plik").to_owned(); let bytes = field .bytes() .await .map_err(|_| ApiError::bad_request("Failed to read the file"))?; if bytes.len() > state.upload_max_size_bytes { return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); } file = Some((filename, bytes.to_vec())); } } let pad = authorized_pad( &state, &slug, password.as_deref(), access_token.as_deref(), bearer_token(&headers), ) .await?; let level = if db::verify_pad_password(&pad, password.as_deref()) || (pad.is_private == 0 && pad.password_hash.is_none()) { AccessLevel::Write } else { combined_token_access_level( &state, "pad", &slug, access_token.as_deref(), bearer_token(&headers), ) .await? }; require_write(level)?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::pad_file_token(&state.db, pad.id).await?; let mut stored = safe.clone(); let mut key = crate::storage::object_key("pads", pad.id, &file_token, &stored); if state .storage .exists(&key) .await .map_err(|_| ApiError::internal("Failed to check file storage"))? { let stem = std::path::Path::new(&safe) .file_stem() .and_then(|v| v.to_str()) .unwrap_or("plik"); let ext = std::path::Path::new(&safe) .extension() .and_then(|v| v.to_str()) .map(|v| format!(".{v}")) .unwrap_or_default(); stored = format!("{stem}-{}{}", db::random_suffix(6), ext); key = crate::storage::object_key("pads", pad.id, &file_token, &stored); } let url = format!("/f/{}/{}", file_token, stored); let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); state .storage .put(&key, bytes.clone().into(), &mime, &cache_control) .await .map_err(|_| ApiError::internal("Failed to save the file"))?; db::register_pad_file(&state.db, pad.id, &stored, &url, &mime, bytes.len() as i64).await?; Ok(Json(serde_json::json!({"name": stored, "url": url}))) } pub async fn pad_files( State(state): State, headers: HeaderMap, Path(slug): Path, Json(payload): Json, ) -> Result>, ApiError> { let pad = authorized_pad( &state, &slug, payload.password.as_deref(), payload.access_token.as_deref(), bearer_token(&headers), ) .await?; let mut files = db::list_pad_files(&state.db, pad.id).await?; for file in &mut files { let attached = pad.content.contains(&file.url); if attached != file.is_attached { db::set_pad_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; } file.created_at = db::normalize_timestamp(&file.created_at); } Ok(Json(files)) } pub async fn delete_pad_file( State(state): State, headers: HeaderMap, Path((slug, file_id)): Path<(String, i64)>, Json(payload): Json, ) -> Result, ApiError> { let pad = authorized_pad( &state, &slug, payload.password.as_deref(), payload.access_token.as_deref(), bearer_token(&headers), ) .await?; if !crate::auth::is_resource_owner(&state, "pad", &pad.slug, bearer_token(&headers)) .await .unwrap_or(false) { return Err(ApiError::forbidden("Only the note owner can delete files")); } let file = db::find_pad_file(&state.db, pad.id, file_id) .await? .ok_or_else(ApiError::not_found_file)?; crate::storage::delete_url_file(&state.storage, "pads", pad.id, &file.url) .await .map_err(|_| ApiError::internal("Failed to delete the file"))?; db::delete_pad_file(&state.db, pad.id, file_id).await?; Ok(Json(serde_json::json!({"ok": true}))) } pub async fn upload_note_file( State(state): State, headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, mut multipart: Multipart, ) -> Result, ApiError> { let mut password: Option = None; let mut access_token: Option = None; let mut file: Option<(String, Vec)> = None; while let Some(field) = multipart .next_field() .await .map_err(|_| ApiError::bad_request("Invalid form data"))? { let name = field.name().unwrap_or_default().to_owned(); if name == "password" { password = Some( field .text() .await .map_err(|_| ApiError::bad_request("Invalid password"))?, ); } else if name == "access_token" { access_token = Some( field .text() .await .map_err(|_| ApiError::bad_request("Invalid access token"))?, ); } else if name == "file" { let filename = field.file_name().unwrap_or("plik").to_owned(); let bytes = field .bytes() .await .map_err(|_| ApiError::bad_request("Failed to read the file"))?; if bytes.len() > state.upload_max_size_bytes { return Err(ApiError::payload_too_large(state.upload_max_size_bytes)); } file = Some((filename, bytes.to_vec())); } } let (workspace, note) = authorized_note( &state, &workspace_slug, ¬e_slug, password.as_deref(), access_token.as_deref(), bearer_token(&headers), ) .await?; let level = if db::verify_workspace_password(&workspace, password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) { AccessLevel::Write } else { combined_token_access_level( &state, "workspace", &workspace_slug, access_token.as_deref(), bearer_token(&headers), ) .await? }; require_write(level)?; let (original, bytes) = file.ok_or_else(|| ApiError::bad_request("No file provided"))?; let safe = sanitize_filename(&original); let file_token = db::note_file_token(&state.db, note.id).await?; let mut stored = safe.clone(); let mut key = crate::storage::object_key("notes", note.id, &file_token, &stored); if state .storage .exists(&key) .await .map_err(|_| ApiError::internal("Failed to check file storage"))? { let stem = std::path::Path::new(&safe) .file_stem() .and_then(|v| v.to_str()) .unwrap_or("plik"); let ext = std::path::Path::new(&safe) .extension() .and_then(|v| v.to_str()) .map(|v| format!(".{v}")) .unwrap_or_default(); stored = format!("{stem}-{}{}", db::random_suffix(6), ext); key = crate::storage::object_key("notes", note.id, &file_token, &stored); } let url = format!("/f/{}/{}", file_token, stored); let mime = mime_guess::from_path(&stored) .first_or_octet_stream() .to_string(); let cache_control = format!("public, max-age={}", state.file_cache_max_age_seconds); state .storage .put(&key, bytes.clone().into(), &mime, &cache_control) .await .map_err(|_| ApiError::internal("Failed to save the file"))?; db::register_note_file(&state.db, note.id, &stored, &url, &mime, bytes.len() as i64).await?; Ok(Json(serde_json::json!({"name": stored, "url": url}))) } pub async fn delete_note( State(state): State, headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result, ApiError> { let (workspace, note) = authorized_note( &state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), bearer_token(&headers), ) .await?; let level = if db::verify_workspace_password(&workspace, payload.password.as_deref()) || (workspace.is_private == 0 && workspace.password_hash.is_none()) { AccessLevel::Write } else { combined_token_access_level( &state, "workspace", &workspace_slug, payload.access_token.as_deref(), bearer_token(&headers), ) .await? }; require_write(level)?; if note.protected { return Err(ApiError::bad_request( "This note is protected and cannot be deleted", )); } for file in db::list_note_files(&state.db, note.id).await? { crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) .await .map_err(|_| ApiError::internal("Failed to delete note files"))?; } db::delete_note(&state.db, note.id).await?; Ok(Json(serde_json::json!({"ok": true}))) } pub async fn note_files( State(state): State, headers: HeaderMap, Path((workspace_slug, note_slug)): Path<(String, String)>, Json(payload): Json, ) -> Result>, ApiError> { let (_workspace, note) = authorized_note( &state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), bearer_token(&headers), ) .await?; let mut files = db::list_note_files(&state.db, note.id).await?; for file in &mut files { let attached = note.content.contains(&file.url); if attached != file.is_attached { db::set_note_file_attached(&state.db, file.id, attached).await?; file.is_attached = attached; file.detached_at = if attached { None } else { Some(chrono::Utc::now().to_rfc3339()) }; } file.created_at = db::normalize_timestamp(&file.created_at); } Ok(Json(files)) } pub async fn delete_note_file( State(state): State, headers: HeaderMap, Path((workspace_slug, note_slug, file_id)): Path<(String, String, i64)>, Json(payload): Json, ) -> Result, ApiError> { let (workspace, note) = authorized_note( &state, &workspace_slug, ¬e_slug, payload.password.as_deref(), payload.access_token.as_deref(), bearer_token(&headers), ) .await?; let workspace_owner = crate::auth::is_resource_owner( &state, "workspace", &workspace.slug, bearer_token(&headers), ) .await .unwrap_or(false); let note_owner = crate::auth::optional_user(&state, &headers) .await .ok() .flatten() .and_then(|user| { note.created_by .as_deref() .map(|creator| creator == user.nickname) }) .unwrap_or(false); if !workspace_owner && !note_owner { return Err(ApiError::forbidden( "Only the note owner or workspace owner can delete files", )); } let file = db::find_note_file(&state.db, note.id, file_id) .await? .ok_or_else(ApiError::not_found_file)?; crate::storage::delete_url_file(&state.storage, "notes", note.id, &file.url) .await .map_err(|_| ApiError::internal("Failed to delete the file"))?; db::delete_note_file(&state.db, note.id, file_id).await?; Ok(Json(serde_json::json!({"ok": true}))) } pub async fn download_file( State(state): State, Path((token, filename)): Path<(String, String)>, ) -> Result { serve_token_file(&state, &token, &filename).await } pub async fn download_legacy_file( State(state): State, Path((directory, filename)): Path<(String, String)>, ) -> Result { let Some((id_part, token)) = directory.split_once('_') else { return Err(ApiError::not_found_file()); }; let id: i64 = id_part.parse().map_err(|_| ApiError::not_found_file())?; let owner = db::find_file_owner(&state.db, token) .await? .ok_or_else(ApiError::not_found_file)?; if owner.id != id { return Err(ApiError::not_found_file()); } serve_token_file(&state, token, &filename).await } async fn serve_token_file( state: &SharedState, token: &str, filename: &str, ) -> Result { let safe = sanitize_filename(filename); if safe != filename { return Err(ApiError::not_found_file()); } let owner = db::find_file_owner(&state.db, token) .await? .ok_or_else(ApiError::not_found_file)?; let kind = match owner.kind { db::FileOwnerKind::Pad => "pads", db::FileOwnerKind::Note => "notes", }; let key = crate::storage::object_key(kind, owner.id, token, &safe); let legacy_key = crate::storage::legacy_key(owner.id, token, &safe); let bytes = state .storage .get_local_with_legacy(&key, &legacy_key) .await .map_err(|_| ApiError::not_found_file())?; let mime = mime_guess::from_path(&safe).first_or_octet_stream(); let mut response = bytes.into_response(); response.headers_mut().insert( header::CONTENT_TYPE, HeaderValue::from_str(mime.as_ref()) .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")), ); response.headers_mut().insert( header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), ); response.headers_mut().insert( HeaderName::from_static("x-robots-tag"), HeaderValue::from_static("noindex, nofollow, noarchive, nosnippet"), ); response.headers_mut().insert( header::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() } }