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 doctate_common::API_KEY_HEADER; use tracing::warn; use crate::config::Config; use crate::error::AppError; use crate::web_session::{SESSION_COOKIE, SessionStore}; /// Truncate a session token to a non-sensitive prefix for logs. fn token_prefix(token: &str) -> String { token.chars().take(8).collect() } /// Extracted from the X-API-Key header on every authenticated request. pub struct AuthenticatedUser { pub slug: String, pub role: String, pub data_dir: PathBuf, /// Copied from the matching entry in `users.toml` — the single /// source of truth for how many hours of history clients see. pub window_hours: u32, } impl AuthenticatedUser { pub fn is_admin(&self) -> bool { self.role == "admin" } } impl FromRequestParts for AuthenticatedUser where S: Send + Sync, Arc: FromRef, { type Rejection = AppError; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let config = Arc::::from_ref(state); let api_key = parts .headers .get(API_KEY_HEADER) .and_then(|v| v.to_str().ok()) .ok_or(AppError::Unauthorized)?; let slug = config.api_keys.get(api_key).ok_or(AppError::Unauthorized)?; let user = config .users .iter() .find(|u| u.slug == *slug) .ok_or(AppError::Unauthorized)?; let data_dir = config.data_path.join(slug); tokio::fs::create_dir_all(&data_dir).await?; Ok(Self { slug: slug.clone(), role: user.role.clone(), data_dir, window_hours: user.window_hours, }) } } /// Extracted from the session cookie on web UI requests. /// On missing/expired session, returns `AppError::Redirect("/login")`. pub struct AuthenticatedWebUser { pub slug: String, pub role: String, pub data_dir: PathBuf, /// Mirrors [`AuthenticatedUser::window_hours`] so the same policy /// is available to web handlers without a second config lookup. pub window_hours: u32, /// Human-readable label rendered in the page header (browser title /// stays neutral). Resolved from the user's `display_name` field /// in users.toml, falling back to `slug` if absent. Pre-resolved /// here so handlers and templates don't repeat the fallback logic. pub display_name: String, /// Session's CSRF token. Rendered into every state-changing form /// as a hidden field so the `CsrfForm` extractor can validate it /// on the matching POST. Cloned out of the session record here so /// the template handlers don't need a second store lookup. pub csrf_token: String, } impl AuthenticatedWebUser { pub fn is_admin(&self) -> bool { self.role == "admin" } } 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("/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("/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("/login".into())); } let r = store.read().await; let session = r .get(&token) .ok_or_else(|| AppError::Redirect("/login".into()))?; let data_dir = config.data_path.join(&session.slug); // Look up the TOML user record for the policy fields the session // does not carry. A missing record can only happen if users.toml // was edited while a session was still live; fall back to the // serde default so the request stays functional. let user_record = config.users.iter().find(|u| u.slug == session.slug); let window_hours = user_record.map(|u| u.window_hours).unwrap_or(72); let display_name = user_record .map(|u| u.display_name().to_owned()) .unwrap_or_else(|| session.slug.clone()); Ok(Self { slug: session.slug.clone(), role: session.role.clone(), data_dir, window_hours, display_name, csrf_token: session.csrf_token.clone(), }) } }