From 8a88f046d3b20fbbd55d80dc32f6f9311598e8ea Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Thu, 9 Apr 2026 17:13:58 -0500 Subject: [PATCH] feat(image): add BMP support and upload_file_to_datastore command - Add image/bmp to supported MIME types - New upload_file_to_datastore command for GenAI datastore file uploads - Uses multipart/form-data with session ID authentication - Returns ImageAttachment for consistency - Updated test to expect BMP support (was negative test) --- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/image.rs | 133 ++++++++++++++++++++++++++++++- src-tauri/src/commands/system.rs | 63 ++++++++------- src-tauri/src/lib.rs | 11 +-- src-tauri/src/state.rs | 3 + 5 files changed, 175 insertions(+), 37 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 38120a56..6827b82a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", features = ["json", "stream"] } +reqwest = { version = "0.12", features = ["json", "stream", "multipart"] } regex = "1" aho-corasick = "1" uuid = { version = "1", features = ["v7"] } diff --git a/src-tauri/src/commands/image.rs b/src-tauri/src/commands/image.rs index b2675315..3345117f 100644 --- a/src-tauri/src/commands/image.rs +++ b/src-tauri/src/commands/image.rs @@ -8,12 +8,13 @@ use crate::db::models::{AuditEntry, ImageAttachment}; use crate::state::AppState; const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024; -const SUPPORTED_IMAGE_MIME_TYPES: [&str; 5] = [ +const SUPPORTED_IMAGE_MIME_TYPES: [&str; 6] = [ "image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml", + "image/bmp", ]; fn validate_image_file_path(file_path: &str) -> Result { @@ -265,6 +266,128 @@ pub async fn delete_image_attachment( Ok(()) } +#[tauri::command] +pub async fn upload_file_to_datastore( + issue_id: String, + file_path: String, + session_id: String, + api_url: String, + api_key: String, + state: State<'_, AppState>, +) -> Result { + use reqwest::Client; + + let canonical_path = std::path::Path::new(&file_path); + let canonical = std::fs::canonicalize(canonical_path) + .map_err(|_| "Unable to access file")?; + + let file_name = canonical + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string(); + + let content = std::fs::read(&canonical).map_err(|_| "Failed to read file")?; + let file_size = content.len() as i64; + + // Upload hash before consuming content + let _upload_hash = format!("{:x}", sha2::Sha256::digest(&content)); + + // Clone for upload (reqwest multipart consumes content) + let content_clone = content.clone(); + + // Build the upload URL + let api_url = api_url.trim_end_matches('/'); + let upload_url = format!("{}/upload/{}", api_url, session_id); + + // Upload file using multipart/form-data + let client = Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let resp = client + .post(&upload_url) + .header("x-msi-genai-api-key", api_key) + .multipart(reqwest::multipart::Form::new().part("file", reqwest::multipart::Part::bytes(content_clone).file_name(file_name.clone()))) + .send() + .await + .map_err(|e| format!("Failed to upload file: {}", e))?; + + if !resp.status().is_success() { + let status = resp.status(); + let text = resp.text().await.unwrap_or_else(|_| "unable to read response".to_string()); + return Err(format!("File upload failed: {} - {}", status, text)); + } + + let response_json: serde_json::Value = resp.json().await + .map_err(|e| format!("Failed to parse upload response: {}", e))?; + + if !response_json["status"].as_bool().unwrap_or(false) { + let msg = response_json["msg"].as_str().unwrap_or("Unknown error"); + return Err(format!("Upload failed: {}", msg)); + } + + // Create ImageAttachment record + let upload_hash = format!("{:x}", sha2::Sha256::digest(&content)); + let mime_type = infer::get(&content) + .map(|m| m.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + let attachment = ImageAttachment::new( + issue_id.clone(), + file_name, + canonical.to_string_lossy().to_string(), + file_size, + mime_type, + upload_hash, + true, + false, + ); + + let db = state.db.lock().map_err(|e| e.to_string())?; + db.execute( + "INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + attachment.id, + attachment.issue_id, + attachment.file_name, + attachment.file_path, + attachment.file_size, + attachment.mime_type, + attachment.upload_hash, + attachment.uploaded_at, + attachment.pii_warning_acknowledged as i32, + attachment.is_paste as i32, + ], + ) + .map_err(|_| "Failed to store file metadata".to_string())?; + + let entry = AuditEntry::new( + "upload_file_to_datastore".to_string(), + "image_attachment".to_string(), + attachment.id.clone(), + serde_json::json!({ + "issue_id": issue_id, + "file_name": attachment.file_name, + "session_id": session_id, + }) + .to_string(), + ); + if let Err(err) = write_audit_event( + &db, + &entry.action, + &entry.entity_type, + &entry.entity_id, + &entry.details, + ) { + tracing::warn!(error = %err, "failed to write upload_file_to_datastore audit entry"); + } + + Ok(attachment) +} + #[cfg(test)] mod tests { use super::*; @@ -276,7 +399,13 @@ mod tests { assert!(is_supported_image_format("image/gif")); assert!(is_supported_image_format("image/webp")); assert!(is_supported_image_format("image/svg+xml")); - assert!(!is_supported_image_format("image/bmp")); + assert!(is_supported_image_format("image/bmp")); assert!(!is_supported_image_format("text/plain")); } + + #[test] + fn test_add_bmp_support() { + // BMP support added + assert!(is_supported_image_format("image/bmp")); + } } diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 40cd21e5..5099ee7d 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -158,8 +158,8 @@ pub async fn save_ai_provider( db.execute( "INSERT OR REPLACE INTO ai_providers (id, name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature, - custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now'))", + custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id, use_datastore_upload, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'))", rusqlite::params![ uuid::Uuid::now_v7().to_string(), provider.name, @@ -174,6 +174,7 @@ pub async fn save_ai_provider( provider.custom_auth_prefix, provider.api_format, provider.user_id, + provider.use_datastore_upload, ], ) .map_err(|e| format!("Failed to save AI provider: {e}"))?; @@ -190,10 +191,11 @@ pub async fn load_ai_providers( let mut stmt = db .prepare( - "SELECT name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature, - custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id - FROM ai_providers - ORDER BY name", + "SELECT name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature, + custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id, + use_datastore_upload + FROM ai_providers + ORDER BY name", ) .map_err(|e| e.to_string())?; @@ -212,29 +214,31 @@ pub async fn load_ai_providers( row.get::<_, Option>(7)?, // custom_endpoint_path row.get::<_, Option>(8)?, // custom_auth_header row.get::<_, Option>(9)?, // custom_auth_prefix - row.get::<_, Option>(10)?, // api_format - row.get::<_, Option>(11)?, // user_id - )) - }) - .map_err(|e| e.to_string())? - .filter_map(|r| r.ok()) - .filter_map( - |( - name, - provider_type, - api_url, - encrypted_key, - model, - max_tokens, - temperature, - custom_endpoint_path, - custom_auth_header, - custom_auth_prefix, - api_format, - user_id, - )| { - // Decrypt the API key - let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?; + row.get::<_, Option>(10)?, // api_format + row.get::<_, Option>(11)?, // user_id + row.get::<_, Option>(12)?, // use_datastore_upload + )) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .filter_map( + |( + name, + provider_type, + api_url, + encrypted_key, + model, + max_tokens, + temperature, + custom_endpoint_path, + custom_auth_header, + custom_auth_prefix, + api_format, + user_id, + _use_datastore_upload, + )| { + // Decrypt the API key + let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?; Some(ProviderConfig { name, @@ -250,6 +254,7 @@ pub async fn load_ai_providers( api_format, session_id: None, // Session IDs are not persisted user_id, + use_datastore_upload: None, }) }, ) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d6471dd..c9ce6c5c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,11 +73,12 @@ pub fn run() { commands::analysis::upload_log_file, commands::analysis::detect_pii, commands::analysis::apply_redactions, - commands::image::upload_image_attachment, - commands::image::list_image_attachments, - commands::image::delete_image_attachment, - commands::image::upload_paste_image, - // AI + commands::image::upload_image_attachment, + commands::image::list_image_attachments, + commands::image::delete_image_attachment, + commands::image::upload_paste_image, + commands::image::upload_file_to_datastore, + // AI commands::ai::analyze_logs, commands::ai::chat_message, commands::ai::test_provider_connection, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 78d37a10..f609ebd8 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -39,6 +39,9 @@ pub struct ProviderConfig { /// Optional: User ID for custom REST API cost tracking (CORE ID email) #[serde(skip_serializing_if = "Option::is_none")] pub user_id: Option, + /// Optional: Enable file upload to datastore (for providers that support it) + #[serde(skip_serializing_if = "Option::is_none")] + pub use_datastore_upload: Option, } #[derive(Debug, Clone, Serialize, Deserialize)]