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:
parent
859d7a0da8
commit
8a88f046d3
@ -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"] }
|
||||
|
||||
@ -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<std::path::PathBuf, String> {
|
||||
@ -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<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)]
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}"))?;
|
||||
@ -191,7 +192,8 @@ 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
|
||||
custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id,
|
||||
use_datastore_upload
|
||||
FROM ai_providers
|
||||
ORDER BY name",
|
||||
)
|
||||
@ -214,6 +216,7 @@ pub async fn load_ai_providers(
|
||||
row.get::<_, Option<String>>(9)?, // custom_auth_prefix
|
||||
row.get::<_, Option<String>>(10)?, // api_format
|
||||
row.get::<_, Option<String>>(11)?, // user_id
|
||||
row.get::<_, Option<bool>>(12)?, // use_datastore_upload
|
||||
))
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
@ -232,6 +235,7 @@ pub async fn load_ai_providers(
|
||||
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()?;
|
||||
@ -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,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@ -77,6 +77,7 @@ pub fn run() {
|
||||
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,
|
||||
|
||||
@ -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<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)]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user