diff --git a/Cargo.lock b/Cargo.lock index 566ef8c8..6cf46145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,6 +404,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -3481,6 +3482,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -6038,6 +6056,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spinning_top" version = "0.3.0" diff --git a/Cargo.toml b/Cargo.toml index 85273859..ef903ad6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,7 +84,7 @@ url = "2" wasmtime = "41" # HTTP server (for API daemon) -axum = { version = "0.8", features = ["ws"] } +axum = { version = "0.8", features = ["ws", "multipart"] } tower = "0.5" tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "compression-br"] } diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 5a383d81..f65b8635 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -1,7 +1,7 @@ //! Route handlers for the OpenFang API. use crate::types::*; -use axum::extract::{Path, Query, State}; +use axum::extract::{Multipart, Path, Query, State}; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::Json; @@ -9319,16 +9319,14 @@ fn is_allowed_content_type(ct: &str) -> bool { /// POST /api/agents/{id}/upload — Upload a file attachment. /// -/// Accepts raw body bytes. The client must set: -/// - `Content-Type` header (e.g., `image/png`, `text/plain`, `application/pdf`) -/// - `X-Filename` header (original filename) +/// Accepts multipart/form-data. The client must include a file field with: +/// - `Content-Type` field (e.g., `image/png`, `text/plain`, `application/pdf`) +/// - `filename` attribute (original filename) pub async fn upload_file( State(state): State>, Path(id): Path, - headers: axum::http::HeaderMap, - body: axum::body::Bytes, + mut multipart: Multipart, ) -> impl IntoResponse { - // Validate agent ID format let _agent_id: AgentId = match id.parse() { Ok(id) => id, Err(_) => { @@ -9339,12 +9337,62 @@ pub async fn upload_file( } }; - // Extract content type - let content_type = headers - .get(axum::http::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); + let mut file_data: Option<(Option, String, axum::body::Bytes)> = None; + let mut filename_from_field: Option = None; + + while let Some(field) = multipart.next_field().await.transpose() { + let field = match field { + Ok(f) => f, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": format!("Failed to read field: {}", e)})), + ); + } + }; + + let field_name = field.name().unwrap_or("").to_string(); + + match field_name.as_str() { + "file" => { + let filename_attr = field.file_name().map(|s| s.to_string()); + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + let bytes = match field.bytes().await { + Ok(b) => b, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json( + serde_json::json!({"error": format!("Failed to read file: {}", e)}), + ), + ); + } + }; + file_data = Some((filename_attr, content_type, bytes)); + } + "filename" => { + filename_from_field = field.text().await.ok(); + } + _ => {} + } + } + + let (filename_attr, content_type, body) = match file_data { + Some(data) => data, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "No file provided"})), + ); + } + }; + + let filename = filename_from_field + .or(filename_attr) + .unwrap_or_else(|| "upload".to_string()); if !is_allowed_content_type(&content_type) { return ( @@ -9355,13 +9403,6 @@ pub async fn upload_file( ); } - // Extract filename from header - let filename = headers - .get("X-Filename") - .and_then(|v| v.to_str().ok()) - .unwrap_or("upload") - .to_string(); - // Validate size if body.len() > MAX_UPLOAD_SIZE { return ( diff --git a/crates/openfang-api/static/js/api.js b/crates/openfang-api/static/js/api.js index 08d6a44f..5a03b45c 100644 --- a/crates/openfang-api/static/js/api.js +++ b/crates/openfang-api/static/js/api.js @@ -297,15 +297,15 @@ var OpenFangAPI = (function() { function getToken() { return _authToken; } function upload(agentId, file) { - var hdrs = { - 'Content-Type': file.type || 'application/octet-stream', - 'X-Filename': file.name - }; + var hdrs = {}; if (_authToken) hdrs['Authorization'] = 'Bearer ' + _authToken; + var form = new FormData(); + form.append('file', file); + form.append('filename', file.name); return fetch(BASE + '/api/agents/' + agentId + '/upload', { method: 'POST', headers: hdrs, - body: file + body: form }).then(function(r) { if (!r.ok) throw new Error('Upload failed'); return r.json();