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.
This commit is contained in:
+6
-6
@@ -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<HashMap<Token, ArztId>>`, 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<HashMap<Token, Session>>`, 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")
|
||||
|
||||
Generated
+83
-4
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<S> FromRequestParts<S> for AuthenticatedWebUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
Arc<Config>: FromRef<S>,
|
||||
SessionStore: FromRef<S>,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let config = Arc::<Config>::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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '<password>'");
|
||||
std::process::exit(2);
|
||||
});
|
||||
|
||||
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)
|
||||
.expect("bcrypt hashing failed");
|
||||
println!("{hash}");
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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<Config>,
|
||||
pub transcribe_tx: TranscribeSender,
|
||||
pub session_store: SessionStore,
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for Arc<Config> {
|
||||
@@ -33,6 +36,12 @@ impl FromRef<AppState> for TranscribeSender {
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> 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<Config>) -> Router {
|
||||
create_router_with_state(AppState {
|
||||
config,
|
||||
transcribe_tx: tx,
|
||||
session_store: web_session::new_store(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Html<String>, AppError> {
|
||||
render_login(None)
|
||||
}
|
||||
|
||||
pub async fn handle_login_submit(
|
||||
State(config): State<Arc<Config>>,
|
||||
State(store): State<SessionStore>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<Arc<Config>>,
|
||||
State(store): State<SessionStore>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Response, AppError> {
|
||||
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<Html<String>, 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()
|
||||
}
|
||||
@@ -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<AppState> {
|
||||
.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}",
|
||||
|
||||
@@ -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<String>,
|
||||
recordings: Vec<RecordingView>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "my_cases.html")]
|
||||
struct MyCasesTemplate {
|
||||
slug: String,
|
||||
open: Vec<UserCaseView>,
|
||||
done: Vec<UserCaseView>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "case_detail.html")]
|
||||
struct CaseDetailTemplate {
|
||||
slug: String,
|
||||
case_id: String,
|
||||
case_id_short: String,
|
||||
status: String,
|
||||
oneliner: Option<String>,
|
||||
recordings: Vec<RecordingView>,
|
||||
}
|
||||
|
||||
pub async fn handle_my_cases(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
) -> Result<Html<String>, 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<Arc<Config>>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
) -> Result<Html<String>, 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 `<user_root>/{open|done}/<case_id>`.
|
||||
/// 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<UserCaseView>, Vec<UserCaseView>) {
|
||||
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)
|
||||
}
|
||||
@@ -11,12 +11,12 @@ use tracing::warn;
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
|
||||
struct RecordingView {
|
||||
filename: String,
|
||||
transcript: Option<String>,
|
||||
pub(crate) struct RecordingView {
|
||||
pub(crate) filename: String,
|
||||
pub(crate) transcript: Option<String>,
|
||||
/// True if the file has been renamed to `<ts>.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<CaseView> {
|
||||
cases
|
||||
}
|
||||
|
||||
async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
|
||||
pub(crate) 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,
|
||||
|
||||
@@ -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<RwLock<HashMap<String, WebSession>>>;
|
||||
|
||||
pub fn new_store() -> SessionStore {
|
||||
Arc::new(RwLock::new(HashMap::new()))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Fall {{ case_id_short }}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; }
|
||||
header form { margin: 0; }
|
||||
.back { color: #4a90e2; text-decoration: none; }
|
||||
.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; }
|
||||
.meta { color: #666; font-family: monospace; font-size: 0.9em; }
|
||||
.recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; }
|
||||
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; }
|
||||
.recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; }
|
||||
.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; }
|
||||
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
|
||||
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
|
||||
.failed { color: #b00; font-weight: bold; margin-top: 0.5em; }
|
||||
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; }
|
||||
.status-badge.open { background: #4a90e2; }
|
||||
.status-badge.done { background: #7ed321; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><a class="back" href="/web/cases">← Zurück</a></div>
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</header>
|
||||
<h1>Fall {{ case_id_short }} <span class="status-badge {{ status }}">{{ status }}</span></h1>
|
||||
{% match oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<div class="oneliner">{{ t }}</div>
|
||||
{% when None %}
|
||||
<div class="oneliner" style="color:#888;font-style:italic">Noch kein Oneliner.</div>
|
||||
{% endmatch %}
|
||||
<div class="meta">{{ case_id }}</div>
|
||||
|
||||
<h2>Aufnahmen ({{ recordings.len() }})</h2>
|
||||
{% for rec in recordings %}
|
||||
<div class="recording{% if rec.failed %} failed-row{% endif %}">
|
||||
<div class="recording-head">
|
||||
<span>{{ rec.filename }}</span>
|
||||
<audio controls preload="none">
|
||||
<source src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}" type="audio/mp4">
|
||||
</audio>
|
||||
</div>
|
||||
{% if rec.failed %}
|
||||
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
|
||||
{% else %}
|
||||
{% match rec.transcript %}
|
||||
{% when Some with (t) %}
|
||||
<div class="transcript">{{ t }}</div>
|
||||
{% when None %}
|
||||
<div class="pending">Transkription läuft…</div>
|
||||
{% endmatch %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Login</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 400px; margin: 4em auto; padding: 0 1em; }
|
||||
h1 { font-size: 1.3em; }
|
||||
form { display: flex; flex-direction: column; gap: 0.7em; }
|
||||
input { padding: 0.5em; font-size: 1em; }
|
||||
button { padding: 0.6em; font-size: 1em; cursor: pointer; }
|
||||
.error { background: #fee; border: 1px solid #e99; padding: 0.5em; border-radius: 4px; color: #900; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Doctate Login</h1>
|
||||
{% match error %}
|
||||
{% when Some with (msg) %}
|
||||
<div class="error">{{ msg }}</div>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
<form method="post" action="/web/login">
|
||||
<label>Benutzer (slug)<input type="text" name="slug" autofocus required></label>
|
||||
<label>Passwort<input type="password" name="password" required></label>
|
||||
<button type="submit">Anmelden</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Doctate — Meine Fälle</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; }
|
||||
header form { margin: 0; }
|
||||
.case { border: 1px solid #ccc; margin: 0.8em 0; padding: 0.8em 1em; border-radius: 4px; display: block; text-decoration: none; color: inherit; }
|
||||
.case:hover { background: #f6f6f6; }
|
||||
.case h2 { margin: 0 0 0.3em 0; font-size: 1em; }
|
||||
.case small { color: #666; font-family: monospace; }
|
||||
.status-open { border-left: 4px solid #4a90e2; }
|
||||
.status-done { border-left: 4px solid #7ed321; }
|
||||
.oneliner { font-size: 1em; color: #333; margin: 0.2em 0; }
|
||||
section { margin: 1.5em 0; }
|
||||
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
||||
.empty { color: #888; font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{ slug }} — Meine Fälle</h1>
|
||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Offen ({{ open.len() }})</h2>
|
||||
{% if open.is_empty() %}
|
||||
<p class="empty">Keine offenen Fälle.</p>
|
||||
{% else %}
|
||||
{% for case in open %}
|
||||
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
|
||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)</h2>
|
||||
{% match case.oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<div class="oneliner">{{ t }}</div>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
<small>{{ case.most_recent }}</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Abgeschlossen ({{ done.len() }})</h2>
|
||||
{% if done.is_empty() %}
|
||||
<p class="empty">Keine abgeschlossenen Fälle.</p>
|
||||
{% else %}
|
||||
{% for case in done %}
|
||||
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
|
||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n)</h2>
|
||||
{% match case.oneliner %}
|
||||
{% when Some with (t) %}
|
||||
<div class="oneliner">{{ t }}</div>
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
<small>{{ case.most_recent }}</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
@@ -35,6 +35,7 @@ fn test_config() -> Arc<Config> {
|
||||
llm_model: String::new(),
|
||||
llm_temperature: 0.0,
|
||||
session_timeout_hours: 8,
|
||||
cookie_secure: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ fn test_config() -> Arc<Config> {
|
||||
llm_model: String::new(),
|
||||
llm_temperature: 0.0,
|
||||
session_timeout_hours: 8,
|
||||
cookie_secure: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<User>) -> Arc<Config> {
|
||||
let data_path = std::env::temp_dir().join(format!(
|
||||
"doctate-login-test-{}-{}",
|
||||
std::process::id(),
|
||||
uuid::Uuid::new_v4()
|
||||
));
|
||||
let api_keys: HashMap<String, String> =
|
||||
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<String> {
|
||||
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<Body> {
|
||||
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);
|
||||
}
|
||||
@@ -276,6 +276,7 @@ fn test_config_with_whisper(whisper_url: String) -> Arc<Config> {
|
||||
llm_model: String::new(),
|
||||
llm_temperature: 0.0,
|
||||
session_timeout_hours: 8,
|
||||
cookie_secure: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ fn test_config() -> Arc<Config> {
|
||||
llm_model: String::new(),
|
||||
llm_temperature: 0.0,
|
||||
session_timeout_hours: 8,
|
||||
cookie_secure: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ fn test_config() -> Arc<Config> {
|
||||
llm_model: String::new(),
|
||||
llm_temperature: 0.0,
|
||||
session_timeout_hours: 8,
|
||||
cookie_secure: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user