Skip to main content

max / goingson

13.5 KB · 411 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 let source_path = Path::new(&file_path);
81
82 // Validate path exists and is a file
83 if !source_path.is_file() {
84 return Err(ApiError::validation("filePath", "File does not exist or is not a regular file"));
85 }
86
87 // Validate no path traversal
88 if file_path.contains("..") {
89 return Err(ApiError::validation("filePath", "Path traversal not allowed"));
90 }
91
92 // Read file metadata
93 let metadata = std::fs::metadata(source_path)
94 .map_api_err("Failed to read file metadata", ApiError::internal)?;
95
96 if metadata.len() > MAX_FILE_SIZE {
97 return Err(ApiError::validation("filePath", format!(
98 "File too large ({}, max {})",
99 format_file_size(metadata.len() as i64),
100 format_file_size(MAX_FILE_SIZE as i64),
101 )));
102 }
103
104 // Read file and compute SHA-256
105 let file_data = std::fs::read(source_path)
106 .map_api_err("Failed to read file", ApiError::internal)?;
107
108 let hash = {
109 let mut hasher = Sha256::new();
110 hasher.update(&file_data);
111 format!("{:x}", hasher.finalize())
112 };
113
114 // Ensure blobs directory exists
115 let blobs_dir = state.data_dir.join("blobs");
116 std::fs::create_dir_all(&blobs_dir)
117 .map_api_err("Failed to create blobs directory", ApiError::internal)?;
118
119 // Copy to blob store (skip if hash already exists — dedup)
120 let blob_path = blobs_dir.join(&hash);
121 if !blob_path.exists() {
122 std::fs::write(&blob_path, &file_data)
123 .map_api_err("Failed to write blob", ApiError::internal)?;
124 }
125
126 // Extract filename from path
127 let filename = source_path
128 .file_name()
129 .and_then(|n| n.to_str())
130 .unwrap_or("unnamed")
131 .to_string();
132
133 let mime_type = mime_from_extension(&filename).to_string();
134 let file_size = file_data.len() as i64;
135
136 let attachment = state.attachments
137 .create(DESKTOP_USER_ID, NewAttachment {
138 task_id,
139 project_id,
140 filename,
141 file_size,
142 mime_type,
143 blob_hash: hash,
144 source_email_id: None,
145 })
146 .await?;
147
148 Ok(to_response(attachment, &state.data_dir))
149 }
150
151 /// List attachments for a task or project.
152 #[tauri::command]
153 #[instrument(skip_all)]
154 pub async fn list_attachments(
155 state: State<'_, Arc<AppState>>,
156 task_id: Option<TaskId>,
157 project_id: Option<ProjectId>,
158 ) -> Result<Vec<AttachmentResponse>, ApiError> {
159 let attachments = if let Some(tid) = task_id {
160 state.attachments.list_for_task(tid, DESKTOP_USER_ID).await?
161 } else if let Some(pid) = project_id {
162 state.attachments.list_for_project(pid, DESKTOP_USER_ID).await?
163 } else {
164 return Err(ApiError::validation_msg("Either taskId or projectId is required"));
165 };
166
167 let data_dir = &state.data_dir;
168 Ok(attachments.into_iter().map(|a| to_response(a, data_dir)).collect())
169 }
170
171 /// Delete an attachment record.
172 ///
173 /// Does NOT delete the blob from disk — other attachments may reference the same hash.
174 #[tauri::command]
175 #[instrument(skip_all)]
176 pub async fn delete_attachment(
177 state: State<'_, Arc<AppState>>,
178 id: AttachmentId,
179 ) -> Result<bool, ApiError> {
180 Ok(state.attachments.delete(id, DESKTOP_USER_ID).await?)
181 }
182
183 /// Open an attachment with the system default application.
184 #[tauri::command]
185 #[instrument(skip_all)]
186 pub async fn open_attachment(
187 state: State<'_, Arc<AppState>>,
188 id: AttachmentId,
189 ) -> Result<(), ApiError> {
190 let attachment = state.attachments
191 .get_by_id(id, DESKTOP_USER_ID)
192 .await?
193 .or_not_found("attachment", id)?;
194
195 let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash);
196 if !blob_path.exists() {
197 return Err(ApiError::bad_request("Blob not available locally — sync required"));
198 }
199
200 // Create a temp directory keyed by blob hash so different attachments with the same
201 // filename don't overwrite each other.
202 let hash_prefix = if attachment.blob_hash.len() >= 8 {
203 &attachment.blob_hash[..8]
204 } else {
205 &attachment.blob_hash
206 };
207 let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix);
208 std::fs::create_dir_all(&temp_dir)
209 .map_api_err("Failed to create temp dir", ApiError::internal)?;
210
211 // Sanitize filename: strip path separators, .., and control characters to prevent path traversal
212 let safe_name: String = attachment.filename
213 .replace(['/', '\\'], "_")
214 .replace("..", "_")
215 .chars()
216 .filter(|c| !c.is_control())
217 .collect();
218 let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name };
219 let temp_path = temp_dir.join(&safe_name);
220 // Copy blob to temp with original filename (overwrite if exists)
221 std::fs::copy(&blob_path, &temp_path)
222 .map_api_err("Failed to prepare file for opening", ApiError::internal)?;
223
224 open::that(&temp_path)
225 .map_api_err("Failed to open file", ApiError::internal)?;
226
227 Ok(())
228 }
229
230 /// Save an attachment to a user-chosen destination.
231 #[tauri::command]
232 #[instrument(skip_all)]
233 pub async fn save_attachment(
234 state: State<'_, Arc<AppState>>,
235 id: AttachmentId,
236 destination: String,
237 ) -> Result<(), ApiError> {
238 let attachment = state.attachments
239 .get_by_id(id, DESKTOP_USER_ID)
240 .await?
241 .or_not_found("attachment", id)?;
242
243 let blob_path = state.data_dir.join("blobs").join(&attachment.blob_hash);
244 if !blob_path.exists() {
245 return Err(ApiError::bad_request("Blob not available locally — sync required"));
246 }
247
248 std::fs::copy(&blob_path, &destination)
249 .map_api_err("Failed to save file", ApiError::internal)?;
250
251 Ok(())
252 }
253
254 /// Convert email attachments to task attachments.
255 ///
256 /// Reads the email's pre-stored `attachment_meta` JSON (populated during IMAP sync),
257 /// verifies each blob exists on disk, and creates `Attachment` records linked to the task.
258 #[tauri::command]
259 #[instrument(skip_all)]
260 pub async fn convert_email_attachments(
261 state: State<'_, Arc<AppState>>,
262 email_id: EmailId,
263 task_id: TaskId,
264 ) -> Result<Vec<AttachmentResponse>, ApiError> {
265 // Verify email and task exist
266 let email = state.emails
267 .get_by_id(email_id, DESKTOP_USER_ID)
268 .await?
269 .or_not_found("email", email_id)?;
270
271 let _task = state.tasks
272 .get_by_id(task_id, DESKTOP_USER_ID)
273 .await?
274 .or_not_found("task", task_id)?;
275
276 // Parse attachment metadata — if none, return empty
277 let meta_json = match email.attachment_meta {
278 Some(ref json) if !json.is_empty() => json,
279 _ => return Ok(Vec::new()),
280 };
281
282 let metas: Vec<AttachmentMeta> = serde_json::from_str(meta_json)
283 .map_api_err("Invalid attachment_meta JSON", ApiError::internal)?;
284
285 let mut results = Vec::new();
286 let blobs_dir = state.data_dir.join("blobs");
287
288 for meta in metas {
289 // Verify blob exists on disk
290 let blob_path = blobs_dir.join(&meta.blob_hash);
291 if !blob_path.exists() {
292 tracing::warn!(
293 blob_hash = %meta.blob_hash,
294 filename = %meta.filename,
295 "Attachment blob missing — skipping"
296 );
297 continue;
298 }
299
300 // Dedup: skip if attachment with same source_email_id + blob_hash already exists
301 let existing = state.attachments
302 .list_by_blob_hash(&meta.blob_hash, DESKTOP_USER_ID)
303 .await?;
304 if existing.iter().any(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) {
305 // Already converted — include in results without creating duplicate
306 if let Some(a) = existing.into_iter().find(|a| a.source_email_id == Some(email_id) && a.task_id == Some(task_id)) {
307 results.push(to_response(a, &state.data_dir));
308 }
309 continue;
310 }
311
312 let attachment = state.attachments
313 .create(DESKTOP_USER_ID, NewAttachment {
314 task_id: Some(task_id),
315 project_id: None,
316 filename: meta.filename,
317 file_size: meta.size as i64,
318 mime_type: meta.mime_type,
319 blob_hash: meta.blob_hash,
320 source_email_id: Some(email_id),
321 })
322 .await?;
323
324 results.push(to_response(attachment, &state.data_dir));
325 }
326
327 Ok(results)
328 }
329
330 /// Open an email attachment blob with the system default application.
331 ///
332 /// Unlike `open_attachment`, this works directly on the blob store using the hash
333 /// and filename from the email's `attachment_meta` — no Attachment record needed.
334 #[tauri::command]
335 #[instrument(skip_all)]
336 pub async fn open_email_blob(
337 state: State<'_, Arc<AppState>>,
338 blob_hash: String,
339 filename: String,
340 ) -> Result<(), ApiError> {
341 let blob_path = state.data_dir.join("blobs").join(&blob_hash);
342 if !blob_path.exists() {
343 return Err(ApiError::bad_request("Attachment not available locally — sync required"));
344 }
345
346 let hash_prefix = if blob_hash.len() >= 8 { &blob_hash[..8] } else { &blob_hash };
347 let temp_dir = std::env::temp_dir().join("goingson-attachments").join(hash_prefix);
348 std::fs::create_dir_all(&temp_dir)
349 .map_api_err("Failed to create temp dir", ApiError::internal)?;
350
351 let safe_name: String = filename
352 .replace(['/', '\\'], "_")
353 .replace("..", "_")
354 .chars()
355 .filter(|c| !c.is_control())
356 .collect();
357 let safe_name = if safe_name.is_empty() { "attachment".to_string() } else { safe_name };
358 let temp_path = temp_dir.join(&safe_name);
359
360 std::fs::copy(&blob_path, &temp_path)
361 .map_api_err("Failed to prepare file for opening", ApiError::internal)?;
362
363 open::that(&temp_path)
364 .map_api_err("Failed to open file", ApiError::internal)?;
365
366 Ok(())
367 }
368
369 /// Save an email attachment blob to a user-chosen destination.
370 #[tauri::command]
371 #[instrument(skip_all)]
372 pub async fn save_email_blob(
373 state: State<'_, Arc<AppState>>,
374 blob_hash: String,
375 destination: String,
376 ) -> Result<(), ApiError> {
377 let blob_path = state.data_dir.join("blobs").join(&blob_hash);
378 if !blob_path.exists() {
379 return Err(ApiError::bad_request("Attachment not available locally — sync required"));
380 }
381
382 std::fs::copy(&blob_path, &destination)
383 .map_api_err("Failed to save file", ApiError::internal)?;
384
385 Ok(())
386 }
387
388 // ============ Helpers ============
389
390 fn to_response(a: goingson_core::Attachment, data_dir: &Path) -> AttachmentResponse {
391 let has_local_blob = data_dir.join("blobs").join(&a.blob_hash).exists();
392 AttachmentResponse {
393 id: a.id,
394 task_id: a.task_id,
395 project_id: a.project_id,
396 filename: a.filename,
397 file_size_formatted: format_file_size(a.file_size),
398 file_size: a.file_size,
399 mime_type: a.mime_type,
400 blob_hash: a.blob_hash,
401 source_email_id: a.source_email_id,
402 created_at: a.created_at,
403 has_local_blob,
404 }
405 }
406
407 /// Get the path to a blob file by hash.
408 pub(crate) fn blob_path(data_dir: &Path, hash: &str) -> PathBuf {
409 data_dir.join("blobs").join(hash)
410 }
411