//! File attachment commands. //! //! Provides commands for attaching files to tasks and projects, //! backed by a content-addressed local blob store and synced via SyncKit. use chrono::{DateTime, Utc}; use serde::Serialize; use sha2::{Sha256, Digest}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tauri::State; use tracing::instrument; use goingson_core::{ AttachmentId, AttachmentMeta, EmailId, NewAttachment, ProjectId, TaskId, format_file_size, mime_from_extension, }; use crate::state::{AppState, DESKTOP_USER_ID}; use super::{ApiError, OptionNotFound, ResultApiError}; /// Maximum file size for attachments (50 MB). const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024; /// Get the size (in bytes) of a file on disk. Used by the compose UI to surface /// a per-message attachment total and warn before SMTP rejects an oversized send. #[tauri::command] #[instrument(skip_all)] pub async fn get_file_size(file_path: String) -> Result { let p = Path::new(&file_path); if file_path.contains("..") { return Err(ApiError::validation("filePath", "Path traversal not allowed")); } if !p.is_file() { return Err(ApiError::validation("filePath", "File does not exist or is not a regular file")); } let metadata = std::fs::metadata(p) .map_api_err("Failed to read file metadata", ApiError::internal)?; Ok(metadata.len()) } // ============ Types ============ /// Attachment response with pre-computed display fields. #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AttachmentResponse { pub id: AttachmentId, pub task_id: Option, pub project_id: Option, pub filename: String, pub file_size: i64, pub mime_type: String, pub blob_hash: String, pub source_email_id: Option, pub created_at: DateTime, pub file_size_formatted: String, pub has_local_blob: bool, } // ============ Commands ============ /// Attach a file to a task or project. /// /// Reads the file, computes SHA-256, copies to blob store (dedup), and creates /// the attachment record. The `file_path` comes from the JS file picker dialog. #[tauri::command] #[instrument(skip_all)] pub async fn add_attachment( state: State<'_, Arc>, task_id: Option, project_id: Option, file_path: String, ) -> Result { // Validate at least one parent if task_id.is_none() && project_id.is_none() { return Err(ApiError::validation_msg("Either taskId or projectId is required")); } // Verify the parent exists *before* touching the blob store, so a bad id // fails fast instead of after a disk write orphans a blob. if let Some(tid) = task_id { state.tasks.get_by_id(tid, DESKTOP_USER_ID).await? .or_not_found("task", tid)?; } if let Some(pid) = project_id { state.projects.get_by_id(pid, DESKTOP_USER_ID).await? .or_not_found("project", pid)?; } let source_path = Path::new(&file_path); // Validate path exists and is a file if !source_path.is_file() { return Err(ApiError::validation("filePath", "File does not exist or is not a regular file")); } // Validate no path traversal if file_path.contains("..") { return Err(ApiError::validation("filePath", "Path traversal not allowed")); } // Read file metadata let metadata = std::fs::metadata(source_path) .map_api_err("Failed to read file metadata", ApiError::internal)?; if metadata.len() > MAX_FILE_SIZE { return Err(ApiError::validation("filePath", format!( "File too large ({}, max {})", format_file_size(metadata.len() as i64), format_file_size(MAX_FILE_SIZE as i64), ))); } // Read (up to MAX_FILE_SIZE), hash, and write the blob on the blocking pool so a // large attachment doesn't stall the async reactor (ultra-fuzz Run #27 Perf S1). let source_owned = source_path.to_path_buf(); let blobs_dir = state.data_dir.join("blobs"); let (hash, file_size, wrote_new_blob, blob_path) = tokio::task::spawn_blocking( move || -> Result<(String, i64, bool, std::path::PathBuf), String> { let file_data = std::fs::read(&source_owned).map_err(|e| format!("Failed to read file: {e}"))?; let file_size = file_data.len() as i64; let hash = { let mut hasher = Sha256::new(); hasher.update(&file_data); format!("{:x}", hasher.finalize()) }; std::fs::create_dir_all(&blobs_dir) .map_err(|e| format!("Failed to create blobs directory: {e}"))?; // Copy to blob store (skip if hash already exists — dedup). Track whether // we wrote it so a failed insert can roll back exactly the blob we added. let blob_path = blobs_dir.join(&hash); let wrote_new_blob = !blob_path.exists(); if wrote_new_blob { std::fs::write(&blob_path, &file_data).map_err(|e| format!("Failed to write blob: {e}"))?; } Ok((hash, file_size, wrote_new_blob, blob_path)) }, ) .await .map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))? .map_err(ApiError::internal)?; // Extract filename from path let filename = source_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unnamed") .to_string(); let mime_type = mime_from_extension(&filename).to_string(); let create_result = state.attachments .create(DESKTOP_USER_ID, NewAttachment { task_id, project_id, filename, file_size, mime_type, blob_hash: hash, source_email_id: None, }) .await; let attachment = match create_result { Ok(a) => a, Err(e) => { // The row never landed; reclaim the blob we just wrote so it doesn't // leak. Only remove a blob we created this call (dedup hits must stay). if wrote_new_blob { let _ = std::fs::remove_file(&blob_path); } return Err(e.into()); } }; Ok(to_response(attachment, &state.data_dir)) } /// List attachments for a task or project. #[tauri::command] #[instrument(skip_all)] pub async fn list_attachments( state: State<'_, Arc>, task_id: Option, project_id: Option, ) -> Result, ApiError> { let attachments = if let Some(tid) = task_id { state.attachments.list_for_task(tid, DESKTOP_USER_ID).await? } else if let Some(pid) = project_id { state.attachments.list_for_project(pid, DESKTOP_USER_ID).await? } else { return Err(ApiError::validation_msg("Either taskId or projectId is required")); }; let data_dir = &state.data_dir; Ok(attachments.into_iter().map(|a| to_response(a, data_dir)).collect()) } /// Delete an attachment record. /// /// Does NOT delete the blob from disk — other attachments may reference the same hash. #[tauri::command] #[instrument(skip_all)] pub async fn delete_attachment( state: State<'_, Arc>, id: AttachmentId, ) -> Result { Ok(state.attachments.delete(id, DESKTOP_USER_ID).await?) } /// Open an attachment with the system default application. #[tauri::command] #[instrument(skip_all)] pub async fn open_attachment( state: State<'_, Arc>, id: AttachmentId, ) -> Result<(), ApiError> { let attachment = state.attachments .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("attachment", id)?; if !is_valid_blob_hash(&attachment.blob_hash) { return Err(ApiError::bad_request("Invalid attachment reference")); } let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Blob not available locally — sync required")); } // Create a temp directory keyed by blob hash so different attachments with the same // filename don't overwrite each other. let hash_prefix = if attachment.blob_hash.len() >= 8 { &attachment.blob_hash[..8] } else { &attachment.blob_hash }; let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix); std::fs::create_dir_all(&temp_dir) .map_api_err("Failed to create temp dir", ApiError::internal)?; // Owner-only before the copy: the decrypted blob must not be readable by // other local users via the world-readable system temp dir. super::harden_temp_dir(&temp_dir); // Sanitize filename: strip path separators, .., and control characters to prevent path traversal let safe_name: String = attachment.filename .replace(['/', '\\'], "_") .replace("..", "_") .chars() .filter(|c| !c.is_control()) .collect(); let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name }; let temp_path = temp_dir.join(&safe_name); // Copy blob to temp with original filename (overwrite if exists). On the blocking // pool — a large attachment copy must not stall the reactor (Perf S1). copy_blocking(blob_path, temp_path.clone()).await?; super::harden_temp_file(&temp_path); open::that(&temp_path) .map_api_err("Failed to open file", ApiError::internal)?; Ok(()) } /// Save an attachment to a user-chosen destination. #[tauri::command] #[instrument(skip_all)] pub async fn save_attachment( state: State<'_, Arc>, id: AttachmentId, destination: String, ) -> Result<(), ApiError> { let attachment = state.attachments .get_by_id(id, DESKTOP_USER_ID) .await? .or_not_found("attachment", id)?; if !is_valid_blob_hash(&attachment.blob_hash) { return Err(ApiError::bad_request("Invalid attachment reference")); } let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Blob not available locally — sync required")); } copy_blocking(blob_path, PathBuf::from(&destination)).await?; Ok(()) } /// Convert email attachments to task attachments. /// /// Reads the email's pre-stored `attachment_meta` JSON (populated during IMAP sync), /// verifies each blob exists on disk, and creates `Attachment` records linked to the task. #[tauri::command] #[instrument(skip_all)] pub async fn convert_email_attachments( state: State<'_, Arc>, email_id: EmailId, task_id: TaskId, ) -> Result, ApiError> { // Verify email and task exist let email = state.emails .get_by_id(email_id, DESKTOP_USER_ID) .await? .or_not_found("email", email_id)?; let _task = state.tasks .get_by_id(task_id, DESKTOP_USER_ID) .await? .or_not_found("task", task_id)?; // Parse attachment metadata — if none, return empty let meta_json = match email.attachment_meta { Some(ref json) if !json.is_empty() => json, _ => return Ok(Vec::new()), }; let metas: Vec = serde_json::from_str(meta_json) .map_api_err("Invalid attachment_meta JSON", ApiError::internal)?; let mut results = Vec::new(); let blobs_dir = state.data_dir.join("blobs"); for meta in metas { // Reject a malformed hash before it can escape the blobs dir (the meta JSON // is populated during sync and could carry a tampered value). if !is_valid_blob_hash(&meta.blob_hash) { tracing::warn!(blob_hash = %meta.blob_hash, "Skipping attachment with malformed blob hash"); continue; } // Verify blob exists on disk let blob_path = blobs_dir.join(&meta.blob_hash); if !blob_path.exists() { tracing::warn!( blob_hash = %meta.blob_hash, filename = %meta.filename, "Attachment blob missing — skipping" ); continue; } // Dedup: skip if attachment with same source_email_id + blob_hash already exists let existing = state.attachments .list_by_blob_hash(&meta.blob_hash, DESKTOP_USER_ID) .await?; if existing.iter().any(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) { // Already converted — include in results without creating duplicate if let Some(a) = existing.into_iter().find(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) { results.push(to_response(a, &state.data_dir)); } continue; } let attachment = state.attachments .create(DESKTOP_USER_ID, NewAttachment { task_id: Some(task_id), project_id: None, filename: meta.filename, file_size: meta.size as i64, mime_type: meta.mime_type, blob_hash: meta.blob_hash, source_email_id: Some(email_id), }) .await?; results.push(to_response(attachment, &state.data_dir)); } Ok(results) } /// Open an email attachment blob with the system default application. /// /// Unlike `open_attachment`, this works directly on the blob store using the hash /// and filename from the email's `attachment_meta` — no Attachment record needed. #[tauri::command] #[instrument(skip_all)] pub async fn open_email_blob( state: State<'_, Arc>, blob_hash: String, filename: String, ) -> Result<(), ApiError> { if !is_valid_blob_hash(&blob_hash) { return Err(ApiError::bad_request("Invalid attachment reference")); } let blob_path = state.data_dir.join("blobs").join(&blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Attachment not available locally — sync required")); } let hash_prefix = if blob_hash.len() >= 8 { &blob_hash[..8] } else { &blob_hash }; let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix); std::fs::create_dir_all(&temp_dir) .map_api_err("Failed to create temp dir", ApiError::internal)?; super::harden_temp_dir(&temp_dir); let safe_name: String = filename .replace(['/', '\\'], "_") .replace("..", "_") .chars() .filter(|c| !c.is_control()) .collect(); let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name }; let temp_path = temp_dir.join(&safe_name); copy_blocking(blob_path, temp_path.clone()).await?; super::harden_temp_file(&temp_path); open::that(&temp_path) .map_api_err("Failed to open file", ApiError::internal)?; Ok(()) } /// Save an email attachment blob to a user-chosen destination. #[tauri::command] #[instrument(skip_all)] pub async fn save_email_blob( state: State<'_, Arc>, blob_hash: String, destination: String, ) -> Result<(), ApiError> { if !is_valid_blob_hash(&blob_hash) { return Err(ApiError::bad_request("Invalid attachment reference")); } let blob_path = state.data_dir.join("blobs").join(&blob_hash); if !blob_path.exists() { return Err(ApiError::bad_request("Attachment not available locally — sync required")); } copy_blocking(blob_path, PathBuf::from(&destination)).await?; Ok(()) } // ============ Helpers ============ /// Remove the attachment temp spool (`/goingson-attachments`) left by /// previous sessions. Opened attachments are copied there for an external app to /// render and are not reaped per-file (a slow app may still be holding one open), /// so sweeping on startup bounds their lifetime to a single session. Owner-only /// perms (see `harden_temp_dir`/`harden_temp_file`) keep them private meanwhile. pub async fn cleanup_stale_attachment_temp() { let dir = std::env::temp_dir().join("goingson-attachments"); let _ = tokio::fs::remove_dir_all(&dir).await; } /// True only for a well-formed content-addressed blob hash (64-char lowercase /// SHA-256 hex). Guards every `blobs/` path join: a `blob_hash` can arrive /// from the frontend (`open_email_blob`/`save_email_blob`) or from a synced /// attachment row (`apply.rs` binds it unvalidated), so a value like /// `../../../../etc/passwd` must be rejected before it can escape the blobs dir /// and be copied out or launched (ultra-fuzz Run #27 Security S-1). pub(crate) fn is_valid_blob_hash(hash: &str) -> bool { hash.len() == 64 && hash .bytes() .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } /// Copy a file on the blocking pool so a large blob copy doesn't stall the async /// reactor (ultra-fuzz Run #27 Perf S1). async fn copy_blocking(from: PathBuf, to: PathBuf) -> Result<(), ApiError> { tokio::task::spawn_blocking(move || std::fs::copy(&from, &to)) .await .map_err(|e| ApiError::internal(format!("Copy task panicked: {e}")))? .map_err(|e| ApiError::internal(format!("Failed to copy file: {e}")))?; Ok(()) } fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentResponse { let has_local_blob = data_dir.join("blobs").join(&a.blob_hash).exists(); AttachmentResponse { id: a.id, task_id: a.task_id, project_id: a.project_id, filename: a.filename, file_size_formatted: format_file_size(a.file_size), file_size: a.file_size, mime_type: a.mime_type, blob_hash: a.blob_hash, source_email_id: a.source_email_id, created_at: a.created_at, has_local_blob, } } /// Get the path to a blob file by hash. pub(crate) fn blob_path(data_dir: &Path, hash: &str) -> PathBuf { data_dir.join("blobs").join(hash) } #[cfg(test)] mod tests { use super::is_valid_blob_hash; #[test] fn accepts_real_sha256_hex() { // 64 lowercase hex chars. let h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; assert!(is_valid_blob_hash(h)); } #[test] fn rejects_traversal_and_malformed() { assert!(!is_valid_blob_hash("../../../../etc/passwd")); assert!(!is_valid_blob_hash("..\\..\\windows\\system32")); assert!(!is_valid_blob_hash("")); // empty assert!(!is_valid_blob_hash("abc")); // too short assert!(!is_valid_blob_hash( "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855" )); // uppercase not allowed (canonical store is lowercase) assert!(!is_valid_blob_hash( "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85/" )); // 64 len but contains a slash } }