Skip to main content

max / goingson

18.6 KB · 520 lines History Blame Raw
1 //! File attachment commands.
2 //!
3 //! Provides commands for attaching files to tasks and projects,
4 //! backed by a content-addressed local blob store and synced via SyncKit.
5
6 use chrono::{DateTime, Utc};
7 use serde::Serialize;
8 use sha2::{Sha256, Digest};
9 use std::path::{Path, PathBuf};
10 use std::sync::Arc;
11 use tauri::State;
12 use tracing::instrument;
13
14 use goingson_core::{
15 AttachmentId, AttachmentMeta, EmailId, NewAttachment, ProjectId, TaskId,
16 format_file_size, mime_from_extension,
17 };
18
19 use crate::state::{AppState, DESKTOP_USER_ID};
20 use super::{ApiError, OptionNotFound, ResultApiError};
21
22 /// Maximum file size for attachments (50 MB).
23 const MAX_FILE_SIZE: u64 = 50 * 1024 * 1024;
24
25 /// Get the size (in bytes) of a file on disk. Used by the compose UI to surface
26 /// a per-message attachment total and warn before SMTP rejects an oversized send.
27 #[tauri::command]
28 #[instrument(skip_all)]
29 pub async fn get_file_size(file_path: String) -> Result<u64, ApiError> {
30 let p = Path::new(&file_path);
31 if file_path.contains("..") {
32 return Err(ApiError::validation("filePath", "Path traversal not allowed"));
33 }
34 if !p.is_file() {
35 return Err(ApiError::validation("filePath", "File does not exist or is not a regular file"));
36 }
37 let metadata = std::fs::metadata(p)
38 .map_api_err("Failed to read file metadata", ApiError::internal)?;
39 Ok(metadata.len())
40 }
41
42 // ============ Types ============
43
44 /// Attachment response with pre-computed display fields.
45 #[derive(Debug, Serialize)]
46 #[serde(rename_all = "camelCase")]
47 pub struct AttachmentResponse {
48 pub id: AttachmentId,
49 pub task_id: Option<TaskId>,
50 pub project_id: Option<ProjectId>,
51 pub filename: String,
52 pub file_size: i64,
53 pub mime_type: String,
54 pub blob_hash: String,
55 pub source_email_id: Option<EmailId>,
56 pub created_at: DateTime<Utc>,
57 pub file_size_formatted: String,
58 pub has_local_blob: bool,
59 }
60
61 // ============ Commands ============
62
63 /// Attach a file to a task or project.
64 ///
65 /// Reads the file, computes SHA-256, copies to blob store (dedup), and creates
66 /// the attachment record. The `file_path` comes from the JS file picker dialog.
67 #[tauri::command]
68 #[instrument(skip_all)]
69 pub async fn add_attachment(
70 state: State<'_, Arc<AppState>>,
71 task_id: Option<TaskId>,
72 project_id: Option<ProjectId>,
73 file_path: String,
74 ) -> Result<AttachmentResponse, ApiError> {
75 // Validate at least one parent
76 if task_id.is_none() && project_id.is_none() {
77 return Err(ApiError::validation_msg("Either taskId or projectId is required"));
78 }
79
80 // Verify the parent exists *before* touching the blob store, so a bad id
81 // fails fast instead of after a disk write orphans a blob.
82 if let Some(tid) = task_id {
83 state.tasks.get_by_id(tid, DESKTOP_USER_ID).await?
84 .or_not_found("task", tid)?;
85 }
86 if let Some(pid) = project_id {
87 state.projects.get_by_id(pid, DESKTOP_USER_ID).await?
88 .or_not_found("project", pid)?;
89 }
90
91 let source_path = Path::new(&file_path);
92
93 // Validate path exists and is a file
94 if !source_path.is_file() {
95 return Err(ApiError::validation("filePath", "File does not exist or is not a regular file"));
96 }
97
98 // Validate no path traversal
99 if file_path.contains("..") {
100 return Err(ApiError::validation("filePath", "Path traversal not allowed"));
101 }
102
103 // Read file metadata
104 let metadata = std::fs::metadata(source_path)
105 .map_api_err("Failed to read file metadata", ApiError::internal)?;
106
107 if metadata.len() > MAX_FILE_SIZE {
108 return Err(ApiError::validation("filePath", format!(
109 "File too large ({}, max {})",
110 format_file_size(metadata.len() as i64),
111 format_file_size(MAX_FILE_SIZE as i64),
112 )));
113 }
114
115 // Read (up to MAX_FILE_SIZE), hash, and write the blob on the blocking pool so a
116 // large attachment doesn't stall the async reactor (ultra-fuzz Run #27 Perf S1).
117 let source_owned = source_path.to_path_buf();
118 let blobs_dir = state.data_dir.join("blobs");
119 let (hash, file_size, wrote_new_blob, blob_path) = tokio::task::spawn_blocking(
120 move || -> Result<(String, i64, bool, std::path::PathBuf), String> {
121 let file_data = std::fs::read(&source_owned).map_err(|e| format!("Failed to read file: {e}"))?;
122 let file_size = file_data.len() as i64;
123 let hash = {
124 let mut hasher = Sha256::new();
125 hasher.update(&file_data);
126 format!("{:x}", hasher.finalize())
127 };
128 std::fs::create_dir_all(&blobs_dir)
129 .map_err(|e| format!("Failed to create blobs directory: {e}"))?;
130 // Copy to blob store (skip if hash already exists — dedup). Track whether
131 // we wrote it so a failed insert can roll back exactly the blob we added.
132 let blob_path = blobs_dir.join(&hash);
133 let wrote_new_blob = !blob_path.exists();
134 if wrote_new_blob {
135 std::fs::write(&blob_path, &file_data).map_err(|e| format!("Failed to write blob: {e}"))?;
136 }
137 Ok((hash, file_size, wrote_new_blob, blob_path))
138 },
139 )
140 .await
141 .map_err(|e| ApiError::internal(format!("Attachment task panicked: {e}")))?
142 .map_err(ApiError::internal)?;
143
144 // Extract filename from path
145 let filename = source_path
146 .file_name()
147 .and_then(|n| n.to_str())
148 .unwrap_or("unnamed")
149 .to_string();
150
151 let mime_type = mime_from_extension(&filename).to_string();
152
153 let create_result = state.attachments
154 .create(DESKTOP_USER_ID, NewAttachment {
155 task_id,
156 project_id,
157 filename,
158 file_size,
159 mime_type,
160 blob_hash: hash,
161 source_email_id: None,
162 })
163 .await;
164
165 let attachment = match create_result {
166 Ok(a) => a,
167 Err(e) => {
168 // The row never landed; reclaim the blob we just wrote so it doesn't
169 // leak. Only remove a blob we created this call (dedup hits must stay).
170 if wrote_new_blob {
171 let _ = std::fs::remove_file(&blob_path);
172 }
173 return Err(e.into());
174 }
175 };
176
177 Ok(to_response(attachment, &state.data_dir))
178 }
179
180 /// List attachments for a task or project.
181 #[tauri::command]
182 #[instrument(skip_all)]
183 pub async fn list_attachments(
184 state: State<'_, Arc<AppState>>,
185 task_id: Option<TaskId>,
186 project_id: Option<ProjectId>,
187 ) -> Result<Vec<AttachmentResponse>, ApiError> {
188 let attachments = if let Some(tid) = task_id {
189 state.attachments.list_for_task(tid, DESKTOP_USER_ID).await?
190 } else if let Some(pid) = project_id {
191 state.attachments.list_for_project(pid, DESKTOP_USER_ID).await?
192 } else {
193 return Err(ApiError::validation_msg("Either taskId or projectId is required"));
194 };
195
196 let data_dir = &state.data_dir;
197 Ok(attachments.into_iter().map(|a| to_response(a, data_dir)).collect())
198 }
199
200 /// Delete an attachment record.
201 ///
202 /// Does NOT delete the blob from disk — other attachments may reference the same hash.
203 #[tauri::command]
204 #[instrument(skip_all)]
205 pub async fn delete_attachment(
206 state: State<'_, Arc<AppState>>,
207 id: AttachmentId,
208 ) -> Result<bool, ApiError> {
209 Ok(state.attachments.delete(id, DESKTOP_USER_ID).await?)
210 }
211
212 /// Open an attachment with the system default application.
213 #[tauri::command]
214 #[instrument(skip_all)]
215 pub async fn open_attachment(
216 state: State<'_, Arc<AppState>>,
217 id: AttachmentId,
218 ) -> Result<(), ApiError> {
219 let attachment = state.attachments
220 .get_by_id(id, DESKTOP_USER_ID)
221 .await?
222 .or_not_found("attachment", id)?;
223
224 if !is_valid_blob_hash(&attachment.blob_hash) {
225 return Err(ApiError::bad_request("Invalid attachment reference"));
226 }
227 let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash);
228 if !blob_path.exists() {
229 return Err(ApiError::bad_request("Blob not available locally — sync required"));
230 }
231
232 // Create a temp directory keyed by blob hash so different attachments with the same
233 // filename don't overwrite each other.
234 let hash_prefix = if attachment.blob_hash.len() >= 8 {
235 &attachment.blob_hash[..8]
236 } else {
237 &attachment.blob_hash
238 };
239 let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix);
240 std::fs::create_dir_all(&temp_dir)
241 .map_api_err("Failed to create temp dir", ApiError::internal)?;
242 // Owner-only before the copy: the decrypted blob must not be readable by
243 // other local users via the world-readable system temp dir.
244 super::harden_temp_dir(&temp_dir);
245
246 // Sanitize filename: strip path separators, .., and control characters to prevent path traversal
247 let safe_name: String = attachment.filename
248 .replace(['/', '\\'], "_")
249 .replace("..", "_")
250 .chars()
251 .filter(|c| !c.is_control())
252 .collect();
253 let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name };
254 let temp_path = temp_dir.join(&safe_name);
255 // Copy blob to temp with original filename (overwrite if exists). On the blocking
256 // pool — a large attachment copy must not stall the reactor (Perf S1).
257 copy_blocking(blob_path, temp_path.clone()).await?;
258 super::harden_temp_file(&temp_path);
259
260 open::that(&temp_path)
261 .map_api_err("Failed to open file", ApiError::internal)?;
262
263 Ok(())
264 }
265
266 /// Save an attachment to a user-chosen destination.
267 #[tauri::command]
268 #[instrument(skip_all)]
269 pub async fn save_attachment(
270 state: State<'_, Arc<AppState>>,
271 id: AttachmentId,
272 destination: String,
273 ) -> Result<(), ApiError> {
274 let attachment = state.attachments
275 .get_by_id(id, DESKTOP_USER_ID)
276 .await?
277 .or_not_found("attachment", id)?;
278
279 if !is_valid_blob_hash(&attachment.blob_hash) {
280 return Err(ApiError::bad_request("Invalid attachment reference"));
281 }
282 let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash);
283 if !blob_path.exists() {
284 return Err(ApiError::bad_request("Blob not available locally — sync required"));
285 }
286
287 copy_blocking(blob_path, PathBuf::from(&destination)).await?;
288
289 Ok(())
290 }
291
292 /// Convert email attachments to task attachments.
293 ///
294 /// Reads the email's pre-stored `attachment_meta` JSON (populated during IMAP sync),
295 /// verifies each blob exists on disk, and creates `Attachment` records linked to the task.
296 #[tauri::command]
297 #[instrument(skip_all)]
298 pub async fn convert_email_attachments(
299 state: State<'_, Arc<AppState>>,
300 email_id: EmailId,
301 task_id: TaskId,
302 ) -> Result<Vec<AttachmentResponse>, ApiError> {
303 // Verify email and task exist
304 let email = state.emails
305 .get_by_id(email_id, DESKTOP_USER_ID)
306 .await?
307 .or_not_found("email", email_id)?;
308
309 let _task = state.tasks
310 .get_by_id(task_id, DESKTOP_USER_ID)
311 .await?
312 .or_not_found("task", task_id)?;
313
314 // Parse attachment metadata — if none, return empty
315 let meta_json = match email.attachment_meta {
316 Some(ref json) if !json.is_empty() => json,
317 _ => return Ok(Vec::new()),
318 };
319
320 let metas: Vec<AttachmentMeta> = serde_json::from_str(meta_json)
321 .map_api_err("Invalid attachment_meta JSON", ApiError::internal)?;
322
323 let mut results = Vec::new();
324 let blobs_dir = state.data_dir.join("blobs");
325
326 for meta in metas {
327 // Reject a malformed hash before it can escape the blobs dir (the meta JSON
328 // is populated during sync and could carry a tampered value).
329 if !is_valid_blob_hash(&meta.blob_hash) {
330 tracing::warn!(blob_hash = %meta.blob_hash, "Skipping attachment with malformed blob hash");
331 continue;
332 }
333 // Verify blob exists on disk
334 let blob_path = blobs_dir.join(&meta.blob_hash);
335 if !blob_path.exists() {
336 tracing::warn!(
337 blob_hash = %meta.blob_hash,
338 filename = %meta.filename,
339 "Attachment blob missing — skipping"
340 );
341 continue;
342 }
343
344 // Dedup: skip if attachment with same source_email_id + blob_hash already exists
345 let existing = state.attachments
346 .list_by_blob_hash(&meta.blob_hash, DESKTOP_USER_ID)
347 .await?;
348 if existing.iter().any(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) {
349 // Already converted — include in results without creating duplicate
350 if let Some(a) = existing.into_iter().find(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) {
351 results.push(to_response(a, &state.data_dir));
352 }
353 continue;
354 }
355
356 let attachment = state.attachments
357 .create(DESKTOP_USER_ID, NewAttachment {
358 task_id: Some(task_id),
359 project_id: None,
360 filename: meta.filename,
361 file_size: meta.size as i64,
362 mime_type: meta.mime_type,
363 blob_hash: meta.blob_hash,
364 source_email_id: Some(email_id),
365 })
366 .await?;
367
368 results.push(to_response(attachment, &state.data_dir));
369 }
370
371 Ok(results)
372 }
373
374 /// Open an email attachment blob with the system default application.
375 ///
376 /// Unlike `open_attachment`, this works directly on the blob store using the hash
377 /// and filename from the email's `attachment_meta` — no Attachment record needed.
378 #[tauri::command]
379 #[instrument(skip_all)]
380 pub async fn open_email_blob(
381 state: State<'_, Arc<AppState>>,
382 blob_hash: String,
383 filename: String,
384 ) -> Result<(), ApiError> {
385 if !is_valid_blob_hash(&blob_hash) {
386 return Err(ApiError::bad_request("Invalid attachment reference"));
387 }
388 let blob_path = state.data_dir.join("blobs").join(&blob_hash);
389 if !blob_path.exists() {
390 return Err(ApiError::bad_request("Attachment not available locally — sync required"));
391 }
392
393 let hash_prefix = if blob_hash.len() >= 8 { &blob_hash[..8] } else { &blob_hash };
394 let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix);
395 std::fs::create_dir_all(&temp_dir)
396 .map_api_err("Failed to create temp dir", ApiError::internal)?;
397 super::harden_temp_dir(&temp_dir);
398
399 let safe_name: String = filename
400 .replace(['/', '\\'], "_")
401 .replace("..", "_")
402 .chars()
403 .filter(|c| !c.is_control())
404 .collect();
405 let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name };
406 let temp_path = temp_dir.join(&safe_name);
407
408 copy_blocking(blob_path, temp_path.clone()).await?;
409 super::harden_temp_file(&temp_path);
410
411 open::that(&temp_path)
412 .map_api_err("Failed to open file", ApiError::internal)?;
413
414 Ok(())
415 }
416
417 /// Save an email attachment blob to a user-chosen destination.
418 #[tauri::command]
419 #[instrument(skip_all)]
420 pub async fn save_email_blob(
421 state: State<'_, Arc<AppState>>,
422 blob_hash: String,
423 destination: String,
424 ) -> Result<(), ApiError> {
425 if !is_valid_blob_hash(&blob_hash) {
426 return Err(ApiError::bad_request("Invalid attachment reference"));
427 }
428 let blob_path = state.data_dir.join("blobs").join(&blob_hash);
429 if !blob_path.exists() {
430 return Err(ApiError::bad_request("Attachment not available locally — sync required"));
431 }
432
433 copy_blocking(blob_path, PathBuf::from(&destination)).await?;
434
435 Ok(())
436 }
437
438 // ============ Helpers ============
439
440 /// Remove the attachment temp spool (`<tmp>/goingson-attachments`) left by
441 /// previous sessions. Opened attachments are copied there for an external app to
442 /// render and are not reaped per-file (a slow app may still be holding one open),
443 /// so sweeping on startup bounds their lifetime to a single session. Owner-only
444 /// perms (see `harden_temp_dir`/`harden_temp_file`) keep them private meanwhile.
445 pub async fn cleanup_stale_attachment_temp() {
446 let dir = std::env::temp_dir().join("goingson-attachments");
447 let _ = tokio::fs::remove_dir_all(&dir).await;
448 }
449
450 /// True only for a well-formed content-addressed blob hash (64-char lowercase
451 /// SHA-256 hex). Guards every `blobs/<hash>` path join: a `blob_hash` can arrive
452 /// from the frontend (`open_email_blob`/`save_email_blob`) or from a synced
453 /// attachment row (`apply.rs` binds it unvalidated), so a value like
454 /// `../../../../etc/passwd` must be rejected before it can escape the blobs dir
455 /// and be copied out or launched (ultra-fuzz Run #27 Security S-1).
456 pub(crate) fn is_valid_blob_hash(hash: &str) -> bool {
457 hash.len() == 64
458 && hash
459 .bytes()
460 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
461 }
462
463 /// Copy a file on the blocking pool so a large blob copy doesn't stall the async
464 /// reactor (ultra-fuzz Run #27 Perf S1).
465 async fn copy_blocking(from: PathBuf, to: PathBuf) -> Result<(), ApiError> {
466 tokio::task::spawn_blocking(move || std::fs::copy(&from, &to))
467 .await
468 .map_err(|e| ApiError::internal(format!("Copy task panicked: {e}")))?
469 .map_err(|e| ApiError::internal(format!("Failed to copy file: {e}")))?;
470 Ok(())
471 }
472
473 fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentResponse {
474 let has_local_blob = data_dir.join("blobs").join(&a.blob_hash).exists();
475 AttachmentResponse {
476 id: a.id,
477 task_id: a.task_id,
478 project_id: a.project_id,
479 filename: a.filename,
480 file_size_formatted: format_file_size(a.file_size),
481 file_size: a.file_size,
482 mime_type: a.mime_type,
483 blob_hash: a.blob_hash,
484 source_email_id: a.source_email_id,
485 created_at: a.created_at,
486 has_local_blob,
487 }
488 }
489
490 /// Get the path to a blob file by hash.
491 pub(crate) fn blob_path(data_dir: &Path, hash: &str) -> PathBuf {
492 data_dir.join("blobs").join(hash)
493 }
494
495 #[cfg(test)]
496 mod tests {
497 use super::is_valid_blob_hash;
498
499 #[test]
500 fn accepts_real_sha256_hex() {
501 // 64 lowercase hex chars.
502 let h = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
503 assert!(is_valid_blob_hash(h));
504 }
505
506 #[test]
507 fn rejects_traversal_and_malformed() {
508 assert!(!is_valid_blob_hash("../../../../etc/passwd"));
509 assert!(!is_valid_blob_hash("..\\..\\windows\\system32"));
510 assert!(!is_valid_blob_hash("")); // empty
511 assert!(!is_valid_blob_hash("abc")); // too short
512 assert!(!is_valid_blob_hash(
513 "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
514 )); // uppercase not allowed (canonical store is lowercase)
515 assert!(!is_valid_blob_hash(
516 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b85/"
517 )); // 64 len but contains a slash
518 }
519 }
520