use std::path::Path; use std::sync::Arc; use std::time::{Duration, SystemTime}; use axum::extract::Multipart; use axum::extract::Path as AxumPath; use axum::response::{IntoResponse, Response}; use axum::{Extension, Json}; use serde::Serialize; use tokio::io::AsyncWriteExt; use crate::auth::session::SessionState; use crate::config::AppConfig; use crate::db; use crate::error::AppError; /// Maximum attachment file size: 25 MB. const MAX_ATTACHMENT_SIZE: usize = 25 * 1024 * 1024; const STALE_PART_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); async fn cleanup_stale_upload_parts(dir: &Path) { let Ok(mut entries) = tokio::fs::read_dir(dir).await else { return; }; while let Ok(Some(entry)) = entries.next_entry().await { let path = entry.path(); let is_part = path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".part")); if !is_part { continue; } let stale = entry .metadata() .await .ok() .and_then(|meta| meta.modified().ok()) .and_then(|modified| SystemTime::now().duration_since(modified).ok()) .is_some_and(|age| age >= STALE_PART_MAX_AGE); if stale { let _ = tokio::fs::remove_file(path).await; } } } #[derive(Debug, Serialize)] struct UploadResponse { id: String, filename: String, content_type: String, size: i64, } #[derive(Debug, Serialize)] struct DeleteResponse { status: String, } /// Handler for `POST /api/drafts/{draft_id}/attachments`. /// /// Accepts a multipart upload containing one or more files. Each file is /// saved to disk and recorded in the `draft_attachments` table. pub async fn upload_attachment( Extension(session): Extension, Extension(config): Extension>, AxumPath(draft_id): AxumPath, mut multipart: Multipart, ) -> Result { db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; // Ensure draft exists (create it if not — auto-creates on first attachment upload). let existing = db::drafts::get_draft(&conn, &draft_id).map_err(AppError::InternalError)?; if existing.is_none() { db::drafts::upsert_draft( &conn, &draft_id, "", "", "", "", "", None, None, None, false, "normal", "html", "none", false, false, false, ) .map_err(AppError::InternalError)?; } // Build the attachment storage directory. let att_dir = Path::new(&config.data_dir) .join(&session.user_hash) .join("attachments") .join(&draft_id); tokio::fs::create_dir_all(&att_dir) .await .map_err(|e| AppError::InternalError(format!("Failed to create attachment dir: {e}")))?; cleanup_stale_upload_parts(&att_dir).await; let mut uploaded = Vec::new(); while let Some(mut field) = multipart .next_field() .await .map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))? { let filename = field.file_name().unwrap_or("unnamed").to_string(); let content_type = field .content_type() .unwrap_or("application/octet-stream") .to_string(); let att_id = uuid::Uuid::new_v4().to_string(); let file_path = att_dir.join(&att_id); let part_path = att_dir.join(format!(".{att_id}.part")); let mut file = tokio::fs::File::create(&part_path).await.map_err(|e| { AppError::InternalError(format!("Failed to create attachment temp file: {e}")) })?; let mut size: usize = 0; loop { let chunk = match field.chunk().await { Ok(chunk) => chunk, Err(e) => { drop(file); let _ = tokio::fs::remove_file(&part_path).await; return Err(AppError::BadRequest(format!( "Failed to read field data: {e}" ))); } }; let Some(chunk) = chunk else { break }; size = size.saturating_add(chunk.len()); if size > MAX_ATTACHMENT_SIZE { drop(file); let _ = tokio::fs::remove_file(&part_path).await; return Err(AppError::BadRequest(format!( "File '{}' exceeds maximum size of 25 MB", filename ))); } if let Err(e) = file.write_all(&chunk).await { drop(file); let _ = tokio::fs::remove_file(&part_path).await; return Err(AppError::InternalError(format!( "Failed to write attachment: {e}" ))); } } if let Err(e) = file.flush().await { drop(file); let _ = tokio::fs::remove_file(&part_path).await; return Err(AppError::InternalError(format!( "Failed to flush attachment: {e}" ))); } drop(file); if let Err(e) = tokio::fs::rename(&part_path, &file_path).await { let _ = tokio::fs::remove_file(&part_path).await; return Err(AppError::InternalError(format!( "Failed to finalize attachment: {e}" ))); } let size = size as i64; if let Err(e) = db::drafts::add_draft_attachment( &conn, &att_id, &draft_id, &filename, &content_type, size, file_path.to_str().unwrap_or(""), ) { let _ = tokio::fs::remove_file(&file_path).await; return Err(AppError::InternalError(e)); } uploaded.push(UploadResponse { id: att_id, filename, content_type, size, }); } if uploaded.is_empty() { return Err(AppError::BadRequest("No files uploaded".to_string())); } Ok(Json(serde_json::json!({ "attachments": uploaded, })) .into_response()) } /// Handler for `GET /api/drafts/{draft_id}/attachments/{attachment_id}/content`. /// /// Serves the raw file content of a draft attachment for inline preview. pub async fn get_attachment_content( Extension(session): Extension, Extension(config): Extension>, AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>, ) -> Result { db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; let attachments = db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?; let attachment = attachments .iter() .find(|a| a.id == attachment_id) .ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?; let data = tokio::fs::read(&attachment.file_path) .await .map_err(|e| AppError::InternalError(format!("Failed to read attachment file: {e}")))?; Ok(Response::builder() .header("content-type", &attachment.content_type) .header("cache-control", "private, max-age=3600") .body(axum::body::Body::from(data)) .unwrap()) } /// Handler for `DELETE /api/drafts/{draft_id}/attachments/{attachment_id}`. /// /// Removes an attachment from the database and deletes the file from disk. pub async fn delete_attachment( Extension(session): Extension, Extension(config): Extension>, AxumPath((draft_id, attachment_id)): AxumPath<(String, String)>, ) -> Result { db::drafts::validate_draft_id(&draft_id).map_err(AppError::BadRequest)?; let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash) .map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?; // Get the attachment record so we can find the file path. let attachments = db::drafts::get_draft_attachments(&conn, &draft_id).map_err(AppError::InternalError)?; let attachment = attachments .iter() .find(|a| a.id == attachment_id) .ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?; // Delete file from disk (best-effort). let file_path = attachment.file_path.clone(); if let Err(e) = tokio::fs::remove_file(&file_path).await { tracing::warn!(error = %e, path = %file_path, "Failed to delete attachment file from disk"); } // Delete from DB. db::drafts::delete_draft_attachment(&conn, &attachment_id).map_err(AppError::InternalError)?; Ok(Json(DeleteResponse { status: "deleted".to_string(), }) .into_response()) }