From d489716c9b6089907dde831739ce9220ad44c003 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 14 Apr 2026 15:09:23 +0200 Subject: [PATCH] Feat: Implement basic web authentication and UI This commit introduces the foundation for web-based authentication and user interface. It includes: - **Session Management**: Securely handling user sessions using cryptographically generated tokens, cookies with appropriate security flags, and server-side storage. - **Login/Logout Functionality**: Endpoints for users to log in with their credentials and log out, clearing their session. - **User Interface**: Basic templates for the login page, a list of cases, and a detailed view of a single case, allowing users to navigate and view their data. - **Authorization**: An `AuthenticatedWebUser` extractor to ensure only logged-in users can access protected web routes. - **Configuration**: Added a `cookie_secure` option to the configuration to control the `Secure` flag on session cookies, useful for local development. - **Dependencies**: Added necessary dependencies for password hashing, time handling, and TOML file manipulation. - **Helper Binary**: Included a `hash-password` binary to simplify generating bcrypt hashes for user passwords. --- docs/projektplan.md | 12 +- server/Cargo.lock | 87 ++++++++++- server/Cargo.toml | 3 + server/src/auth.rs | 72 +++++++++ server/src/bin/hash-password.rs | 16 ++ server/src/config.rs | 5 + server/src/error.rs | 11 ++ server/src/lib.rs | 10 ++ server/src/main.rs | 1 + server/src/routes/login.rs | 121 +++++++++++++++ server/src/routes/mod.rs | 8 +- server/src/routes/user_web.rs | 173 ++++++++++++++++++++++ server/src/routes/web.rs | 10 +- server/src/web_session.rs | 17 +++ server/templates/case_detail.html | 61 ++++++++ server/templates/login.html | 28 ++++ server/templates/my_cases.html | 66 +++++++++ server/tests/auth_test.rs | 1 + server/tests/health_test.rs | 1 + server/tests/login_test.rs | 235 ++++++++++++++++++++++++++++++ server/tests/transcribe_test.rs | 1 + server/tests/upload_test.rs | 1 + server/tests/web_test.rs | 1 + server/users.toml.example | 5 + 24 files changed, 930 insertions(+), 16 deletions(-) create mode 100644 server/src/bin/hash-password.rs create mode 100644 server/src/routes/login.rs create mode 100644 server/src/routes/user_web.rs create mode 100644 server/src/web_session.rs create mode 100644 server/templates/case_detail.html create mode 100644 server/templates/login.html create mode 100644 server/templates/my_cases.html create mode 100644 server/tests/login_test.rs diff --git a/docs/projektplan.md b/docs/projektplan.md index de7d070..4b955f7 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -769,15 +769,15 @@ axum-extra = { version = "0.12", features = ["cookie"] } # passend zu axum 0.8 - [ ] Upload für gelöschte Fälle (ACK mit status "gone") ### Phase 4 — Webinterface -- [ ] Session-Management: kryptographisches Token (256-Bit, `OsRng`), Cookie (`Secure`, `HttpOnly`, `SameSite=Strict`), serverseitiger `RwLock>`, Ablauf nach 8 h -- [ ] `AuthenticatedArzt`-Extractor: Session-Token → Arzt auflösen, bei ungültiger/abgelaufener Session → Redirect `/web/login` +- [x] Session-Management: kryptographisches Token (256-Bit über 43 Alphanumeric-Zeichen, `OsRng`), Cookie (`HttpOnly`, `SameSite=Strict`, `Secure` via `COOKIE_SECURE`-ENV schaltbar für Dev), serverseitiger `RwLock>`, Ablauf nach 8 h +- [x] `AuthenticatedWebUser`-Extractor: Session-Token → User auflösen, bei ungültiger/abgelaufener Session → Redirect `/web/login` - [ ] `ValidCaseId`-Extractor: `case_id` als UUIDv4 validieren (`uuid::Uuid::parse_str`), bei Fehler → 400 -- [ ] Login-Seite (`GET /web/login`, `POST /web/login`) -- [ ] askama Templates (Arzt-UI) +- [x] Login-Seite (`GET /web/login`, `POST /web/login`), Logout (`POST /web/logout`) +- [x] askama Templates: `login.html`, `my_cases.html`, `case_detail.html` - [ ] SSE-Endpunkt (`GET /web/events`), Session-Check bei Aufbau + periodisch bei Heartbeat - [ ] Vanilla-JS EventSource-Client -- [ ] Übersicht mit drei States (Empfangen/Transkribiert/Ausgewertet) -- [ ] Fall-Detail: Transkripte pro Aufnahme einsehen +- [ ] Übersicht mit drei States (Empfangen/Transkribiert/Ausgewertet) — aktuell: zwei Sektionen (Offen/Abgeschlossen) via FS-Layout, States kommen mit Phase 3 +- [x] Fall-Detail: Transkripte pro Aufnahme einsehen (`GET /web/cases/{case_id}`, read-only, IDOR-geschützt via Session-Slug) - [ ] Fall abschließen / Alle abschließen - [ ] Dokumentansicht - [ ] Nachtrag-Hinweis bei Aufnahmen nach Abschluss (⚠ "N neue Aufnahmen seit Abschluss") diff --git a/server/Cargo.lock b/server/Cargo.lock index 99a9e6b..0f73593 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -337,11 +337,14 @@ dependencies = [ "dotenvy", "rand 0.8.5", "reqwest", + "rpassword", "serde", "serde_json", "tempfile", + "time", "tokio", "toml", + "toml_edit 0.25.11+spec-1.1.0", "tower", "tower-http", "tracing", @@ -1361,6 +1364,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327b72899159dfae8060c51a1f6aebe955245bcd9cc4997eed0f623caea022e4" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -1767,8 +1791,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -1780,6 +1804,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -1789,9 +1822,31 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", ] [[package]] @@ -1800,6 +1855,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.3" @@ -2165,6 +2226,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2247,6 +2317,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + [[package]] name = "wiremock" version = "0.6.5" diff --git a/server/Cargo.toml b/server/Cargo.toml index 553a3ea..8e99593 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -21,6 +21,9 @@ tower-http = { version = "0.6", features = ["limit", "trace"] } rand = "0.8" bcrypt = "0.15" axum-extra = { version = "0.12", features = ["cookie"] } +time = "0.3.47" +toml_edit = "0.25.11" +rpassword = "7.4.0" [dev-dependencies] tower = { version = "0.5", features = ["util"] } diff --git a/server/src/auth.rs b/server/src/auth.rs index ee6e3fd..26fbb82 100644 --- a/server/src/auth.rs +++ b/server/src/auth.rs @@ -1,11 +1,22 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; use axum::extract::{FromRef, FromRequestParts}; use axum::http::request::Parts; +use axum_extra::extract::cookie::CookieJar; +use tracing::warn; use crate::config::Config; use crate::error::AppError; +use crate::web_session::SessionStore; + +/// Truncate a session token to a non-sensitive prefix for logs. +fn token_prefix(token: &str) -> String { + token.chars().take(8).collect() +} + +const SESSION_COOKIE: &str = "session"; /// Extracted from the X-API-Key header on every authenticated request. pub struct AuthenticatedUser { @@ -52,3 +63,64 @@ where }) } } + +/// Extracted from the session cookie on web UI requests. +/// On missing/expired session, returns `AppError::Redirect("/web/login")`. +pub struct AuthenticatedWebUser { + pub slug: String, + pub role: String, + pub data_dir: PathBuf, +} + +impl FromRequestParts for AuthenticatedWebUser +where + S: Send + Sync, + Arc: FromRef, + SessionStore: FromRef, +{ + type Rejection = AppError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let config = Arc::::from_ref(state); + let store = SessionStore::from_ref(state); + + let jar = CookieJar::from_headers(&parts.headers); + let token = jar + .get(SESSION_COOKIE) + .map(|c| c.value().to_owned()) + .ok_or_else(|| AppError::Redirect("/web/login".into()))?; + + // Read lock first to check validity. If expired, upgrade to write and remove. + let expired = { + let r = store.read().await; + match r.get(&token) { + Some(s) => s.expires_at <= Instant::now(), + None => { + warn!(token_prefix = %token_prefix(&token), "session lookup failed (unknown or already expired)"); + return Err(AppError::Redirect("/web/login".into())); + } + } + }; + + if expired { + let mut w = store.write().await; + let removed = w.remove(&token); + if let Some(s) = removed { + warn!(slug = %s.slug, "session expired"); + } + return Err(AppError::Redirect("/web/login".into())); + } + + let r = store.read().await; + let session = r + .get(&token) + .ok_or_else(|| AppError::Redirect("/web/login".into()))?; + + let data_dir = config.data_path.join(&session.slug); + Ok(Self { + slug: session.slug.clone(), + role: session.role.clone(), + data_dir, + }) + } +} diff --git a/server/src/bin/hash-password.rs b/server/src/bin/hash-password.rs new file mode 100644 index 0000000..87b855a --- /dev/null +++ b/server/src/bin/hash-password.rs @@ -0,0 +1,16 @@ +//! Generate a bcrypt hash for a plaintext password. +//! Use when adding or rotating a `web_password` entry in `users.toml`. +//! +//! Dev: `cargo run --quiet --bin hash-password -- 'my-password'` +//! Release: `./target/release/hash-password 'my-password'` + +fn main() { + let password = std::env::args().nth(1).unwrap_or_else(|| { + eprintln!("Usage: hash-password ''"); + std::process::exit(2); + }); + + let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST) + .expect("bcrypt hashing failed"); + println!("{hash}"); +} diff --git a/server/src/config.rs b/server/src/config.rs index 3e43b1d..b91346b 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -53,6 +53,10 @@ pub struct Config { pub llm_model: String, pub llm_temperature: f32, pub session_timeout_hours: u32, + /// Whether to set the `Secure` flag on the session cookie. Default `true`. + /// Set `COOKIE_SECURE=false` only for plain-HTTP local development — + /// browsers refuse `Secure` cookies on non-HTTPS origins. + pub cookie_secure: bool, } impl Config { @@ -82,6 +86,7 @@ impl Config { llm_model: optional_env("LLM_MODEL", ""), llm_temperature: optional_env_parsed("LLM_TEMPERATURE", 0.0), session_timeout_hours: optional_env_parsed("SESSION_TIMEOUT_HOURS", 8), + cookie_secure: optional_env_parsed("COOKIE_SECURE", true), } } } diff --git a/server/src/error.rs b/server/src/error.rs index dea4a66..d4f498d 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -7,15 +7,26 @@ pub enum AppError { BadRequest(String), NotFound(String), Internal(String), + /// 302 redirect. Used by web auth to send unauthenticated users to login. + Redirect(String), } impl IntoResponse for AppError { fn into_response(self) -> Response { + if let Self::Redirect(url) = &self { + return Response::builder() + .status(StatusCode::FOUND) + .header(axum::http::header::LOCATION, url) + .body(axum::body::Body::empty()) + .unwrap(); + } + 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()), + Self::Redirect(_) => unreachable!(), }; let body = axum::Json(json!({ "error": message })); diff --git a/server/src/lib.rs b/server/src/lib.rs index 32d7a0a..8798df5 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod models; pub mod routes; pub mod transcribe; +pub mod web_session; use std::sync::Arc; @@ -12,6 +13,7 @@ use axum::Router; use config::Config; use transcribe::TranscribeSender; +use web_session::SessionStore; /// Shared application state. Clonable — all fields are cheap to clone /// (`Arc` and `mpsc::Sender`). @@ -19,6 +21,7 @@ use transcribe::TranscribeSender; pub struct AppState { pub config: Arc, pub transcribe_tx: TranscribeSender, + pub session_store: SessionStore, } impl FromRef for Arc { @@ -33,6 +36,12 @@ impl FromRef for TranscribeSender { } } +impl FromRef for SessionStore { + fn from_ref(state: &AppState) -> Self { + state.session_store.clone() + } +} + /// Test/simple entrypoint: jobs pushed into the transcribe channel are dropped /// because the receiver is not retained. Use [`create_router_with_state`] from /// `main.rs` where a real worker owns the receiver. @@ -41,6 +50,7 @@ pub fn create_router(config: Arc) -> Router { create_router_with_state(AppState { config, transcribe_tx: tx, + session_store: web_session::new_store(), }) } diff --git a/server/src/main.rs b/server/src/main.rs index 5708e7a..057e11d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -62,6 +62,7 @@ async fn main() { let state = AppState { config: config.clone(), transcribe_tx, + session_store: doctate_server::web_session::new_store(), }; let addr = format!("0.0.0.0:{}", config.server_port); diff --git a/server/src/routes/login.rs b/server/src/routes/login.rs new file mode 100644 index 0000000..0c76b53 --- /dev/null +++ b/server/src/routes/login.rs @@ -0,0 +1,121 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use askama::Template; +use axum::extract::State; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::Form; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use rand::distributions::Alphanumeric; +use rand::Rng; +use serde::Deserialize; +use tracing::{info, warn}; + +use crate::config::Config; +use crate::error::AppError; +use crate::web_session::{SessionStore, WebSession}; + +const COOKIE_NAME: &str = "session"; +const TOKEN_LEN: usize = 43; + +#[derive(Template)] +#[template(path = "login.html")] +struct LoginTemplate { + error: Option<&'static str>, +} + +#[derive(Deserialize)] +pub struct LoginForm { + slug: String, + password: String, +} + +pub async fn handle_login_page() -> Result, AppError> { + render_login(None) +} + +pub async fn handle_login_submit( + State(config): State>, + State(store): State, + jar: CookieJar, + Form(form): Form, +) -> Result { + let user = config.users.iter().find(|u| u.slug == form.slug); + let valid = user + .map(|u| bcrypt::verify(&form.password, &u.web_password).unwrap_or(false)) + .unwrap_or(false); + + // Always evaluate bcrypt even on unknown slug to reduce timing signal. + if !valid { + if user.is_none() { + // Dummy verify so timing is similar regardless of slug existence. + let _ = bcrypt::verify(&form.password, "$2b$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvalidii"); + } + warn!(slug = %form.slug, "login failed"); + return render_login(Some("Login fehlgeschlagen")).map(IntoResponse::into_response); + } + + let user = user.unwrap(); + let token: String = rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(TOKEN_LEN) + .map(char::from) + .collect(); + + let ttl = Duration::from_secs(u64::from(config.session_timeout_hours) * 3600); + { + let mut w = store.write().await; + w.insert( + token.clone(), + WebSession { + slug: user.slug.clone(), + role: user.role.clone(), + expires_at: Instant::now() + ttl, + }, + ); + } + + info!(slug = %user.slug, "login ok"); + + let cookie = build_cookie(token, ttl, config.cookie_secure); + let jar = jar.add(cookie); + Ok((jar, Redirect::to("/web/cases")).into_response()) +} + +pub async fn handle_logout( + State(config): State>, + State(store): State, + jar: CookieJar, +) -> Result { + if let Some(c) = jar.get(COOKIE_NAME) { + let token = c.value().to_owned(); + let mut w = store.write().await; + let removed = w.remove(&token); + if let Some(s) = removed { + info!(slug = %s.slug, "logout"); + } + } + + // Expire the cookie by setting an empty value with max_age=0. + let mut cookie = build_cookie(String::new(), Duration::from_secs(0), config.cookie_secure); + cookie.make_removal(); + let jar = jar.add(cookie); + Ok((jar, Redirect::to("/web/login")).into_response()) +} + +fn render_login(error: Option<&'static str>) -> Result, AppError> { + LoginTemplate { error } + .render() + .map(Html) + .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) +} + +fn build_cookie(value: String, max_age: Duration, secure: bool) -> Cookie<'static> { + Cookie::build((COOKIE_NAME, value)) + .http_only(true) + .secure(secure) + .same_site(SameSite::Strict) + .path("/") + .max_age(time::Duration::seconds(max_age.as_secs() as i64)) + .build() +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 800a7c1..e3e30f5 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,7 +1,9 @@ mod debug; mod health; +mod login; mod upload; -mod web; +pub(crate) mod web; +mod user_web; use axum::routing::{get, post}; use axum::Router; @@ -13,6 +15,10 @@ 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/login", get(login::handle_login_page).post(login::handle_login_submit)) + .route("/web/logout", post(login::handle_logout)) + .route("/web/cases", get(user_web::handle_my_cases)) + .route("/web/cases/{case_id}", get(user_web::handle_case_detail)) .route("/web/", get(web::handle_case_list)) .route( "/web/audio/{user}/{case_id}/{filename}", diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs new file mode 100644 index 0000000..1c68aaa --- /dev/null +++ b/server/src/routes/user_web.rs @@ -0,0 +1,173 @@ +use std::path::Path; +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path as AxumPath, State}; +use axum::response::Html; +use tracing::{info, warn}; + +use crate::auth::AuthenticatedWebUser; +use crate::config::Config; +use crate::error::AppError; +use crate::routes::web::{scan_recordings, RecordingView}; + +struct UserCaseView { + case_id: String, + case_id_short: String, + status: String, + most_recent: String, + oneliner: Option, + recordings: Vec, +} + +#[derive(Template)] +#[template(path = "my_cases.html")] +struct MyCasesTemplate { + slug: String, + open: Vec, + done: Vec, +} + +#[derive(Template)] +#[template(path = "case_detail.html")] +struct CaseDetailTemplate { + slug: String, + case_id: String, + case_id_short: String, + status: String, + oneliner: Option, + recordings: Vec, +} + +pub async fn handle_my_cases( + user: AuthenticatedWebUser, + State(config): State>, +) -> Result, AppError> { + let (open, done) = scan_user_cases(&config.data_path, &user.slug).await; + MyCasesTemplate { + slug: user.slug, + open, + done, + } + .render() + .map(Html) + .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) +} + +pub async fn handle_case_detail( + user: AuthenticatedWebUser, + State(config): State>, + AxumPath(case_id): AxumPath, +) -> Result, AppError> { + uuid::Uuid::parse_str(&case_id) + .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; + + // IDOR guard: case_dir must live under the session's user_slug. + let user_root = config.data_path.join(&user.slug); + let (case_dir, status) = match locate_case(&user_root, &case_id).await { + Some(v) => v, + None => { + warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)"); + return Err(AppError::NotFound("Case not found".into())); + } + }; + info!(slug = %user.slug, case_id = %case_id, status = %status, "case detail viewed"); + + let recordings = scan_recordings(&case_dir).await; + let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt")) + .await + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let case_id_short = case_id.chars().take(8).collect(); + + CaseDetailTemplate { + slug: user.slug, + case_id, + case_id_short, + status, + oneliner, + recordings, + } + .render() + .map(Html) + .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) +} + +/// Locate a case directory under `/{open|done}/`. +/// Returns the path and the status bucket it was found in. +async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> { + for status in ["open", "done"] { + let p = user_root.join(status).join(case_id); + if tokio::fs::try_exists(&p).await.unwrap_or(false) { + return Some((p, status.to_string())); + } + } + None +} + +/// Scan only the given user's cases. Returns (open, done), each sorted +/// with most recent first. +async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec, Vec) { + let user_root = data_path.join(slug); + let mut open = Vec::new(); + let mut done = Vec::new(); + + for status in ["open", "done"] { + let status_dir = user_root.join(status); + let mut entries = match tokio::fs::read_dir(&status_dir).await { + Ok(r) => r, + Err(_) => continue, + }; + + while let Ok(Some(case_entry)) = 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(); + let oneliner = tokio::fs::read_to_string(case_entry.path().join("oneliner.txt")) + .await + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + let view = UserCaseView { + case_id, + case_id_short, + status: status.to_string(), + most_recent, + oneliner, + recordings, + }; + + if status == "open" { + open.push(view); + } else { + done.push(view); + } + } + } + + open.sort_by(|a, b| b.most_recent.cmp(&a.most_recent)); + done.sort_by(|a, b| b.most_recent.cmp(&a.most_recent)); + (open, done) +} diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index b356ed0..5e25029 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -11,12 +11,12 @@ use tracing::warn; use crate::config::Config; use crate::error::AppError; -struct RecordingView { - filename: String, - transcript: Option, +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. - failed: bool, + pub(crate) failed: bool, } struct CaseView { @@ -170,7 +170,7 @@ async fn scan_cases(data_path: &Path) -> Vec { cases } -async fn scan_recordings(case_dir: &Path) -> Vec { +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, diff --git a/server/src/web_session.rs b/server/src/web_session.rs new file mode 100644 index 0000000..7a05c4d --- /dev/null +++ b/server/src/web_session.rs @@ -0,0 +1,17 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; + +use tokio::sync::RwLock; + +pub struct WebSession { + pub slug: String, + pub role: String, + pub expires_at: Instant, +} + +pub type SessionStore = Arc>>; + +pub fn new_store() -> SessionStore { + Arc::new(RwLock::new(HashMap::new())) +} diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html new file mode 100644 index 0000000..8ca2618 --- /dev/null +++ b/server/templates/case_detail.html @@ -0,0 +1,61 @@ + + + + +Doctate — Fall {{ case_id_short }} + + + +
+ +
+
+

