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:
@@ -854,5 +854,7 @@ axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8
|
||||
| Crate-Versionen | axum 0.7, tower-http 0.5, axum-extra 0.9 | axum 0.8, tower-http 0.6, axum-extra 0.12 | Rust edition 2024 erfordert native async fn in Traits; axum 0.7 nutzt `#[async_trait]`, was inkompatibel ist |
|
||||
| User-Verwaltung | `API_KEY_*` / `WEB_PASSWORD_*` in .env | Separate `users.toml` mit role-Feld | Flexibler: neue User ohne .env-Änderung, Rollen-System (doctor, mta, admin) |
|
||||
| faster-whisper Port | 8100 | 10300 | Tatsächlicher Port des laufenden Containers auf minerva |
|
||||
| STT-Container | `lscr.io/linuxserver/faster-whisper` (Wyoming-Protokoll) | `onerahmet/openai-whisper-asr-webservice` (HTTP-API) | Wyoming ist binäres TCP-Protokoll, ungeeignet für einfache HTTP-Clients. whisper-asr-webservice bietet OpenAPI-kompatiblen `/asr`-Endpoint mit multipart. |
|
||||
| m4a-Preprocessing | nicht erwähnt | Server remuxt m4a mit `-movflags faststart` vor Whisper-Aufruf | Bekannter Bug in whisper-asr-webservice ([Issue #97](https://github.com/ahmetoner/whisper-asr-webservice/issues/97)): m4a-Dateien >20s liefern leere Transkripte, weil Android `MediaRecorder` das `moov atom` ans Dateiende schreibt. `faststart`-Remuxing (verlustfrei, <1s) verschiebt die Metadaten an den Anfang und behebt das Problem. ffmpeg wird dafür im Axum-Container mitgeliefert. |
|
||||
| Terminologie | "Arzt/Ärzte" | "User" | Rollen-System: nicht nur Ärzte, auch MTAs und ggf. Admins |
|
||||
| Erster Upload neue case_id | "Fall unbekannt → ACK gone" | Neuer Fall anlegen, ACK "received" | Sonst würde der allererste Upload einer neuen case_id immer abgelehnt. "gone" nur für gelöschte Fälle. |
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Cases</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
||||
.case { border: 1px solid #ccc; margin: 1em 0; padding: 1em; border-radius: 4px; }
|
||||
.case h2 { margin: 0 0 0.3em 0; font-size: 1.1em; }
|
||||
.case small { color: #666; font-family: monospace; }
|
||||
.status-open { border-left: 4px solid #4a90e2; }
|
||||
.status-done { border-left: 4px solid #7ed321; }
|
||||
.recording { margin: 0.5em 0; display: flex; align-items: center; gap: 1em; }
|
||||
.recording span { font-family: monospace; font-size: 0.9em; min-width: 20em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Cases</h1>
|
||||
{% if cases.is_empty() %}
|
||||
<p>No cases found.</p>
|
||||
{% else %}
|
||||
{% for case in cases %}
|
||||
<div class="case status-{{ case.status }}">
|
||||
<h2>{{ case.user_slug }} — {{ case.case_id_short }} ({{ case.status }})</h2>
|
||||
<small>{{ case.case_id }}</small>
|
||||
{% for rec in case.recordings %}
|
||||
<div class="recording">
|
||||
<span>{{ rec.filename }}</span>
|
||||
<audio controls preload="none">
|
||||
<source src="/web/audio/{{ case.user_slug }}/{{ case.case_id }}/{{ rec.filename }}" type="audio/mp4">
|
||||
</audio>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,215 @@
|
||||
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<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-web-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".into(),
|
||||
web_password: "unused".into(),
|
||||
role: "doctor".into(),
|
||||
}],
|
||||
api_keys: HashMap::from([("test-key".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,
|
||||
})
|
||||
}
|
||||
|
||||
async fn body_to_string(response: axum::response::Response) -> String {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
String::from_utf8(bytes.to_vec()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_index_empty() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_string(response).await;
|
||||
assert!(body.contains("No cases found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_index_shows_cases() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
// Pre-create cases on disk
|
||||
let case1 = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let case2 = "660e8400-e29b-41d4-a716-446655440000";
|
||||
|
||||
let open_case = data_path.join("dr_test/open").join(case1);
|
||||
std::fs::create_dir_all(&open_case).unwrap();
|
||||
std::fs::write(
|
||||
open_case.join("2026-04-13T10-30-00Z.m4a"),
|
||||
b"fake audio 1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let done_case = data_path.join("dr_test/done").join(case2);
|
||||
std::fs::create_dir_all(&done_case).unwrap();
|
||||
std::fs::write(
|
||||
done_case.join("2026-04-12T09-00-00Z.m4a"),
|
||||
b"fake audio 2",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = body_to_string(response).await;
|
||||
|
||||
assert!(body.contains(case1));
|
||||
assert!(body.contains(case2));
|
||||
assert!(body.contains("dr_test"));
|
||||
assert!(body.contains("<audio"));
|
||||
assert!(body.contains("2026-04-13T10-30-00Z.m4a"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_serves_file() {
|
||||
let config = test_config();
|
||||
let data_path = config.data_path.clone();
|
||||
|
||||
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
||||
let case_dir = data_path.join("dr_test/open").join(case_id);
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
|
||||
let audio_bytes = b"fake audio content for test";
|
||||
let filename = "2026-04-13T10-30-00Z.m4a";
|
||||
std::fs::write(case_dir.join(filename), audio_bytes).unwrap();
|
||||
|
||||
let app = doctate_server::create_router(config);
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri(format!("/web/audio/dr_test/{case_id}/{filename}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap(),
|
||||
"audio/mp4"
|
||||
);
|
||||
|
||||
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&bytes[..], audio_bytes);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&data_path);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_path_traversal_rejected() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/audio/..%2Fetc/550e8400-e29b-41d4-a716-446655440000/foo.m4a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_invalid_case_id_rejected() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/audio/dr_test/not-a-uuid/foo.m4a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_audio_nonexistent_file_returns_404() {
|
||||
let config = test_config();
|
||||
let app = doctate_server::create_router(config);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/web/audio/dr_test/550e8400-e29b-41d4-a716-446655440000/missing.m4a")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
Reference in New Issue
Block a user