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.
This commit is contained in:
2026-04-13 13:01:29 +02:00
parent 08c6e12a74
commit 1051e83da3
7 changed files with 505 additions and 2 deletions
+1
View File
@@ -1,6 +1,7 @@
pub mod auth;
pub mod config;
pub mod error;
pub mod models;
pub mod routes;
use std::sync::Arc;
+15
View File
@@ -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,
}
+3 -1
View File
@@ -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<Arc<Config>> {
Router::new()
.route("/api/health", get(health::handle_health))
.route("/api/debug/whoami", get(debug::handle_whoami))
.route("/api/upload", post(upload::handle_upload))
}
+115
View File
@@ -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<Json<AckResponse>, AppError> {
let mut case_id: Option<String> = None;
let mut recorded_at: Option<String> = None;
let mut audio: Option<Vec<u8>> = 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<PathBuf, AppError> {
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)
}