Fall {{ case_id_short }} {{ status }}

+{% match oneliner %} +{% when Some with (t) %} +
{{ t }}
+{% when None %} +
Noch kein Oneliner.
+{% endmatch %} +
{{ case_id }}
+ +

Aufnahmen ({{ recordings.len() }})

+{% for rec in recordings %} +
+
+{{ rec.filename }} + +
+{% if rec.failed %} +
Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.
+{% else %} +{% match rec.transcript %} +{% when Some with (t) %} +
{{ t }}
+{% when None %} +
Transkription läuft…
+{% endmatch %} +{% endif %} +
+{% endfor %} + + diff --git a/server/templates/login.html b/server/templates/login.html new file mode 100644 index 0000000..d6e3873 --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,28 @@ + + + + +Doctate — Login + + + +

Doctate Login

+{% match error %} +{% when Some with (msg) %} +
{{ msg }}
+{% when None %} +{% endmatch %} +
+ + + +
+ + diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html new file mode 100644 index 0000000..984b76a --- /dev/null +++ b/server/templates/my_cases.html @@ -0,0 +1,66 @@ + + + + +Doctate — Meine Fälle + + + +
+

{{ slug }} — Meine Fälle

+
+
+ +
+

Offen ({{ open.len() }})

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

Keine offenen Fälle.

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

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)

