use std::path::Path; use std::sync::Arc; use askama::Template; use axum::body::Body; use axum::extract::{Path as AxumPath, State}; use axum::http::header; use axum::response::{Html, Response}; use tracing::warn; use crate::config::Config; use crate::error::AppError; use crate::routes::user_web::any_document_exists; pub(crate) struct RecordingView { pub(crate) filename: String, pub(crate) transcript: Option, /// True if the file has been renamed to `.m4a.failed` by the worker /// after a non-recoverable ffmpeg or whisper error. pub(crate) failed: bool, } struct CaseView { user_slug: String, case_id: String, case_id_short: String, has_document: bool, most_recent: String, oneliner: Option, recordings: Vec, } #[derive(Template)] #[template(path = "cases.html")] struct CasesTemplate { cases: Vec, } pub async fn handle_case_list( State(config): State>, ) -> Result, AppError> { let cases = scan_cases(&config.data_path).await; let template = CasesTemplate { cases }; template .render() .map(Html) .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) } pub async fn handle_audio( State(config): State>, AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>, ) -> Result { validate_user_slug(&user)?; uuid::Uuid::parse_str(&case_id) .map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?; validate_filename(&filename)?; let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id); if crate::paths::is_deleted(&case_path).await { return Err(AppError::NotFound("Audio file not found".into())); } let file_path = case_path.join(&filename); if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) { return Err(AppError::NotFound("Audio file not found".into())); } let bytes = tokio::fs::read(&file_path).await?; Response::builder() .header(header::CONTENT_TYPE, "audio/mp4") .body(Body::from(bytes)) .map_err(|e| AppError::Internal(e.to_string())) } fn validate_user_slug(user: &str) -> Result<(), AppError> { if user.is_empty() || user.contains('/') || user.contains('\\') || user.contains("..") { return Err(AppError::BadRequest("Invalid user slug".into())); } Ok(()) } fn validate_filename(filename: &str) -> Result<(), AppError> { let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed"); if !ok_suffix || filename.contains('/') || filename.contains('\\') || filename.contains("..") { return Err(AppError::BadRequest("Invalid filename".into())); } Ok(()) } /// Scan the data directory for all cases across all users. /// Silently skips invalid entries so a single broken directory does not break the page. async fn scan_cases(data_path: &Path) -> Vec { let mut cases = Vec::new(); let mut users = match tokio::fs::read_dir(data_path).await { Ok(r) => r, Err(_) => return cases, // data_path may not exist yet }; while let Ok(Some(user_entry)) = users.next_entry().await { let user_slug = match user_entry.file_name().into_string() { Ok(s) => s, Err(_) => continue, }; if validate_user_slug(&user_slug).is_err() { continue; } if !user_entry.path().is_dir() { continue; } let mut case_entries = match tokio::fs::read_dir(user_entry.path()).await { Ok(r) => r, Err(_) => continue, }; while let Ok(Some(case_entry)) = case_entries.next_entry().await { let case_id = match case_entry.file_name().into_string() { Ok(s) => s, Err(_) => continue, }; if uuid::Uuid::parse_str(&case_id).is_err() { warn!(case_id = %case_id, "Skipping non-UUID directory"); continue; } if !case_entry.path().is_dir() { continue; } let case_path = case_entry.path(); if crate::paths::is_deleted(&case_path).await { continue; } let recordings = scan_recordings(&case_path).await; if recordings.is_empty() { continue; } let most_recent = recordings .last() .map(|r| r.filename.clone()) .unwrap_or_default(); let case_id_short = case_id.chars().take(8).collect(); let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt")) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let has_document = any_document_exists(&case_path).await; cases.push(CaseView { user_slug: user_slug.clone(), case_id, case_id_short, has_document, most_recent, oneliner, recordings, }); } } // Most recent case first. cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent)); cases } pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec { let mut recordings = Vec::new(); let mut entries = match tokio::fs::read_dir(case_dir).await { Ok(r) => r, Err(_) => return recordings, }; while let Ok(Some(entry)) = entries.next_entry().await { let filename = match entry.file_name().into_string() { Ok(s) => s, Err(_) => continue, }; let failed = filename.ends_with(".m4a.failed"); let is_audio = filename.ends_with(".m4a") || failed; if !is_audio { continue; } // Transcript naming uses the original `.m4a` stem in both cases // (failed recordings never produced one, but the lookup still works). let stem_path = case_dir.join(filename.trim_end_matches(".failed")); let transcript_path = stem_path.with_extension("transcript.txt"); let transcript = tokio::fs::read_to_string(&transcript_path).await.ok(); recordings.push(RecordingView { filename, transcript, failed, }); } recordings.sort_by(|a, b| a.filename.cmp(&b.filename)); recordings }