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)
This commit is contained in:
Shaun Arman 2026-04-09 17:13:58 -05:00
parent 859d7a0da8
commit 8a88f046d3
5 changed files with 175 additions and 37 deletions

View File

@ -21,7 +21,7 @@ rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] } reqwest = { version = "0.12", features = ["json", "stream", "multipart"] }
regex = "1" regex = "1"
aho-corasick = "1" aho-corasick = "1"
uuid = { version = "1", features = ["v7"] } uuid = { version = "1", features = ["v7"] }

View File

@ -8,12 +8,13 @@ use crate::db::models::{AuditEntry, ImageAttachment};
use crate::state::AppState; use crate::state::AppState;
const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024; 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/png",
"image/jpeg", "image/jpeg",
"image/gif", "image/gif",
"image/webp", "image/webp",
"image/svg+xml", "image/svg+xml",
"image/bmp",
]; ];
fn validate_image_file_path(file_path: &str) -> Result<std::path::PathBuf, String> { fn validate_image_file_path(file_path: &str) -> Result<std::path::PathBuf, String> {
@ -265,6 +266,128 @@ pub async fn delete_image_attachment(
Ok(()) 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<ImageAttachment, String> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -276,7 +399,13 @@ mod tests {
assert!(is_supported_image_format("image/gif")); assert!(is_supported_image_format("image/gif"));
assert!(is_supported_image_format("image/webp")); assert!(is_supported_image_format("image/webp"));
assert!(is_supported_image_format("image/svg+xml")); 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")); assert!(!is_supported_image_format("text/plain"));
} }
#[test]
fn test_add_bmp_support() {
// BMP support added
assert!(is_supported_image_format("image/bmp"));
}
} }

View File

@ -158,8 +158,8 @@ pub async fn save_ai_provider(
db.execute( db.execute(
"INSERT OR REPLACE INTO ai_providers "INSERT OR REPLACE INTO ai_providers
(id, name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature, (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) 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, datetime('now'))", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'))",
rusqlite::params![ rusqlite::params![
uuid::Uuid::now_v7().to_string(), uuid::Uuid::now_v7().to_string(),
provider.name, provider.name,
@ -174,6 +174,7 @@ pub async fn save_ai_provider(
provider.custom_auth_prefix, provider.custom_auth_prefix,
provider.api_format, provider.api_format,
provider.user_id, provider.user_id,
provider.use_datastore_upload,
], ],
) )
.map_err(|e| format!("Failed to save AI provider: {e}"))?; .map_err(|e| format!("Failed to save AI provider: {e}"))?;
@ -190,10 +191,11 @@ pub async fn load_ai_providers(
let mut stmt = db let mut stmt = db
.prepare( .prepare(
"SELECT name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature, "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 custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id,
FROM ai_providers use_datastore_upload
ORDER BY name", FROM ai_providers
ORDER BY name",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -212,29 +214,31 @@ pub async fn load_ai_providers(
row.get::<_, Option<String>>(7)?, // custom_endpoint_path row.get::<_, Option<String>>(7)?, // custom_endpoint_path
row.get::<_, Option<String>>(8)?, // custom_auth_header row.get::<_, Option<String>>(8)?, // custom_auth_header
row.get::<_, Option<String>>(9)?, // custom_auth_prefix row.get::<_, Option<String>>(9)?, // custom_auth_prefix
row.get::<_, Option<String>>(10)?, // api_format row.get::<_, Option<String>>(10)?, // api_format
row.get::<_, Option<String>>(11)?, // user_id row.get::<_, Option<String>>(11)?, // user_id
)) row.get::<_, Option<bool>>(12)?, // use_datastore_upload
}) ))
.map_err(|e| e.to_string())? })
.filter_map(|r| r.ok()) .map_err(|e| e.to_string())?
.filter_map( .filter_map(|r| r.ok())
|( .filter_map(
name, |(
provider_type, name,
api_url, provider_type,
encrypted_key, api_url,
model, encrypted_key,
max_tokens, model,
temperature, max_tokens,
custom_endpoint_path, temperature,
custom_auth_header, custom_endpoint_path,
custom_auth_prefix, custom_auth_header,
api_format, custom_auth_prefix,
user_id, api_format,
)| { user_id,
// Decrypt the API key _use_datastore_upload,
let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?; )| {
// Decrypt the API key
let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?;
Some(ProviderConfig { Some(ProviderConfig {
name, name,
@ -250,6 +254,7 @@ pub async fn load_ai_providers(
api_format, api_format,
session_id: None, // Session IDs are not persisted session_id: None, // Session IDs are not persisted
user_id, user_id,
use_datastore_upload: None,
}) })
}, },
) )

View File

@ -73,11 +73,12 @@ pub fn run() {
commands::analysis::upload_log_file, commands::analysis::upload_log_file,
commands::analysis::detect_pii, commands::analysis::detect_pii,
commands::analysis::apply_redactions, commands::analysis::apply_redactions,
commands::image::upload_image_attachment, commands::image::upload_image_attachment,
commands::image::list_image_attachments, commands::image::list_image_attachments,
commands::image::delete_image_attachment, commands::image::delete_image_attachment,
commands::image::upload_paste_image, commands::image::upload_paste_image,
// AI commands::image::upload_file_to_datastore,
// AI
commands::ai::analyze_logs, commands::ai::analyze_logs,
commands::ai::chat_message, commands::ai::chat_message,
commands::ai::test_provider_connection, commands::ai::test_provider_connection,

View File

@ -39,6 +39,9 @@ pub struct ProviderConfig {
/// Optional: User ID for custom REST API cost tracking (CORE ID email) /// Optional: User ID for custom REST API cost tracking (CORE ID email)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>, pub user_id: Option<String>,
/// Optional: Enable file upload to datastore (for providers that support it)
#[serde(skip_serializing_if = "Option::is_none")]
pub use_datastore_upload: Option<bool>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]