pub mod analyze; pub mod auth; pub mod case_id; pub mod config; pub mod csrf; pub mod error; pub mod events; pub mod gazetteer; pub mod loudness; pub mod magic_link; pub mod models; pub mod oneliner_locks; pub mod paths; pub mod retention; pub mod routes; pub mod settings; pub mod transcribe; pub mod validate; pub mod web_session; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use axum::Router; use axum::extract::FromRef; use axum::http::{HeaderName, HeaderValue}; use tower_http::set_header::SetResponseHeaderLayer; use analyze::AnalyzeSender; use config::Config; use gazetteer::Gazetteer; use magic_link::MagicLinkStore; use oneliner_locks::OnelinerLocks; use settings::Settings; use transcribe::TranscribeSender; use web_session::SessionStore; /// Live "is this worker currently processing a job?" flag. /// Set true before each job, false after. UI uses it to distinguish a real /// in-flight job from a stale on-disk marker (orphan input, missing /// transcript) left over from a crash. pub type WorkerBusy = Arc; /// Newtype wrappers so `FromRef` can target the two busy-flags /// separately even though both are `Arc` underneath. #[derive(Clone)] pub struct AnalyzeBusy(pub WorkerBusy); #[derive(Clone)] pub struct TranscribeBusy(pub WorkerBusy); /// Dedup flag for the detached oneliner-heal `tokio::spawn` fired from /// `heal_orphans_if_idle`. The handler CAS's this to `true`; the spawned /// task resets via [`BusyGuard`] on drop. Keeps concurrent page-loads /// from launching parallel Ollama-call loops against the same data. #[derive(Clone)] pub struct OnelinerHealBusy(pub WorkerBusy); /// RAII guard that flips a [`WorkerBusy`] true on construction and false on /// drop — panic-safe. Shared across analyze + transcribe workers. pub struct BusyGuard(WorkerBusy); impl BusyGuard { pub fn new(busy: WorkerBusy) -> Self { busy.store(true, Ordering::Release); Self(busy) } } impl Drop for BusyGuard { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } /// Bundle of the four worker-pipeline handles (analyze + transcribe, /// each with a busy-flag and a job sender). Extracted so handlers that /// need the whole pipeline (self-heal, status probes) can take a single /// `State` instead of four separate `State(...)` params. /// The individual `FromRef` impls below remain valid, so /// handlers that only need one sender keep their narrower signature. #[derive(Clone)] pub struct PipelineState { pub analyze_busy: AnalyzeBusy, pub analyze_tx: AnalyzeSender, pub transcribe_busy: TranscribeBusy, pub transcribe_tx: TranscribeSender, pub oneliner_heal_busy: OnelinerHealBusy, pub oneliner_locks: OnelinerLocks, } /// Shared application state. Clonable — all fields are cheap to clone /// (`Arc` and `mpsc::Sender` / `broadcast::Sender`). #[derive(Clone)] pub struct AppState { pub config: Arc, pub settings: Arc, pub transcribe_tx: TranscribeSender, pub analyze_tx: AnalyzeSender, pub session_store: SessionStore, pub magic_link_store: MagicLinkStore, pub analyze_busy: AnalyzeBusy, pub transcribe_busy: TranscribeBusy, pub oneliner_heal_busy: OnelinerHealBusy, pub oneliner_locks: OnelinerLocks, pub events_tx: events::EventSender, pub http_client: reqwest::Client, pub vocab: Arc, } impl FromRef for Arc { fn from_ref(state: &AppState) -> Self { state.config.clone() } } impl FromRef for Arc { fn from_ref(state: &AppState) -> Self { state.settings.clone() } } impl FromRef for TranscribeSender { fn from_ref(state: &AppState) -> Self { state.transcribe_tx.clone() } } impl FromRef for AnalyzeSender { fn from_ref(state: &AppState) -> Self { state.analyze_tx.clone() } } impl FromRef for SessionStore { fn from_ref(state: &AppState) -> Self { state.session_store.clone() } } impl FromRef for MagicLinkStore { fn from_ref(state: &AppState) -> Self { state.magic_link_store.clone() } } impl FromRef for AnalyzeBusy { fn from_ref(state: &AppState) -> Self { state.analyze_busy.clone() } } impl FromRef for TranscribeBusy { fn from_ref(state: &AppState) -> Self { state.transcribe_busy.clone() } } impl FromRef for OnelinerHealBusy { fn from_ref(state: &AppState) -> Self { state.oneliner_heal_busy.clone() } } impl FromRef for OnelinerLocks { fn from_ref(state: &AppState) -> Self { state.oneliner_locks.clone() } } impl FromRef for PipelineState { fn from_ref(state: &AppState) -> Self { PipelineState { analyze_busy: state.analyze_busy.clone(), analyze_tx: state.analyze_tx.clone(), transcribe_busy: state.transcribe_busy.clone(), transcribe_tx: state.transcribe_tx.clone(), oneliner_heal_busy: state.oneliner_heal_busy.clone(), oneliner_locks: state.oneliner_locks.clone(), } } } impl FromRef for events::EventSender { fn from_ref(state: &AppState) -> Self { state.events_tx.clone() } } impl FromRef for reqwest::Client { fn from_ref(state: &AppState) -> Self { state.http_client.clone() } } impl FromRef for Arc { fn from_ref(state: &AppState) -> Self { state.vocab.clone() } } /// Test/simple entrypoint: jobs pushed into either channel are dropped /// because the receivers are not retained. Use [`create_router_with_state`] /// from `main.rs` where real workers own the receivers. pub fn create_router(config: Arc) -> Router { create_router_and_session_store(config).0 } /// Same as [`create_router`], but also returns a handle to the newly /// created session store so tests can peek at `WebSession::csrf_token` /// values without rendering and parsing HTML. The store handle is /// shared with the router (`Arc`), so any session created via `/web/login` /// or `/web/magic` becomes observable through it. pub fn create_router_and_session_store(config: Arc) -> (Router, SessionStore) { create_router_and_session_store_with_settings(config, Arc::new(Settings::default())) } /// Variant of [`create_router`] that takes both `Config` and `Settings`. /// Use from tests that exercise Bucket-B paths (LLM, Whisper, Ollama URLs); /// otherwise the [`create_router`] convenience with default Settings is enough. pub fn create_router_with_settings(config: Arc, settings: Arc) -> Router { create_router_and_session_store_with_settings(config, settings).0 } /// Variant of [`create_router_and_session_store`] that takes both `Config` and `Settings`. pub fn create_router_and_session_store_with_settings( config: Arc, settings: Arc, ) -> (Router, SessionStore) { let (transcribe_tx, _transcribe_rx) = transcribe::channel(); let (analyze_tx, _analyze_rx) = analyze::channel(); let session_store = web_session::new_store(); let router = create_router_with_state(AppState { config, settings, transcribe_tx, analyze_tx, session_store: session_store.clone(), magic_link_store: magic_link::new_store(), analyze_busy: AnalyzeBusy(Arc::new(AtomicBool::new(false))), transcribe_busy: TranscribeBusy(Arc::new(AtomicBool::new(false))), oneliner_heal_busy: OnelinerHealBusy(Arc::new(AtomicBool::new(false))), oneliner_locks: OnelinerLocks::new(), events_tx: events::channel(), http_client: reqwest::Client::new(), vocab: Arc::new(Gazetteer::empty()), }); (router, session_store) } pub fn create_router_with_state(state: AppState) -> Router { with_security_headers(routes::api_router().with_state(state)) } /// Content-Security-Policy for all responses. `'unsafe-inline'` on /// script-src / style-src is a pragmatic concession — the templates /// include inline scripts (e.g. partials/time_format.js, localStorage /// toggles) and user content is always escaped by askama before /// rendering, so the XSS surface is small. Revisit if ever rendering /// unescaped user input. const CSP: &str = "default-src 'self'; \ script-src 'self' 'unsafe-inline'; \ style-src 'self' 'unsafe-inline'; \ img-src 'self' data:; \ media-src 'self'; \ object-src 'none'; \ base-uri 'self'; \ frame-ancestors 'none'; \ form-action 'self'"; const PERMISSIONS_POLICY: &str = "microphone=(), camera=(), geolocation=(), payment=()"; /// Defense-in-depth security headers applied to every response. /// `if_not_present` respects per-route overrides (see routes/magic.rs, /// which sets its own Referrer-Policy). HSTS is deliberately omitted /// until TLS termination is in place; adding it prematurely on a /// plain-HTTP deployment is irreversible (browser caches max-age). fn with_security_headers(router: Router) -> Router { router .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"), )) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("x-frame-options"), HeaderValue::from_static("DENY"), )) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("referrer-policy"), HeaderValue::from_static("no-referrer"), )) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("content-security-policy"), HeaderValue::from_static(CSP), )) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("permissions-policy"), HeaderValue::from_static(PERMISSIONS_POLICY), )) }