diff --git a/docs/projektplan.md b/docs/projektplan.md index e803e07..47a9111 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -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. | diff --git a/server/src/error.rs b/server/src/error.rs index 2d622a3..dea4a66 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -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()), }; diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index f40c5b0..4ce03ff 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -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> { .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), + ) } diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs new file mode 100644 index 0000000..33de349 --- /dev/null +++ b/server/src/routes/web.rs @@ -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, +} + +#[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 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 { + 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 { + 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 +} diff --git a/server/templates/cases.html b/server/templates/cases.html new file mode 100644 index 0000000..1fd092f --- /dev/null +++ b/server/templates/cases.html @@ -0,0 +1,38 @@ + + + + +Doctate — Cases + + + +

Cases

+{% if cases.is_empty() %} +

No cases found.

+{% else %} +{% for case in cases %} +
+

{{ case.user_slug }} — {{ case.case_id_short }} ({{ case.status }})

+{{ case.case_id }} +{% for rec in case.recordings %} +
+{{ rec.filename }} + +
+{% endfor %} +
+{% endfor %} +{% endif %} + + diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs new file mode 100644 index 0000000..6fe7027 --- /dev/null +++ b/server/tests/web_test.rs @@ -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 { + 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("