//! 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 axum::extract::FromRef; use crate::AppState; use crate::auth::SessionUser; pub mod auth_pages; pub mod buyer_contacts; pub mod cart_act; pub mod clip_acts; pub mod collections; pub mod creators; pub mod custom_page; pub mod discover_search; pub mod discover_typeahead; pub mod embeds; pub mod export_act; pub mod export_portal; pub mod fan_plus; pub mod feeds; pub mod follow; pub mod forum_memberships; pub mod git_explore; pub mod git_repos; pub mod item_files; pub mod item_sales; pub mod item_tabs; pub mod library_acts; pub mod library_contacts; pub mod library_tabs; pub mod license_key_act; pub mod link_remove_act; pub mod media_picker; pub mod payout_summary; pub mod policy; pub mod pricing; pub mod project; pub mod project_analytics; pub mod project_blog; pub mod project_content; pub mod project_members; pub mod project_overview; pub mod project_tabs; pub mod promo_code_acts; pub mod repo_acts; pub mod rich_field; pub mod schedule_field; pub mod session_acts; pub mod settings_tabs; pub mod shortcuts; pub mod ssh_keys; pub mod team; pub mod tip; pub mod upload_field; pub mod use_cases; pub mod user; 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, when anybody is. /// /// Resolved and revocation-checked by [`crate::auth::authenticate`], the /// same path the extractor takes. /// /// `None` only on a mount built with [`Audience::Anyone`], which is the /// public documents: a screen a reader can reach with no session, whose /// header and controls differ by whether one is held. Every other mount /// refuses before the handler runs, so a screen behind [`mount`], /// [`writes_only`] or [`document_mount`] can read it through /// [`reader`](Self::reader) and never see the refusal that method can /// return. /// /// The field is `pub` and the accessor exists beside it because both /// readings are legitimate: a gated screen wants the user and treats /// absence as impossible, and a public screen wants the option and treats /// absence as an ordinary state. pub user: Option, /// 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::Handover` 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) } /// Who is asking, on a mount that guarantees somebody is. /// /// The gated mounts resolve the session before the handler runs and refuse /// without one, so this cannot fail there. It returns a `Result` rather /// than unwrapping because that guarantee lives in the mount rather than in /// the type: if a screen is ever moved onto a public mount, the failure is /// a refusal the reader can read instead of a panic in a blocking thread. /// /// A screen that genuinely serves both audiences reads /// [`user`](Self::user) directly instead. pub fn reader(&self) -> Result<&SessionUser, quasi_router::RouteError> { self.user .as_ref() .ok_or_else(|| quasi_router::RouteError::denied("sign in to continue")) } /// 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) } /// The shell a described screen that owns its whole DOCUMENT is drawn in. /// /// [`shell`](Self::shell) is right for a fragment landing inside an Askama /// page, which already has the head, the tail and the token meta. A /// document owes all three itself: [`crate::shell::described`] is the same /// builder `base.html` renders through, [`crate::shell::body_last`] is the /// toast container and the classic shims, and the token meta is what the /// pre-module scripts read. /// /// The meta is not redundant with `Shell::sending`. That covers htmx, and /// `frontend/src/core/net.ts`, `frontend/src/core/htmx-glue.ts`, /// `static/passkey.js` and `static/project-sections.js` all read /// `meta[name=csrf-token]` instead. `/pricing` needed none of it because it /// holds no session. #[must_use] pub fn document_shell(&self) -> quasi_webview::Shell { crate::shell::described() .sending("X-CSRF-Token", &self.csrf) .with_body_last(crate::shell::body_last()) .with_chrome(crate::quasi::shortcuts::chrome()) .with_head(format!( "", crate::helpers::escape_html(&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() } } /// Who a mount is willing to answer. /// /// The signed-out question `b5cbb646` left open, answered here rather than by a /// second state type. Two mounts, one `Viewer`: what differs between a panel /// behind a login and a public page is whether a missing session ends the /// request, and that is one branch in the factory rather than a parallel /// hierarchy of states, factories and renderer signatures. #[derive(Clone, Copy, PartialEq, Eq)] enum Audience { /// A session is required, and its absence ends the request. /// /// Every panel and every document behind a login. The screens built on this /// read [`Viewer::reader`] and never see it fail. Reader, /// A session is read when there is one, and its absence is an ordinary /// state the screen describes. /// /// The public documents: `/team` and the rest of the `pages/` screens that /// read the same to a visitor and to a reader, and differ only in the /// header they carry. A screen here still gets a CSRF token, because the /// header's own controls post. Anyone, } /// Build the state factory the adapter calls per request. /// /// On [`Audience::Reader`] it 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 there 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. /// /// On [`Audience::Anyone`] a failed `authenticate` is not a refusal, it is a /// visitor: the viewer is built with `user: None` and the request goes on. The /// CSRF token is minted either way, since it is the session's rather than the /// user's and a sessionless form still needs one. /// /// The absent session layer stays an internal error on both, because that is /// wiring rather than an audience. fn viewer_factory( app: AppState, audience: Audience, ) -> 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")); }; let user = match crate::auth::authenticate(&session, &app).await { Ok(user) => Some(user), Err(_) if audience == Audience::Anyone => None, Err(_) => { return Err(quasi_router::RouteError::denied("sign in to continue")); } }; // 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(), }) }) } } /// 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 described screen that owns its whole document, with the address it /// answers. /// /// Its own list rather than an entry in [`PATHS`], for the same reason /// [`public_mounts`] keeps its own: what a nest answers with decides what a /// test can assert about it. A panel screen answers `Response::Fragment` and /// names the region it changed, which `tests/workflows/described_screens.rs` /// checks by reading `HX-Retarget` off every entry in [`PATHS`]. A document /// answers `Outcome::Screen`, which sets no such header and is not a defect. /// /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen /// registers no mutating route, so it has nothing to skip here either. pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH, export_portal::PATH]; /// Every described document a reader with no session can reach, with the /// address it answers. /// /// Its own list beside [`DOCUMENT_PATHS`] for the same reason that one sits /// beside [`PATHS`]: what a mount answers with decides what a test can assert /// about it. These answer `Outcome::Screen` like the gated documents, and /// differ in that a signed-out request is a render rather than a 401, which is /// what `tests/workflows/pages.rs` presses them for. /// /// Not in [`public_mounts`], which is the other public list and a different /// mechanism: those screens resolve nothing per request and take a state built /// once at startup. These resolve a session when there is one, so they carry a /// per-request viewer and can mint a CSRF token for the form on them. pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[ team::PATH, use_cases::PATH, policy::PATH, fan_plus::PATH, creators::PATH, collections::PATH, git_explore::PATH, git_repos::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, ), ), ] } /// Every described screen that owns its whole document, mounted. /// /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`]. pub fn document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { vec![ ( feeds::PATH, document_mount(app, feeds::PATH, feeds::screen, feeds::renderer), ), ( export_portal::PATH, document_mount( app, export_portal::PATH, export_portal::screen, export_portal::renderer, ), ), ] } /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches. /// /// The difference is what a signed-out reader gets. [`viewer_factory`] refuses /// with `denied` and quasi-axum answers that as a bare 403 with no body: right /// for a panel fetched by a page that already checked, wrong for an address a /// person can type. `tests/workflows/pages.rs::unauthorized_page_offers_login_and_signup` /// is the shipped rule, so the gate runs [`crate::auth::authenticate`] in front /// and answers whatever that refuses with, which renders the branded 401 with /// its way back in. It is the same call the `AuthUser` extractor makes, so the /// two paths cannot disagree about who is signed in. /// /// It costs one extra session read on this nest: the gate resolves the session /// and the factory resolves it again. Stated rather than optimised, because the /// alternative is a viewer whose `user` is optional, which is the signed-out /// question the feed conversion deliberately did not answer. /// /// # The address is registered whole, not as `/` /// /// [`mount`]'s nests are mounted with `nest_service`, which strips the prefix /// before the adapter sees the request. A document screen is mounted with /// `CsrfRouter::route_service` instead (see there for why), which strips /// nothing, so the router inside answers the address the reader typed. fn document_mount( app: &AppState, path: &'static str, screen: Screen, renderer: fn(&Viewer) -> quasi_webview::Webview, ) -> axum::Router { let router = quasi_router::Router::::new().get(path, screen); quasi_axum::Adapter::per_viewer( router, viewer_factory(app.clone(), Audience::Reader), move |viewer, _, _| renderer(viewer), ) .into_router() .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in)) } /// The gate in front of every document nest: a reader or a branded refusal. async fn signed_in( axum::extract::State(app): axum::extract::State, request: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { use axum::response::IntoResponse as _; let Some(session) = request .extensions() .get::() .cloned() else { // The session layer runs in front of this. Its absence is wiring. return crate::error::AppError::Internal(anyhow::anyhow!("no session layer")) .into_response(); }; match crate::auth::authenticate(&session, &app).await { Ok(_) => next.run(request).await, Err(refusal) => refusal.into_response(), } } /// Every described document a reader with no session can reach, mounted. /// /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in /// [`document_mounts`] nor in [`public_mounts`]. pub fn public_document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { vec![ ( team::PATH, public_document_mount(app, team::PATH, team::screen, team::renderer), ), ( use_cases::PATH, public_document_mount(app, use_cases::PATH, use_cases::screen, use_cases::renderer), ), ( policy::PATH, public_document_mount(app, policy::PATH, policy::screen, policy::renderer), ), ( fan_plus::PATH, public_document_mount(app, fan_plus::PATH, fan_plus::screen, fan_plus::renderer), ), ( creators::PATH, public_document_mount(app, creators::PATH, creators::screen, creators::renderer), ), ( collections::PATH, public_document_mount( app, collections::PATH, collections::screen, collections::renderer, ), ), // The git browse tree carries a per-IP cap on every read, and a // described document taking one of its addresses has to carry it too: // these routes walk bare repositories on disk. The mount is an // `axum::Router`, so the layer goes on here rather than through a // parameter, and the limiter is rebuilt from the same constants // `routes::git` reads. See `git_repos`'s module header. Both git // listings mount here, and a module that is declared but not mounted is // an address nothing answers: `git_explore` shipped that way and its own // tests did not run either, because an undeclared module is not compiled. ( git_explore::PATH, public_document_mount( app, git_explore::PATH, git_explore::screen, git_explore::renderer, ) .layer(tower_governor::GovernorLayer::new( crate::helpers::rate_limiter_ms( crate::constants::GIT_BROWSE_RATE_LIMIT_MS, crate::constants::GIT_BROWSE_RATE_LIMIT_BURST, ), )), ), ( git_repos::PATH, public_document_mount(app, git_repos::PATH, git_repos::screen, git_repos::renderer) .layer(tower_governor::GovernorLayer::new( crate::helpers::rate_limiter_ms( crate::constants::GIT_BROWSE_RATE_LIMIT_MS, crate::constants::GIT_BROWSE_RATE_LIMIT_BURST, ), )), ), ] } /// A described document a reader NAVIGATES to with or without a session. /// /// [`document_mount`] with the two things that make it gated removed: the /// factory is built on [`Audience::Anyone`], so a signed-out request builds a /// viewer rather than being refused, and there is no [`signed_in`] layer in /// front of it, so nothing turns that into a 401. What is left is identical, /// including the exact-address registration -- see [`document_mount`] for why a /// document is not mounted as a nest. /// /// The screens here are the sessionless pages: the ones whose whole purpose is /// to be reachable by somebody who cannot sign in. A page that merely *reads* /// better when signed in is still gated; the test is whether refusing a visitor /// is the right answer. /// /// # Layers go on the returned router, at the call site /// /// This returns an `axum::Router`, so a mount that needs middleware takes it /// with `.layer(..)` where it is registered rather than through a parameter /// here. That matters because the middleware is per-address rather than per /// mount kind: the git browse addresses carry a per-IP cap and the marketing /// pages carry none, and threading an `Option` through every call would /// make the mount know about a policy that belongs to the route. /// /// **Check what the address carried before moving it.** A route lifted out of a /// router with a `route_layer` silently loses that layer, and for the git tree /// that would be a rate limit removed from a route that walks repositories on /// disk. See `git_repos`. fn public_document_mount( app: &AppState, path: &'static str, screen: Screen, renderer: fn(&Viewer) -> quasi_webview::Webview, ) -> axum::Router { let router = quasi_router::Router::::new().get(path, screen); quasi_axum::Adapter::per_viewer( router, viewer_factory(app.clone(), Audience::Anyone), move |viewer, _, _| renderer(viewer), ) .into_router() } /// Every described screen a reader with no session can reach, mounted. /// /// Separate from [`mounts`] because of the factory, not because of the address: /// [`viewer_factory`] resolves a session and refuses without one, which is the /// right answer for every screen behind a login and the wrong one for a /// marketing page. A public screen takes a state resolved once at startup, so /// its adapter is `Adapter::new` rather than `Adapter::per_viewer`. /// /// Deliberately not in [`PATHS`]. That list is what the CSRF probe skips and /// what `tests/workflows/described_screens.rs` presses with a signed-in /// fixture, and both readings are about screens that hold a session. A public /// screen registers no mutating route, so the probe has nothing to skip. pub fn public_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { vec![ (pricing::PATH, pricing_mount(app)), (shortcuts::PATH, shortcuts_mount()), ] } /// The keyboard-shortcuts overlay, on the same shell every described screen /// gets so the listing looks like the site it is drawn over. /// /// Its own mount rather than a route inside the pricing nest: an overlay /// reachable from every screen is not one screen's route, and the binding's /// address is absolute in the emitted markup (`e0c0d991`). fn shortcuts_mount() -> axum::Router { quasi_axum::Adapter::new( shortcuts::router(), std::sync::Arc::new(()), std::sync::Arc::new(pricing::renderer()), ) .into_router() } /// The fee calculator's own nest: the page and the recompute it answers. fn pricing_mount(app: &AppState) -> axum::Router { let state = std::sync::Arc::new(pricing::Pricing { billing: crate::Billing::from_ref(app), founder_window_open: app.config.creator_pricing.founder_window_open, changelog_published: crate::changelog::is_published(), }); let router = quasi_router::Router::::new() .get("/", pricing::screen) .get("/compare", pricing::compare); quasi_axum::Adapter::new(router, state, std::sync::Arc::new(pricing::renderer())).into_router() } /// This server's own copy, as markdown. /// /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so its /// links carry `nofollow`, its raw markup is dropped and fetchable schemes are /// filtered. That is right for a forum post and wrong for a page's own /// sentence, and until quasi grew the trust axis every described page here was /// telling crawlers not to follow its own links (quasicoherent `24a3b1df`). /// /// The two axes stay separate in quasi -- `Richness` is what the format may /// express, `Trust` is who wrote it -- and this is the one combination this /// server reaches for often enough to name: a sentence, ours. A screen wanting /// tables says so with `Node::richness`, and a screen carrying a reader's /// markdown keeps `Node::rich`. /// /// **The test for using it is authorship, not tidiness.** The string has to be /// a literal in this repository, or interpolated from a value that cannot carry /// markup. A creator's description reaching a screen through the database is /// `Node::rich` however well-behaved it has been. #[must_use] pub fn own_prose(source: impl Into) -> quasi_router::Node { quasi_router::Node::rich(source).trust(quasi_router::Trust::Trusted) } /// 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 stays on /// screen after a successful revoke. Neither is visible to a test that checks /// only that 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. /// /// 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(), Audience::Reader), 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 every_document_screen_is_listed_in_document_paths() { let source = include_str!("mod.rs"); let mounted = source .split_once("pub fn document_mounts(") .expect("document_mounts exists") .1 .split_once("\n}") .expect("document_mounts ends") .0; assert_eq!( mounted.matches("document_mount(").count(), DOCUMENT_PATHS.len(), "document_mounts and DOCUMENT_PATHS describe the same screens" ); } #[test] fn every_public_document_is_listed_in_public_document_paths() { let source = include_str!("mod.rs"); let mounted = source .split_once("pub fn public_document_mounts(") .expect("public_document_mounts exists") .1 .split_once("\n}") .expect("public_document_mounts ends") .0; assert_eq!( mounted.matches("public_document_mount(").count(), PUBLIC_DOCUMENT_PATHS.len(), "public_document_mounts and PUBLIC_DOCUMENT_PATHS describe the same screens" ); } /// Every git address carries the browse limiter, checked in the source /// because there is nothing to ask an `axum::Router` about afterwards. /// /// This is the check the whole `/git` conversion turns on. The tree's reads /// sit under one `route_layer` in `routes::git`, so an address lifted out of /// it and mounted here loses that layer silently: nothing fails to compile, /// no test fails, and a route that walks bare repositories on disk stops /// being capped. A conversion that forgets it fails here instead. #[test] fn every_described_git_address_keeps_the_browse_limiter() { let source = include_str!("mod.rs"); let mounted = source .split_once("pub fn public_document_mounts(") .expect("public_document_mounts exists") .1 .split_once("\n}") .expect("public_document_mounts ends") .0; let git_paths = PUBLIC_DOCUMENT_PATHS .iter() .filter(|path| path.starts_with("/git")) .count(); assert!(git_paths > 0, "no git document is mounted yet"); assert_eq!( mounted.matches("GIT_BROWSE_RATE_LIMIT_MS").count(), git_paths, "a described /git address is mounted without the browse limiter" ); } /// The two document lists differ by exactly one thing, and it is the one /// that matters: a gated document refuses a visitor, a public one renders to /// them. Nothing else about the mount changes, so a screen in the wrong list /// is a page that 401s or a page that leaks, depending on the direction. #[test] fn no_document_is_in_both_lists() { for path in PUBLIC_DOCUMENT_PATHS { assert!( !DOCUMENT_PATHS.contains(path), "{path} is mounted both gated and public" ); } } #[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.extend_from_slice(DOCUMENT_PATHS); seen.extend_from_slice(PUBLIC_DOCUMENT_PATHS); seen.sort_unstable(); let before = seen.len(); seen.dedup(); assert_eq!( before, seen.len(), "two screens claim one address: {seen:?}" ); } }