+{% match case.oneliner %} +{% when Some with (t) %} +
{{ t }}
+{% when None %} +{% endmatch %} +{{ case.most_recent }} +
+{% endfor %} +{% endif %} +
+ +
+

Abgeschlossen ({{ done.len() }})

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

Keine abgeschlossenen Fälle.

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

{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)

+{% match case.oneliner %} +{% when Some with (t) %} +
{{ t }}
+{% when None %} +{% endmatch %} +{{ case.most_recent }} +
+{% endfor %} +{% endif %} +
+ + diff --git a/server/tests/auth_test.rs b/server/tests/auth_test.rs index 2a4cba3..e8f83b7 100644 --- a/server/tests/auth_test.rs +++ b/server/tests/auth_test.rs @@ -35,6 +35,7 @@ fn test_config() -> Arc { llm_model: String::new(), llm_temperature: 0.0, session_timeout_hours: 8, + cookie_secure: false, }) } diff --git a/server/tests/health_test.rs b/server/tests/health_test.rs index 6b78979..c065e1d 100644 --- a/server/tests/health_test.rs +++ b/server/tests/health_test.rs @@ -29,6 +29,7 @@ fn test_config() -> Arc { llm_model: String::new(), llm_temperature: 0.0, session_timeout_hours: 8, + cookie_secure: false, }) } diff --git a/server/tests/login_test.rs b/server/tests/login_test.rs new file mode 100644 index 0000000..49aa38c --- /dev/null +++ b/server/tests/login_test.rs @@ -0,0 +1,235 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use tower::util::ServiceExt; + +use doctate_server::config::{Config, User}; + +fn make_user(slug: &str, password_plain: &str) -> User { + User { + slug: slug.into(), + api_key: format!("key-{slug}"), + web_password: bcrypt::hash(password_plain, 4).unwrap(), + role: "doctor".into(), + whisper: Default::default(), + } +} + +fn test_config_with_users(users: Vec) -> Arc { + let data_path = std::env::temp_dir().join(format!( + "doctate-login-test-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let api_keys: HashMap = + users.iter().map(|u| (u.api_key.clone(), u.slug.clone())).collect(); + Arc::new(Config { + server_port: 3000, + data_path, + log_level: "info".into(), + log_path: "/tmp/doctate-test/logs".into(), + log_max_days: 90, + users, + api_keys, + 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, + cookie_secure: false, + }) +} + +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() +} + +/// Extract the session cookie (name=value) from a Set-Cookie response. +fn extract_session_cookie(resp: &axum::response::Response) -> Option { + for v in resp.headers().get_all(header::SET_COOKIE).iter() { + let s = v.to_str().ok()?; + if let Some(pair) = s.split(';').next() + && pair.starts_with("session=") + { + return Some(pair.to_string()); + } + } + None +} + +fn login_request(slug: &str, password: &str) -> Request { + let body = format!("slug={slug}&password={password}"); + Request::builder() + .method("POST") + .uri("/web/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap() +} + +#[tokio::test] +async fn login_success_sets_session_cookie_and_redirects() { + let config = test_config_with_users(vec![make_user("dr_a", "secret")]); + let app = doctate_server::create_router(config); + + let resp = app.oneshot(login_request("dr_a", "secret")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER.as_u16(), "got {:?}", resp.status()); + + let loc = resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(); + assert_eq!(loc, "/web/cases"); + + let cookie = extract_session_cookie(&resp).expect("no session cookie"); + assert!(cookie.starts_with("session=")); + assert!(cookie.len() > "session=".len() + 20, "token looks too short: {cookie}"); +} + +#[tokio::test] +async fn login_wrong_password_shows_error() { + let config = test_config_with_users(vec![make_user("dr_a", "secret")]); + let app = doctate_server::create_router(config); + + let resp = app.oneshot(login_request("dr_a", "wrong")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!(extract_session_cookie(&resp).is_none()); + let body = body_to_string(resp).await; + assert!(body.contains("Login fehlgeschlagen"), "got: {body}"); +} + +#[tokio::test] +async fn login_unknown_user_shows_error() { + let config = test_config_with_users(vec![make_user("dr_a", "secret")]); + let app = doctate_server::create_router(config); + + let resp = app.oneshot(login_request("ghost", "anything")).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert!(extract_session_cookie(&resp).is_none()); +} + +#[tokio::test] +async fn cases_without_cookie_redirects_to_login() { + let config = test_config_with_users(vec![make_user("dr_a", "secret")]); + let app = doctate_server::create_router(config); + + let resp = app + .oneshot(Request::builder().uri("/web/cases").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND); + assert_eq!( + resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(), + "/web/login" + ); +} + +#[tokio::test] +async fn cases_with_valid_cookie_shows_only_own_cases() { + let config = test_config_with_users(vec![ + make_user("dr_a", "s"), + make_user("dr_b", "s"), + ]); + // Seed fixtures: dr_a has an open case, dr_b has one too. + let case_a = config.data_path.join("dr_a/open/11111111-1111-1111-1111-111111111111"); + let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222"); + std::fs::create_dir_all(&case_a).unwrap(); + std::fs::create_dir_all(&case_b).unwrap(); + std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap(); + std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); + + let app = doctate_server::create_router(config); + + // Log in as dr_a. + let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let cookie = extract_session_cookie(&login).unwrap(); + + let resp = app + .oneshot( + Request::builder() + .uri("/web/cases") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = body_to_string(resp).await; + assert!(body.contains("11111111"), "own case missing: {body}"); + assert!(!body.contains("22222222"), "foreign case leaked: {body}"); +} + +#[tokio::test] +async fn case_detail_of_foreign_case_returns_404() { + let config = test_config_with_users(vec![ + make_user("dr_a", "s"), + make_user("dr_b", "s"), + ]); + let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222"); + std::fs::create_dir_all(&case_b).unwrap(); + std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap(); + + let app = doctate_server::create_router(config); + + let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let cookie = extract_session_cookie(&login).unwrap(); + + let resp = app + .oneshot( + Request::builder() + .uri("/web/cases/22222222-2222-2222-2222-222222222222") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn logout_clears_session() { + let config = test_config_with_users(vec![make_user("dr_a", "s")]); + let app = doctate_server::create_router(config); + + let login = app.clone().oneshot(login_request("dr_a", "s")).await.unwrap(); + let cookie = extract_session_cookie(&login).unwrap(); + + let logout = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/web/logout") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::SEE_OTHER.as_u16()); + + // Same cookie must no longer be accepted. + let resp = app + .oneshot( + Request::builder() + .uri("/web/cases") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FOUND); +} diff --git a/server/tests/transcribe_test.rs b/server/tests/transcribe_test.rs index 5a807d5..23efa45 100644 --- a/server/tests/transcribe_test.rs +++ b/server/tests/transcribe_test.rs @@ -276,6 +276,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc { llm_model: String::new(), llm_temperature: 0.0, session_timeout_hours: 8, + cookie_secure: false, }) } diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index afa0664..78ecb19 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -40,6 +40,7 @@ fn test_config() -> Arc { llm_model: String::new(), llm_temperature: 0.0, session_timeout_hours: 8, + cookie_secure: false, }) } diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index a9a36bd..3c6d391 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -40,6 +40,7 @@ fn test_config() -> Arc { llm_model: String::new(), llm_temperature: 0.0, session_timeout_hours: 8, + cookie_secure: false, }) } diff --git a/server/users.toml.example b/server/users.toml.example index 6d2f730..050b83e 100644 --- a/server/users.toml.example +++ b/server/users.toml.example @@ -1,3 +1,8 @@ +# Generate a bcrypt hash for `web_password` e.g. via: +# python3 -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt()).decode())" +# or: +# htpasswd -bnBC 12 '' your-password | tr -d ':\n' + [[user]] slug = "dr_mueller" api_key = "change-me-to-a-secure-key"