//! 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. //! //! Each of the seven converted screens shipped one at a time behind a //! `QUASI_SCREENS` switch, so a conversion went out when its own test was green //! and reverted by editing an env var. All seven are flipped and the switch is //! deleted (`64b33b26`): [`mounts`] is unconditional, the route tables register //! no Askama counterpart, and there is nothing left to revert to. A screen added //! here is live the moment it is mounted. //! //! # 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 blog_delete_act; pub mod buyer_contacts; pub mod discover_search; pub mod discover_typeahead; pub mod embeds; pub mod export_act; pub mod forum_memberships; pub mod item_sales; pub mod item_tabs; pub mod library_contacts; pub mod library_tabs; pub mod media_picker; pub mod payout_summary; pub mod project_analytics; pub mod project_content; pub mod project_members; pub mod project_overview; pub mod project_tabs; pub mod rich_field; pub mod settings_tabs; pub mod ssh_keys; pub mod upload_field; pub mod user_analytics; pub mod user_projects; pub mod user_support; pub mod user_tabs; pub mod version_delete_act; 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, /// This session's CSRF token, for the shell to hand to the document. /// /// Resolved in the factory rather than in a renderer because minting one is /// an async session write and a renderer is sync. It is the same token the /// Askama pages carry: [`crate::csrf::get_or_create_token`] is /// get-or-create, so a described page and a templated one in the same /// session agree, and validation is one comparison either way. pub csrf: String, /// This request's session-tracking id, when it has one. /// /// The same class of fact as [`csrf`](Self::csrf) and resolved the same /// way: reading it is an async session lookup, so the factory does it and /// the sync handler reads the answer off `&S`. It is what lets a screen /// tell the reader's own row apart from the rest, which /// `user_sessions` needs twice over: the `Current` badge, and the one row /// that offers no `Sign out`. /// /// `None` is a real state rather than a failure. A session predating /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a /// screen answering for one marks no row as current. pub session_id: Option, /// 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()); } } /// The shell every described screen is drawn in. /// /// Here rather than in each screen's `renderer` because of what it carries: /// a described page whose shell does not declare the session token has /// every write on it refused, and the five screens that wrote /// `Shell::under("/static")` out by hand were five chances to forget. A /// screen that wants more says so on top of this; a screen that says /// nothing gets the token anyway. /// /// The layer order matches `crate::shell`, which is the Askama half of the /// same document: `makeover` is prepended by the renderer, and the site's /// own sheets live in `components`. #[must_use] pub fn shell(&self) -> quasi_webview::Shell { quasi_webview::Shell::under("/static") .layered(["base", "components", "responsive"]) // Every described write is an htmx request, and htmx inherits this // from the body, so one declaration covers the whole document. .sending("X-CSRF-Token", &self.csrf) } /// 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) => { // Before the handler runs, because the write that mints a // token has to finish on the session this request holds. // A failure here is the session store, not the reader. let csrf = crate::csrf::get_or_create_token(&session) .await .map_err(|_| quasi_router::RouteError::internal("csrf token"))?; // Same reason as `csrf`: an async read a sync handler // cannot do. Absent on a legacy session, which is a state // the screens describe rather than an error. let session_id = session .get::(crate::auth::SESSION_TRACKING_KEY) .await .ok() .flatten(); Ok(Viewer { app, user, runtime, csrf, session_id, 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, payout_summary::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, payout_summary::SCREEN, forum_memberships::LIBRARY_SCREEN, forum_memberships::SETTINGS_SCREEN, ]; /// Every described screen, mounted. /// /// Unconditional since `64b33b26`. Each entry used to be gated on /// `described(app, ..)` reading `QUASI_SCREENS`, so a screen could be switched /// off and its Askama rendering registered instead. There is no Askama /// rendering to fall back to any more, and the route tables no longer register /// one, so a screen missing from this list is an address nothing answers. pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { vec![ ( ssh_keys::PATH, mount(app, ssh_keys::screen, ssh_keys::WRITES, ssh_keys::renderer), ), ( 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. ( forum_memberships::LIBRARY_PATH, mount( app, forum_memberships::library_screen, &[], forum_memberships::renderer, ), ), ( buyer_contacts::PATH, mount(app, buyer_contacts::screen, &[], buyer_contacts::renderer), ), ( user_analytics::PATH, mount(app, user_analytics::screen, &[], user_analytics::renderer), ), ( payout_summary::PATH, mount(app, payout_summary::screen, &[], payout_summary::renderer), ), // A nest that answers no address of its own: the Members panel is read // through its Askama route, which keeps a conditional GET, and only its // writes are described. `03c0977b`; see `writes_only`. ( project_members::NEST, writes_only(app, project_members::WRITES, project_members::renderer), ), // The item dashboard's Sales panel: a parameterized read address, so // the read stays on Askama and only the Refund write is described. // `b25dd957`; see `writes_only`. ( item_sales::NEST, writes_only(app, item_sales::WRITES, item_sales::renderer), ), ( forum_memberships::SETTINGS_PATH, mount( app, forum_memberships::settings_screen, &[], forum_memberships::renderer, ), ), ] } /// 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 { nest(app, Some(screen), writes, renderer) } /// A nest that serves writes and answers no address of its own. /// /// `03c0977b`, ruled 2026-08-26 (Max), option (a). The hole it fills: a panel /// whose route answers a conditional GET cannot be a mounted screen, because /// [`mount`] has no way to say "304 if the cache generation has not moved". So /// it stays a fill on its Askama handler -- and a fill has no nest, so it had /// nowhere to put its writes, so its controls kept addressing API routes that /// answer 200 or 204 and cannot name the region they changed. The patch for /// that was `data-after`, the private dispatcher vocabulary in /// `frontend/src/core/dispatch.ts` that this conversion exists to retire. /// /// This is the other half of such a panel: the read keeps its Askama route and /// its ETag, and the writes get described routes that answer /// `Response::Fragment` naming the panel's region, exactly as a mounted /// screen's do. /// /// # The cost, stated once rather than per panel /// /// One panel is then served by two routers, and the read and the write are no /// longer visible in one place. That is the trade: the alternative was nine /// tabs converting their markup while keeping their JS, which lowers no seal /// and is not what S4 is for. /// /// # The address is a fixed prefix, and the ids go inside it /// /// A nest is mounted at a fixed path, which is also why `project_analytics` is /// a fill rather than a mounted screen. Path parameters live in the inner /// router, which does support them: `ssh_keys` already registers `/keys/{id}` /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids /// in the inner paths and does not reuse the API route's address. That API /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did /// when `ssh_keys` moved its controls off it. fn writes_only( app: &AppState, writes: &[(quasi_router::Method, &'static str, Screen)], renderer: fn(&Viewer) -> quasi_webview::Webview, ) -> axum::Router { nest(app, None, writes, renderer) } /// The router both of the above build, with or without a root GET. fn nest( app: &AppState, screen: Option, writes: &[(quasi_router::Method, &'static str, Screen)], renderer: fn(&Viewer) -> quasi_webview::Webview, ) -> axum::Router { let mut quasi = quasi_router::Router::::new(); if let Some(screen) = screen { quasi = quasi.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() } #[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; // Counted as `mount(` since `64b33b26`. `mounts` used to push // conditionally into a Vec, one `mounted.push((` per screen the switch // had on; it returns a `vec![..]` literal now because every screen is // mounted unconditionally, so the thing to count is the adapter call // each entry makes. Not `mount(app,`: rustfmt breaks the longer entries // across lines and that token then finds three of the six. let registered = mounted.matches("mount(").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:?}" ); } }