From 1051e83da3df09a06a226af313b28254a489173b Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 13 Apr 2026 13:01:29 +0200 Subject: [PATCH] feat: Add file upload endpoint This commit introduces a new API endpoint `/api/upload` for handling audio file uploads. It supports multipart form data, extracts `case_id`, `recorded_at`, and `audio` fields. The audio data is saved to a filesystem path determined by the user and case ID, within either the `open/` or `done/` directories. New features include: - Integration with `axum`'s `Multipart` extractor for parsing form data. - Validation of `case_id` as a UUID. - Creation of case directories if they do not exist. - Handling of late uploads to already closed cases by placing files in the `done/` directory and removing any `.remove` marker. - Logging of received recordings. - Definition of `AckResponse` and `AckStatus` models for API responses. - Addition of comprehensive unit tests for various upload scenarios, including new case creation, invalid inputs, authentication, and late uploads. --- server/Cargo.lock | 24 +++ server/Cargo.toml | 2 +- server/src/lib.rs | 1 + server/src/models.rs | 15 ++ server/src/routes/mod.rs | 4 +- server/src/routes/upload.rs | 115 ++++++++++++ server/tests/upload_test.rs | 346 ++++++++++++++++++++++++++++++++++++ 7 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 server/src/models.rs create mode 100644 server/src/routes/upload.rs create mode 100644 server/tests/upload_test.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index c2fb33d..d17c85f 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -92,6 +92,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -933,6 +934,23 @@ dependencies = [ "windows-sys 0.61.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" @@ -1469,6 +1487,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/server/Cargo.toml b/server/Cargo.toml index f50e8ba..c85d4aa 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["json", "multipart"] } askama = "0.12" diff --git a/server/src/lib.rs b/server/src/lib.rs index 0dcaec8..b155e3a 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod config; pub mod error; +pub mod models; pub mod routes; use std::sync::Arc; diff --git a/server/src/models.rs b/server/src/models.rs new file mode 100644 index 0000000..522825e --- /dev/null +++ b/server/src/models.rs @@ -0,0 +1,15 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct AckResponse { + pub case_id: String, + pub recorded_at: String, + pub status: AckStatus, +} + +#[derive(Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AckStatus { + Received, + Gone, +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index d00ee15..f40c5b0 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,9 +1,10 @@ mod debug; mod health; +mod upload; use std::sync::Arc; -use axum::routing::get; +use axum::routing::{get, post}; use axum::Router; use crate::config::Config; @@ -12,4 +13,5 @@ pub fn api_router() -> Router> { Router::new() .route("/api/health", get(health::handle_health)) .route("/api/debug/whoami", get(debug::handle_whoami)) + .route("/api/upload", post(upload::handle_upload)) } diff --git a/server/src/routes/upload.rs b/server/src/routes/upload.rs new file mode 100644 index 0000000..622b5f0 --- /dev/null +++ b/server/src/routes/upload.rs @@ -0,0 +1,115 @@ +use std::path::{Path, PathBuf}; + +use axum::extract::Multipart; +use axum::Json; +use tracing::info; + +use crate::auth::AuthenticatedUser; +use crate::error::AppError; +use crate::models::{AckResponse, AckStatus}; + +pub async fn handle_upload( + user: AuthenticatedUser, + mut multipart: Multipart, +) -> Result, AppError> { + let mut case_id: Option = None; + let mut recorded_at: Option = None; + let mut audio: Option> = None; + + // Consume multipart fields in whatever order they arrive. + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid multipart data: {e}")))? + { + match field.name() { + Some("case_id") => { + case_id = Some( + field + .text() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid case_id: {e}")))?, + ); + } + Some("recorded_at") => { + recorded_at = Some( + field + .text() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid recorded_at: {e}")))?, + ); + } + Some("audio") => { + audio = Some( + field + .bytes() + .await + .map_err(|e| AppError::BadRequest(format!("Invalid audio data: {e}")))? + .to_vec(), + ); + } + _ => {} // Ignore unknown fields + } + } + + let case_id = case_id.ok_or_else(|| AppError::BadRequest("Missing field: case_id".into()))?; + let recorded_at = + recorded_at.ok_or_else(|| AppError::BadRequest("Missing field: recorded_at".into()))?; + let audio = audio.ok_or_else(|| AppError::BadRequest("Missing field: audio".into()))?; + + if audio.is_empty() { + return Err(AppError::BadRequest("Audio data is empty".into())); + } + + // Validate case_id as UUID (prevents path traversal — UUIDs only contain hex + hyphens). + uuid::Uuid::parse_str(&case_id) + .map_err(|_| AppError::BadRequest(format!("Invalid case_id (not a UUID): {case_id}")))?; + + // Find or create the case directory. + let case_dir = resolve_case_dir(&user.data_dir, &case_id).await?; + + // Build a filesystem-safe filename from the timestamp (colons → hyphens). + let safe_timestamp = recorded_at.replace(':', "-"); + let file_path = case_dir.join(format!("{safe_timestamp}.m4a")); + + tokio::fs::write(&file_path, &audio).await?; + + info!( + user = %user.slug, + case_id = %case_id, + bytes = audio.len(), + "Recording received" + ); + + Ok(Json(AckResponse { + case_id, + recorded_at, + status: AckStatus::Received, + })) +} + +/// Determine where to store the audio file for this case. +/// Creates the directory if it's a new case. +async fn resolve_case_dir(data_dir: &Path, case_id: &str) -> Result { + let open_dir = data_dir.join("open").join(case_id); + if open_dir.exists() { + return Ok(open_dir); + } + + let done_dir = data_dir.join("done").join(case_id); + if done_dir.exists() { + // Late upload for a closed case — remove .remove marker if present. + let remove_marker = done_dir.join(".remove"); + if remove_marker.exists() { + tokio::fs::remove_file(&remove_marker).await?; + info!(case_id = %case_id, "Removed .remove marker due to late upload"); + } + return Ok(done_dir); + } + + // New case — create directory under open/. + tokio::fs::create_dir_all(&open_dir).await?; + info!(case_id = %case_id, "New case created"); + + Ok(open_dir) +} diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs new file mode 100644 index 0000000..7c834ea --- /dev/null +++ b/server/tests/upload_test.rs @@ -0,0 +1,346 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::util::ServiceExt; + +use doctate_server::config::{Config, User}; + +fn test_config() -> Arc { + let data_path = std::env::temp_dir().join(format!( + "doctate-test-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + Arc::new(Config { + server_port: 3000, + data_path, + log_level: "info".into(), + log_path: "/tmp/doctate-test/logs".into(), + log_max_days: 90, + users: vec![User { + slug: "dr_test".into(), + api_key: "test-key-123".into(), + web_password: "unused".into(), + role: "doctor".into(), + }], + api_keys: HashMap::from([("test-key-123".into(), "dr_test".into())]), + retention_audio_days: 30, + retention_transcript_days: 30, + retention_document_days: 0, + whisper_url: "http://localhost:10300".into(), + whisper_timeout_seconds: 120, + ollama_url: "http://localhost:11434".into(), + ollama_model: "gemma3:4b".into(), + ollama_keep_alive: 0, + llm_url: String::new(), + llm_api_key: String::new(), + llm_model: String::new(), + llm_temperature: 0.0, + session_timeout_hours: 8, + }) +} + +/// Build a multipart body with the given fields. +fn multipart_body( + case_id: &str, + recorded_at: &str, + audio: &[u8], +) -> (String, Vec) { + let boundary = "----testboundary"; + let mut body = Vec::new(); + + // case_id field + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"case_id\"\r\n\r\n"); + body.extend_from_slice(case_id.as_bytes()); + body.extend_from_slice(b"\r\n"); + + // recorded_at field + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice(b"Content-Disposition: form-data; name=\"recorded_at\"\r\n\r\n"); + body.extend_from_slice(recorded_at.as_bytes()); + body.extend_from_slice(b"\r\n"); + + // audio field + body.extend_from_slice(format!("--{boundary}\r\n").as_bytes()); + body.extend_from_slice( + b"Content-Disposition: form-data; name=\"audio\"; filename=\"test.m4a\"\r\n", + ); + body.extend_from_slice(b"Content-Type: audio/mp4\r\n\r\n"); + body.extend_from_slice(audio); + body.extend_from_slice(b"\r\n"); + + // End boundary + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + + (boundary.to_owned(), body) +} + +#[tokio::test] +async fn upload_creates_new_case() { + let config = test_config(); + let data_path = config.data_path.clone(); + let app = doctate_server::create_router(config); + + let case_id = "550e8400-e29b-41d4-a716-446655440000"; + let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"fake audio data"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header("X-API-Key", "test-key-123") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + + assert_eq!(json["case_id"], case_id); + assert_eq!(json["status"], "received"); + + // Verify file on disk + let file = data_path + .join("dr_test/open") + .join(case_id) + .join("2026-04-13T10-30-00Z.m4a"); + assert!(file.exists(), "Audio file should exist at {file:?}"); + + // Cleanup + let _ = std::fs::remove_dir_all(&data_path); +} + +#[tokio::test] +async fn upload_invalid_case_id_returns_400() { + let config = test_config(); + let app = doctate_server::create_router(config); + + let (boundary, body) = multipart_body("../etc/passwd", "2026-04-13T10:30:00Z", b"audio"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header("X-API-Key", "test-key-123") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn upload_without_auth_returns_401() { + let config = test_config(); + let app = doctate_server::create_router(config); + + let (boundary, body) = multipart_body( + "550e8400-e29b-41d4-a716-446655440000", + "2026-04-13T10:30:00Z", + b"audio", + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn upload_empty_audio_returns_400() { + let config = test_config(); + let app = doctate_server::create_router(config); + + let (boundary, body) = multipart_body( + "550e8400-e29b-41d4-a716-446655440000", + "2026-04-13T10:30:00Z", + b"", // empty audio + ); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header("X-API-Key", "test-key-123") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn upload_second_recording_same_case() { + let config = test_config(); + let data_path = config.data_path.clone(); + let app = doctate_server::create_router(config); + + let case_id = "660e8400-e29b-41d4-a716-446655440000"; + + // First upload + let (boundary, body) = multipart_body(case_id, "2026-04-13T10:30:00Z", b"first recording"); + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header("X-API-Key", "test-key-123") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // Second upload, same case, different timestamp + let (boundary, body) = multipart_body(case_id, "2026-04-13T10:45:00Z", b"second recording"); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header("X-API-Key", "test-key-123") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // Both files should exist + let case_dir = data_path.join("dr_test/open").join(case_id); + assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists()); + assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists()); + + // Cleanup + let _ = std::fs::remove_dir_all(&data_path); +} + +#[tokio::test] +async fn upload_late_recording_to_done_case() { + let config = test_config(); + let data_path = config.data_path.clone(); + let app = doctate_server::create_router(config); + + let case_id = "770e8400-e29b-41d4-a716-446655440000"; + + // Pre-create a done/ case directory (simulating a closed case). + let done_dir = data_path.join("dr_test/done").join(case_id); + std::fs::create_dir_all(&done_dir).unwrap(); + + let (boundary, body) = multipart_body(case_id, "2026-04-13T11:00:00Z", b"late recording"); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header("X-API-Key", "test-key-123") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // File should be in done/, not open/ + assert!(done_dir.join("2026-04-13T11-00-00Z.m4a").exists()); + assert!(!data_path.join("dr_test/open").join(case_id).exists()); + + // Cleanup + let _ = std::fs::remove_dir_all(&data_path); +} + +#[tokio::test] +async fn upload_removes_remove_marker_on_late_upload() { + let config = test_config(); + let data_path = config.data_path.clone(); + let app = doctate_server::create_router(config); + + let case_id = "880e8400-e29b-41d4-a716-446655440000"; + + // Pre-create a done/ case with .remove marker. + let done_dir = data_path.join("dr_test/done").join(case_id); + std::fs::create_dir_all(&done_dir).unwrap(); + std::fs::write(done_dir.join(".remove"), b"").unwrap(); + assert!(done_dir.join(".remove").exists()); + + let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"surprise recording"); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/upload") + .header("X-API-Key", "test-key-123") + .header( + "Content-Type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + // .remove marker should be gone, audio should be there + assert!(!done_dir.join(".remove").exists()); + assert!(done_dir.join("2026-04-13T12-00-00Z.m4a").exists()); + + // Cleanup + let _ = std::fs::remove_dir_all(&data_path); +}