Feat: Add web interface for case listing and audio playback

Introduces a new web interface to list cases and play back audio
recordings. This includes:

- A new `web` module in `routes` to handle web requests.
- `handle_case_list` to scan and display available cases and their
  recordings.
- `handle_audio` to serve audio files, with validation to prevent path
  traversal and ensure correct file types.
- `AppError::NotFound` variant to handle missing resources gracefully.
- Updates to `projektplan.md` to document the change in STT container
  and m4a preprocessing.
- New tests in `server/tests/web_test.rs` to ensure the web interface
  functions correctly.
This commit is contained in:
2026-04-13 16:24:26 +02:00
parent ef6efec9f0
commit fbf8681df0
6 changed files with 443 additions and 0 deletions
+2
View File
@@ -5,6 +5,7 @@ use serde_json::json;
pub enum AppError {
Unauthorized,
BadRequest(String),
NotFound(String),
Internal(String),
}
@@ -13,6 +14,7 @@ impl IntoResponse for AppError {
let (status, message) = match &self {
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()),
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
Self::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
};
+6
View File
@@ -1,6 +1,7 @@
mod debug;
mod health;
mod upload;
mod web;
use std::sync::Arc;
@@ -14,4 +15,9 @@ pub fn api_router() -> Router<Arc<Config>> {
.route("/api/health", get(health::handle_health))
.route("/api/debug/whoami", get(debug::handle_whoami))
.route("/api/upload", post(upload::handle_upload))
.route("/web/", get(web::handle_case_list))
.route(
"/web/audio/{user}/{case_id}/{filename}",
get(web::handle_audio),
)
}
+180
View File
@@ -0,0 +1,180 @@
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;
struct RecordingView {
filename: String,
}
struct CaseView {
user_slug: String,
case_id: String,
case_id_short: String,
status: String,
most_recent: String,
recordings: Vec<RecordingView>,
}
#[derive(Template)]
#[template(path = "cases.html")]
struct CasesTemplate {
cases: Vec<CaseView>,
}
pub async fn handle_case_list(
State(config): State<Arc<Config>>,
) -> Result<Html<String>, 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<Arc<Config>>,
AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>,
) -> Result<Response, AppError> {
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 base = &config.data_path;
let open_path = base.join(&user).join("open").join(&case_id).join(&filename);
let done_path = base.join(&user).join("done").join(&case_id).join(&filename);
let file_path = if tokio::fs::try_exists(&open_path).await.unwrap_or(false) {
open_path
} else if tokio::fs::try_exists(&done_path).await.unwrap_or(false) {
done_path
} else {
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> {
if !filename.ends_with(".m4a")
|| 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<CaseView> {
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;
}
for status in ["open", "done"] {
let status_dir = user_entry.path().join(status);
let mut case_entries = match tokio::fs::read_dir(&status_dir).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 recordings = scan_recordings(&case_entry.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();
cases.push(CaseView {
user_slug: user_slug.clone(),
case_id,
case_id_short,
status: status.to_string(),
most_recent,
recordings,
});
}
}
}
// Most recent case first.
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
cases
}
async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
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,
};
if filename.ends_with(".m4a") {
recordings.push(RecordingView { filename });
}
}
recordings.sort_by(|a, b| a.filename.cmp(&b.filename));
recordings
}