//! The description layer: screens served through quasi rather than Askama. //! //! Wiki note `mnw-server-conversion-plan`, step S3 onward. `crate::shell` //! already put the document head under quasi's renderer (S1); this is where //! screens themselves start moving. One screen at a time, each behind //! [`QuasiScreens`](crate::config::QuasiScreens), so a conversion ships when //! its own test is green and reverts by editing an env var. //! //! # Why a per-request state and not a per-request handler argument //! //! `quasi_router::Handler = fn(&S, Request)`. `S` is the app, and on every //! other host in the tree there is one app and one viewer for the life of the //! process. A server has one process and many viewers, and that is the only //! assumption it breaks, so the fix is that the adapter builds `S` per request //! rather than that the handler grows a parameter. `Adapter::per_viewer` is //! that: the factory runs in async context, where the session lookup already //! lives, and hands the sync router a [`Viewer`] with the answer already in it. //! //! # The cost this exists to measure //! //! The router is sync (quasi's decision 6, taken for hosts with no runtime), so //! quasi-axum dispatches on `spawn_blocking` and a handler reaching sqlx does it //! through `Handle::block_on`. Every described request therefore holds a //! blocking-pool thread for the length of its database round trips. That is //! fine on a desktop app over rusqlite and is an open question on the one host //! in the tree with many concurrent readers. It is not arguable, only //! measurable: see `tests/load` and the S3 numbers in the wiki note. use tokio::runtime::Handle; use crate::AppState; use crate::auth::SessionUser; pub mod buyer_contacts; pub mod forum_memberships; pub mod library_contacts; pub mod ssh_keys; pub mod user_analytics; pub mod widgets; /// The state one request is answered against. /// /// Built per request by the adapter's factory, which is the whole of quasi's /// answer to a server: everything resolvable from the request head is loaded /// before the sync router runs, and a handler reads it off `&S` the way every /// other host's handler reads its app. pub struct Viewer { /// The long-lived application state. Cloning it clones handles, not data. pub app: AppState, /// Who is asking. Resolved and revocation-checked by /// [`crate::auth::authenticate`], the same path the extractor takes. pub user: SessionUser, /// The runtime the request arrived on, so a sync handler can reach the /// async database. Captured in the factory rather than read inside the /// handler: `Handle::current` works on a blocking thread today, and /// depending on that is depending on where quasi-axum happens to dispatch. pub runtime: Handle, /// Markup for the bespoke regions this request's screen describes. /// /// The seam between a handler and its renderer, and the reason it has to be /// here rather than in either of them: a `Region::Bespoke` is filled on the /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the /// handler has answered, and the renderer factory is handed `&S` and the /// answer. So the handler writes what it drew here and the renderer reads /// it back off the same state. Both see one instance: the adapter builds a /// single `Arc` per request and passes it to the router and then to /// the factory. /// /// Behind a lock because the handler holds `&Viewer` and runs on a blocking /// thread. Uncontended in practice: one request writes it, then one /// renderer reads it, never at once. fills: std::sync::Mutex>, } impl Viewer { /// Run a database future from inside a sync handler. /// /// The blocking hop, named in one place so the thing being measured is /// countable rather than spread across every handler. Every call holds this /// blocking thread until the query answers. pub fn block_on(&self, future: F) -> F::Output { self.runtime.block_on(future) } /// Hand the renderer the markup for one bespoke region. /// /// Called by a handler while it builds its description, keyed by the slot /// id the description gives that region. Markup, not text: a bespoke region /// is the app's own and is not escaped, which is the whole of what makes it /// bespoke and the whole of why a handler must not put a reader's string in /// one without escaping it first. pub fn fill(&self, slot_id: impl Into, markup: impl Into) { if let Ok(mut fills) = self.fills.lock() { fills.insert(slot_id.into(), markup.into()); } } /// Everything the handler drew, for the renderer to mount. pub fn drawn(&self) -> std::collections::HashMap { self.fills.lock().map(|f| f.clone()).unwrap_or_default() } } /// Build the state factory the adapter calls per request. /// /// Refuses with `Unauthorized` when there is no session to resolve, which the /// adapter turns into a bare status with no body. That is the right shape here /// and not a shortcut: a store that will not answer is not a signed-out reader, /// and rendering a sign-in notice would need the renderer that is built from /// the state that could not be resolved. fn viewer_factory( app: AppState, ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture + Send + Sync + 'static { move |parts| { let app = app.clone(); let runtime = Handle::current(); // Taken out of the head first, so the future owns what it needs. let session = parts.extensions.get::().cloned(); Box::pin(async move { let Some(session) = session else { // The session layer runs in front of this. Its absence is a // wiring mistake rather than a signed-out reader. return Err(quasi_router::RouteError::internal("no session layer")); }; match crate::auth::authenticate(&session, &app).await { Ok(user) => Ok(Viewer { app, user, runtime, fills: std::sync::Mutex::default(), }), Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")), } }) } } /// Every converted screen that is switched on, with the address it answers. /// /// One mount per screen rather than one router for all of them, because axum /// strips a nest's prefix before the inner service sees the request: a single /// nest covering both would have to sit at a prefix the Askama routes also live /// under, and matchit refuses to hold a wildcard beside the parameterised routes /// already there. Measured, not assumed: nesting at `/dashboard/project` panics /// at startup against `/dashboard/project/{slug}/tabs/overview`. /// /// The list is empty when nothing is switched on, so the caller registers its /// Askama routes exactly as before and the adapter is not in the stack at all. A /// conversion is a startup-time choice: config is read once, and a per-request /// branch would pay for a switch that never moves. /// Every address a described screen can claim, switched on or not. /// /// `mounts` returns only what is currently on, which depends on config. This is /// the whole set, and it exists for the CSRF coverage test: the manifest that /// test reads is a process-global, so a test that switches a screen on leaves an /// entry behind for a path the default router does not serve, and the probe /// reads that as a route that lost its protection. The list lets it skip exactly /// those and nothing else. /// /// Checked against `mounts` below rather than trusted, since a screen added to /// one and not the other is the obvious way for this to rot. pub const PATHS: &[&str] = &[ ssh_keys::PATH, library_contacts::PATH, buyer_contacts::PATH, user_analytics::PATH, forum_memberships::LIBRARY_PATH, forum_memberships::SETTINGS_PATH, ]; /// Every screen's switch name, in the same order as [`PATHS`]. /// /// Test-only: the switches themselves are read from each screen's own `SCREEN` /// in `mounts`, and this exists so the consistency check below has both halves /// to compare. Kept beside `PATHS` rather than inside the test module, because /// the pairing is the thing being asserted and splitting them is how they drift. #[cfg(test)] const SCREENS: &[&str] = &[ ssh_keys::SCREEN, library_contacts::SCREEN, buyer_contacts::SCREEN, user_analytics::SCREEN, forum_memberships::LIBRARY_SCREEN, forum_memberships::SETTINGS_SCREEN, ]; pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { let mut mounted = Vec::new(); if described(app, ssh_keys::SCREEN) { mounted.push(( ssh_keys::PATH, mount(app, ssh_keys::screen, ssh_keys::WRITES, ssh_keys::renderer), )); } if described(app, library_contacts::SCREEN) { mounted.push(( library_contacts::PATH, mount( app, library_contacts::screen, library_contacts::WRITES, library_contacts::renderer, ), )); } // One module, two screens: the library tab and the settings section are the // same table under different chrome, and each is switched on by itself. if described(app, forum_memberships::LIBRARY_SCREEN) { mounted.push(( forum_memberships::LIBRARY_PATH, mount( app, forum_memberships::library_screen, &[], forum_memberships::renderer, ), )); } if described(app, buyer_contacts::SCREEN) { mounted.push(( buyer_contacts::PATH, mount(app, buyer_contacts::screen, &[], buyer_contacts::renderer), )); } if described(app, user_analytics::SCREEN) { mounted.push(( user_analytics::PATH, mount(app, user_analytics::screen, &[], user_analytics::renderer), )); } if described(app, forum_memberships::SETTINGS_SCREEN) { mounted.push(( forum_memberships::SETTINGS_PATH, mount( app, forum_memberships::settings_screen, &[], forum_memberships::renderer, ), )); } mounted } /// A handler, spelled once so the screens and the mount agree about it. pub type Screen = fn(&Viewer, quasi_router::Request) -> Result; /// One screen behind the adapter, answering the root of its own nest. /// /// The tab is `/` because the nest has already taken the address off: a screen /// mounted at its own tab endpoint sees one route and never has to agree with /// the prefix twice. /// /// # Why a screen serves its own writes /// /// `writes` registers routes under the same nest, and a destructive control on /// a described screen should address one of them rather than the API route the /// Askama version used. Decision 7 is the reason: a described route answers with /// a `Response::Fragment` naming the region it changed, so the answer lands where /// it belongs and carries the screen's own markup. /// /// An API route can do neither, and both described screens that called one were /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx /// request with the whole re-rendered Askama list, and with no target htmx put /// that table inside the button that was pressed. `DELETE /api/contacts/{id}` /// answers 204, which htmx is configured never to swap, so the row stayed on /// screen after a successful revoke. Both found 2026-08-11 by reading what the /// endpoints return; both were invisible to tests that check the address exists. /// /// The write still goes through the same CSRF envelope: `crate::csrf` nests this /// service under an Auto posture, and the core module attaches the token to /// every htmx request on the page. fn mount( app: &AppState, screen: Screen, writes: &[(quasi_router::Method, &'static str, Screen)], renderer: fn(&Viewer) -> quasi_webview::Webview, ) -> axum::Router { let mut quasi = quasi_router::Router::::new().get("/", screen); for (method, path, handler) in writes { quasi = match method { quasi_router::Method::Delete => quasi.delete(path, *handler), quasi_router::Method::Put => quasi.put(path, *handler), quasi_router::Method::Get => quasi.get(path, *handler), quasi_router::Method::Post => quasi.post(path, *handler), }; } quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), move |viewer, _, _| { renderer(viewer) }) .into_router() } /// Whether a named screen serves from the description layer. /// /// Read by the route tables, which mount one or the other. Here rather than /// there so the switch and the screens it names stay together. #[must_use] pub fn described(app: &AppState, screen: &str) -> bool { app.config.quasi_screens.enabled(screen) } #[cfg(test)] mod tests { use super::*; #[test] fn every_screen_is_listed_in_paths() { // The two lists are written by hand and read by two different things, // so the check is that adding a screen to `mounts` and forgetting // `PATHS` fails here rather than silently weakening the CSRF coverage // probe's skip list. assert_eq!( PATHS.len(), SCREENS.len(), "PATHS and SCREENS describe the same screens" ); let source = include_str!("mod.rs"); let mounted = source .split_once("pub fn mounts(") .expect("mounts exists") .1 .split_once("\n}") .expect("mounts ends") .0; let registered = mounted.matches("mounted.push((").count(); assert_eq!( registered, PATHS.len(), "mounts registers {registered} screens, PATHS lists {}", PATHS.len() ); } #[test] fn a_path_is_claimed_by_exactly_one_screen() { // Two screens on one address is an axum panic at startup, and the two // forum-memberships screens are the near miss: one module, two paths. let mut seen = PATHS.to_vec(); seen.sort_unstable(); let before = seen.len(); seen.dedup(); assert_eq!( before, seen.len(), "two screens claim one address: {seen:?}" ); } }