Skip to main content

max / makenotwork

64.3 KB · 1528 lines History Blame Raw
1 //! The description layer: screens served through quasi rather than Askama.
2 //!
3 //! Wiki note `mnw-server-conversion-plan`, step S3 onward. `crate::shell`
4 //! already put the document head under quasi's renderer (S1); this is where
5 //! screens themselves start moving.
6 //!
7 //! Each of the seven converted screens shipped one at a time behind a
8 //! `QUASI_SCREENS` switch, so a conversion went out when its own test was green
9 //! and reverted by editing an env var. All seven are flipped and the switch is
10 //! deleted (`64b33b26`): [`mounts`] is unconditional, the route tables register
11 //! no Askama counterpart, and there is nothing left to revert to. A screen added
12 //! here is live the moment it is mounted.
13 //!
14 //! # Why a per-request state and not a per-request handler argument
15 //!
16 //! `quasi_router::Handler<S> = fn(&S, Request)`. `S` is the app, and on every
17 //! other host in the tree there is one app and one viewer for the life of the
18 //! process. A server has one process and many viewers, and that is the only
19 //! assumption it breaks, so the fix is that the adapter builds `S` per request
20 //! rather than that the handler grows a parameter. `Adapter::per_viewer` is
21 //! that: the factory runs in async context, where the session lookup already
22 //! lives, and hands the sync router a [`Viewer`] with the answer already in it.
23 //!
24 //! # The cost this exists to measure
25 //!
26 //! The router is sync (quasi's decision 6, taken for hosts with no runtime), so
27 //! quasi-axum dispatches on `spawn_blocking` and a handler reaching sqlx does it
28 //! through `Handle::block_on`. Every described request therefore holds a
29 //! blocking-pool thread for the length of its database round trips. That is
30 //! fine on a desktop app over rusqlite and is an open question on the one host
31 //! in the tree with many concurrent readers. It is not arguable, only
32 //! measurable: see `tests/load` and the S3 numbers in the wiki note.
33
34 use quasi_declare::declare;
35 use tokio::runtime::Handle;
36
37 use axum::extract::FromRef;
38
39 use crate::AppState;
40 use crate::auth::SessionUser;
41 pub mod auth_pages;
42
43 pub mod buyer_contacts;
44 pub mod cart_act;
45 pub mod clip_acts;
46 pub mod collections;
47 pub mod creators;
48 pub mod custom_page;
49 pub mod discover_search;
50 pub mod discover_typeahead;
51 pub mod embeds;
52 pub mod export_act;
53 pub mod export_portal;
54 pub mod fan_plus;
55 pub mod feeds;
56 pub mod follow;
57 pub mod forum_memberships;
58 pub mod git_blame;
59 pub mod git_browse;
60 pub mod git_commit;
61 pub mod git_explore;
62 pub mod git_repos;
63 pub mod item_files;
64 pub mod item_sales;
65 pub mod item_tabs;
66 pub mod library_acts;
67 pub mod library_contacts;
68 pub mod library_tabs;
69 pub mod license_key_act;
70 pub mod link_remove_act;
71 pub mod literal;
72 pub mod media_picker;
73 pub mod payout_summary;
74 pub mod policy;
75 pub mod pricing;
76 pub mod project;
77 pub mod project_analytics;
78 pub mod project_blog;
79 pub mod project_content;
80 pub mod project_members;
81 pub mod project_overview;
82 pub mod project_tabs;
83 pub mod promo_code_acts;
84 pub mod repo_acts;
85 pub mod residuals;
86 pub mod rich_field;
87 pub mod schedule_field;
88 pub mod session_acts;
89 pub mod settings_tabs;
90 pub mod shortcuts;
91 pub mod ssh_keys;
92 pub mod team;
93 pub mod tip;
94 pub mod upload_field;
95 pub mod use_cases;
96 pub mod user;
97 pub mod user_analytics;
98 pub mod user_projects;
99 pub mod user_support;
100 pub mod user_tabs;
101 pub mod version_delete_act;
102 pub mod widgets;
103
104 /// The state one request is answered against.
105 ///
106 /// Built per request by the adapter's factory, which is the whole of quasi's
107 /// answer to a server: everything resolvable from the request head is loaded
108 /// before the sync router runs, and a handler reads it off `&S` the way every
109 /// other host's handler reads its app.
110 pub struct Viewer {
111 /// The long-lived application state. Cloning it clones handles, not data.
112 pub app: AppState,
113 /// Who is asking, when anybody is.
114 ///
115 /// Resolved and revocation-checked by [`crate::auth::authenticate`], the
116 /// same path the extractor takes.
117 ///
118 /// `None` only on a mount built with [`Audience::Anyone`], which is the
119 /// public documents: a screen a reader can reach with no session, whose
120 /// header and controls differ by whether one is held. Every other mount
121 /// refuses before the handler runs, so a screen behind [`mount`],
122 /// [`writes_only`] or a served document mount can read it through
123 /// [`reader`](Self::reader) and never see the refusal that method can
124 /// return.
125 ///
126 /// The field is `pub` and the accessor exists beside it because both
127 /// readings are legitimate: a gated screen wants the user and treats
128 /// absence as impossible, and a public screen wants the option and treats
129 /// absence as an ordinary state.
130 pub user: Option<SessionUser>,
131 /// The runtime the request arrived on, so a sync handler can reach the
132 /// async database. Captured in the factory rather than read inside the
133 /// handler: `Handle::current` works on a blocking thread today, and
134 /// depending on that is depending on where quasi-axum happens to dispatch.
135 pub runtime: Handle,
136 /// This session's CSRF token, for the shell to hand to the document.
137 ///
138 /// Resolved in the factory rather than in a renderer because minting one is
139 /// an async session write and a renderer is sync. It is the same token the
140 /// Askama pages carry: [`crate::csrf::get_or_create_token`] is
141 /// get-or-create, so a described page and a templated one in the same
142 /// session agree, and validation is one comparison either way.
143 pub csrf: String,
144 /// This request's session-tracking id, when it has one.
145 ///
146 /// The same class of fact as [`csrf`](Self::csrf) and resolved the same
147 /// way: reading it is an async session lookup, so the factory does it and
148 /// the sync handler reads the answer off `&S`. It is what lets a screen
149 /// tell the reader's own row apart from the rest, which
150 /// `user_sessions` needs twice over: the `Current` badge, and the one row
151 /// that offers no `Sign out`.
152 ///
153 /// `None` is a real state rather than a failure. A session predating
154 /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a
155 /// screen answering for one marks no row as current.
156 pub session_id: Option<crate::db::UserSessionId>,
157 }
158
159 impl Viewer {
160 /// Run a database future from inside a sync handler.
161 ///
162 /// The blocking hop, named in one place so the thing being measured is
163 /// countable rather than spread across every handler. Every call holds this
164 /// blocking thread until the query answers.
165 pub fn block_on<F: Future>(&self, future: F) -> F::Output {
166 self.runtime.block_on(future)
167 }
168
169 /// Who is asking, on a mount that guarantees somebody is.
170 ///
171 /// The gated mounts resolve the session before the handler runs and refuse
172 /// without one, so this cannot fail there. It returns a `Result` rather
173 /// than unwrapping because that guarantee lives in the mount rather than in
174 /// the type: if a screen is ever moved onto a public mount, the failure is
175 /// a refusal the reader can read instead of a panic in a blocking thread.
176 ///
177 /// A screen that genuinely serves both audiences reads
178 /// [`user`](Self::user) directly instead.
179 pub fn reader(&self) -> Result<&SessionUser, quasi_router::RouteError> {
180 self.user
181 .as_ref()
182 .ok_or_else(|| quasi_router::RouteError::denied("sign in to continue"))
183 }
184
185 /// The shell every described screen is drawn in.
186 ///
187 /// Here rather than in each screen's `renderer` because of what it carries:
188 /// a described page whose shell does not declare the session token has
189 /// every write on it refused, and the five screens that wrote
190 /// `Shell::under("/static")` out by hand were five chances to forget. A
191 /// screen that wants more says so on top of this; a screen that says
192 /// nothing gets the token anyway.
193 ///
194 /// The layer order matches `crate::shell`, which is the Askama half of the
195 /// same document: `makeover` is prepended by the renderer, and the site's
196 /// own sheets live in `components`.
197 #[must_use]
198 pub fn shell(&self) -> quasi_webview::Shell {
199 quasi_webview::Shell::under("/static")
200 .layered(["base", "components", "responsive"])
201 // Every described write is an htmx request, and htmx inherits this
202 // from the body, so one declaration covers the whole document.
203 .sending("X-CSRF-Token", &self.csrf)
204 }
205
206 /// The shell a described screen that owns its whole DOCUMENT is drawn in.
207 ///
208 /// [`shell`](Self::shell) is right for a fragment landing inside an Askama
209 /// page, which already has the head, the tail and the token meta. A
210 /// document owes all three itself: [`crate::shell::described`] is the same
211 /// builder `base.html` renders through, [`crate::shell::body_last`] is the
212 /// toast container and the classic shims, and the token meta is what the
213 /// pre-module scripts read.
214 ///
215 /// The meta is not redundant with `Shell::sending`. That covers htmx, and
216 /// `frontend/src/core/net.ts`, `frontend/src/core/htmx-glue.ts`,
217 /// `static/passkey.js` and `static/project-sections.js` all read
218 /// `meta[name=csrf-token]` instead. `/pricing` needed none of it because it
219 /// holds no session.
220 #[must_use]
221 pub fn document_shell(&self) -> quasi_webview::Shell {
222 document_shell(&self.csrf)
223 }
224 }
225
226 /// The shell a described document is drawn in, by the token it carries.
227 ///
228 /// [`Viewer::document_shell`] is this with the token already in hand. It is a
229 /// free function as well because a screen whose address carries a wildcard
230 /// segment cannot be a quasi route at all -- `quasi_router`'s matcher takes
231 /// `{name}` and nothing else -- so the git file and blame views are built here
232 /// and served from their own axum handlers, which hold a token and no
233 /// [`Viewer`].
234 #[must_use]
235 pub fn document_shell(csrf: &str) -> quasi_webview::Shell {
236 crate::shell::described()
237 .sending("X-CSRF-Token", csrf)
238 .with_body_last(crate::shell::body_last())
239 .with_chrome(crate::quasi::shortcuts::chrome())
240 .with_head(format!(
241 "<meta name=\"csrf-token\" content=\"{}\">",
242 crate::helpers::escape_html(csrf)
243 ))
244 }
245
246 /// Who a mount is willing to answer.
247 ///
248 /// The signed-out question `b5cbb646` left open, answered here rather than by a
249 /// second state type. Two mounts, one `Viewer`: what differs between a panel
250 /// behind a login and a public page is whether a missing session ends the
251 /// request, and that is one branch in the factory rather than a parallel
252 /// hierarchy of states, factories and renderer signatures.
253 #[derive(Clone, Copy, PartialEq, Eq)]
254 enum Audience {
255 /// A session is required, and its absence ends the request.
256 ///
257 /// Every panel and every document behind a login. The screens built on this
258 /// read [`Viewer::reader`] and never see it fail.
259 Reader,
260 /// A session is read when there is one, and its absence is an ordinary
261 /// state the screen describes.
262 ///
263 /// The public documents: `/team` and the rest of the `pages/` screens that
264 /// read the same to a visitor and to a reader, and differ only in the
265 /// header they carry. A screen here still gets a CSRF token, because the
266 /// header's own controls post.
267 Anyone,
268 }
269
270 /// Build the state factory the adapter calls per request.
271 ///
272 /// On [`Audience::Reader`] it refuses with `Unauthorized` when there is no
273 /// session to resolve, which the adapter turns into a bare status with no body.
274 /// That is the right shape there and not a shortcut: a store that will not
275 /// answer is not a signed-out reader, and rendering a sign-in notice would need
276 /// the renderer that is built from the state that could not be resolved.
277 ///
278 /// On [`Audience::Anyone`] a failed `authenticate` is not a refusal, it is a
279 /// visitor: the viewer is built with `user: None` and the request goes on. The
280 /// CSRF token is minted either way, since it is the session's rather than the
281 /// user's and a sessionless form still needs one.
282 ///
283 /// The absent session layer stays an internal error on both, because that is
284 /// wiring rather than an audience.
285 fn viewer_factory(
286 app: AppState,
287 audience: Audience,
288 ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static {
289 move |parts| {
290 let app = app.clone();
291 let runtime = Handle::current();
292 // Taken out of the head first, so the future owns what it needs.
293 let session = parts.extensions.get::<tower_sessions::Session>().cloned();
294 Box::pin(async move {
295 let Some(session) = session else {
296 // The session layer runs in front of this. Its absence is a
297 // wiring mistake rather than a signed-out reader.
298 return Err(quasi_router::RouteError::internal("no session layer"));
299 };
300 let user = match crate::auth::authenticate(&session, &app).await {
301 Ok(user) => Some(user),
302 Err(_) if audience == Audience::Anyone => None,
303 Err(_) => {
304 return Err(quasi_router::RouteError::denied("sign in to continue"));
305 }
306 };
307 // Before the handler runs, because the write that mints a token has
308 // to finish on the session this request holds. A failure here is
309 // the session store, not the reader.
310 let csrf = crate::csrf::get_or_create_token(&session)
311 .await
312 .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
313 // Same reason as `csrf`: an async read a sync handler cannot do.
314 // Absent on a legacy session, which is a state the screens describe
315 // rather than an error.
316 let session_id = session
317 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
318 .await
319 .ok()
320 .flatten();
321 Ok(Viewer {
322 app,
323 user,
324 runtime,
325 csrf,
326 session_id,
327 })
328 })
329 }
330 }
331
332 /// Every converted screen that is switched on, with the address it answers.
333 ///
334 /// One mount per screen rather than one router for all of them, because axum
335 /// strips a nest's prefix before the inner service sees the request: a single
336 /// nest covering both would have to sit at a prefix the Askama routes also live
337 /// under, and matchit refuses to hold a wildcard beside the parameterised routes
338 /// already there. Measured, not assumed: nesting at `/dashboard/project` panics
339 /// at startup against `/dashboard/project/{slug}/tabs/overview`.
340 ///
341 /// The list is empty when nothing is switched on, so the caller registers its
342 /// Askama routes exactly as before and the adapter is not in the stack at all. A
343 /// conversion is a startup-time choice: config is read once, and a per-request
344 /// branch would pay for a switch that never moves.
345 /// Every address a described screen can claim, switched on or not.
346 ///
347 /// `mounts` returns only what is currently on, which depends on config. This is
348 /// the whole set, and it exists for the CSRF coverage test: the manifest that
349 /// test reads is a process-global, so a test that switches a screen on leaves an
350 /// entry behind for a path the default router does not serve, and the probe
351 /// reads that as a route that lost its protection. The list lets it skip exactly
352 /// those and nothing else.
353 ///
354 /// Checked against `mounts` below rather than trusted, since a screen added to
355 /// one and not the other is the obvious way for this to rot.
356 pub const PATHS: &[&str] = &[
357 ssh_keys::PATH,
358 library_contacts::PATH,
359 buyer_contacts::PATH,
360 user_analytics::PATH,
361 payout_summary::PATH,
362 forum_memberships::LIBRARY_PATH,
363 forum_memberships::SETTINGS_PATH,
364 ];
365
366 /// Every described screen that owns its whole document, with the address it
367 /// answers.
368 ///
369 /// Its own list rather than an entry in [`PATHS`], for the same reason
370 /// [`public_mounts`] keeps its own: what a nest answers with decides what a
371 /// test can assert about it. A panel screen answers `Response::Fragment` and
372 /// names the region it changed, which `tests/workflows/described_screens.rs`
373 /// checks by reading `HX-Retarget` off every entry in [`PATHS`]. A document
374 /// answers `Outcome::Screen`, which sets no such header and is not a defect.
375 ///
376 /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen
377 /// registers no mutating route, so it has nothing to skip here either.
378 pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH, export_portal::PATH];
379
380 /// Every described document a reader with no session can reach, with the
381 /// address it answers.
382 ///
383 /// Its own list beside [`DOCUMENT_PATHS`] for the same reason that one sits
384 /// beside [`PATHS`]: what a mount answers with decides what a test can assert
385 /// about it. These answer `Outcome::Screen` like the gated documents, and
386 /// differ in that a signed-out request is a render rather than a 401, which is
387 /// what `tests/workflows/pages.rs` presses them for.
388 ///
389 /// Not in [`public_mounts`], which is the other public list and a different
390 /// mechanism: those screens resolve nothing per request and take a state built
391 /// once at startup. These resolve a session when there is one, so they carry a
392 /// per-request viewer and can mint a CSRF token for the form on them.
393 pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[
394 team::PATH,
395 use_cases::PATH,
396 policy::PATH,
397 fan_plus::PATH,
398 creators::PATH,
399 collections::PATH,
400 git_explore::PATH,
401 git_repos::PATH,
402 ];
403
404 /// Every screen's switch name, in the same order as [`PATHS`].
405 ///
406 /// Test-only: the switches themselves are read from each screen's own `SCREEN`
407 /// in `mounts`, and this exists so the consistency check below has both halves
408 /// to compare. Kept beside `PATHS` rather than inside the test module, because
409 /// the pairing is the thing being asserted and splitting them is how they drift.
410 #[cfg(test)]
411 const SCREENS: &[&str] = &[
412 ssh_keys::SCREEN,
413 library_contacts::SCREEN,
414 buyer_contacts::SCREEN,
415 user_analytics::SCREEN,
416 payout_summary::SCREEN,
417 forum_memberships::LIBRARY_SCREEN,
418 forum_memberships::SETTINGS_SCREEN,
419 ];
420
421 /// One window an analytics panel offers.
422 ///
423 /// Named members rather than a tuple, for `policy`'s reason: a description names
424 /// what it draws, and `.1` is not a name.
425 pub(super) struct Range {
426 /// What the address carries.
427 pub value: &'static str,
428 /// What the heading calls it.
429 pub label: &'static str,
430 }
431
432 /// The four windows, and what each is called.
433 ///
434 /// Shared by `project_analytics` and `user_analytics`, which draw the same
435 /// selector against different addresses. It was duplicated between them, along
436 /// with `Range`, `range_heading`, `is_shown` and a `chip` supplier each; the
437 /// supplier went when `chip` became a node member (quasicoherent `e2030032`)
438 /// and the rest is here.
439 pub(super) const RANGES: &[Range] = &[
440 Range {
441 value: "7d",
442 label: "Last 7 days",
443 },
444 Range {
445 value: "30d",
446 label: "Last 30 days",
447 },
448 Range {
449 value: "90d",
450 label: "Last 90 days",
451 },
452 Range {
453 value: "all",
454 label: "All time",
455 },
456 ];
457
458 /// What the current range is called.
459 pub(super) fn range_heading(range: &str) -> &'static str {
460 RANGES
461 .iter()
462 .find(|window| window.value == range)
463 .map_or("All time", |window| window.label)
464 }
465
466 /// Whether this window is the one being shown.
467 ///
468 /// A supplier because a comparison is an expression and the form admits none in
469 /// an argument. It hands back a `bool`, which is the smallest type that works.
470 pub(super) fn is_shown(window: &Range, range: &str) -> bool {
471 window.value == range
472 }
473
474 /// Every described screen, mounted.
475 ///
476 /// Unconditional since `64b33b26`. Each entry used to be gated on
477 /// `described(app, ..)` reading `QUASI_SCREENS`, so a screen could be switched
478 /// off and its Askama rendering registered instead. There is no Askama
479 /// rendering to fall back to any more, and the route tables no longer register
480 /// one, so a screen missing from this list is an address nothing answers.
481 pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
482 vec![
483 (
484 ssh_keys::PATH,
485 served_panel_mount(
486 app,
487 ssh_keys::REGION,
488 ssh_keys::WRITES,
489 ssh_keys::renderer,
490 |viewer, _| {
491 let (username, keys, tokens, themes) = ssh_keys::reading(viewer)?;
492 Ok(ssh_keys::pane_serve(
493 &residuals::SSH_KEYS,
494 &username,
495 &keys,
496 &tokens,
497 &themes,
498 ))
499 },
500 ),
501 ),
502 (
503 library_contacts::PATH,
504 served_panel_mount(
505 app,
506 library_contacts::REGION,
507 library_contacts::WRITES,
508 library_contacts::renderer,
509 |viewer, _| {
510 let (buyers, shared) = library_contacts::reading(viewer)?;
511 Ok(library_contacts::pane_serve(
512 &residuals::LIBRARY_CONTACTS,
513 &buyers,
514 &shared,
515 ))
516 },
517 ),
518 ),
519 // One module, two screens: the library tab and the settings section are
520 // the same table under different chrome.
521 (
522 forum_memberships::LIBRARY_PATH,
523 served_panel_mount(
524 app,
525 forum_memberships::LIBRARY_REGION,
526 &[],
527 forum_memberships::renderer,
528 |viewer, _| {
529 let (memberships, base) = forum_memberships::reading(viewer)?;
530 Ok(forum_memberships::library_pane_serve(
531 &residuals::FORUMS_LIBRARY,
532 &memberships,
533 &base,
534 ))
535 },
536 ),
537 ),
538 (
539 buyer_contacts::PATH,
540 served_panel_mount(
541 app,
542 buyer_contacts::REGION,
543 &[],
544 buyer_contacts::renderer,
545 |viewer, _| {
546 Ok(buyer_contacts::pane_serve(
547 &residuals::BUYER_CONTACTS,
548 &buyer_contacts::reading(viewer)?,
549 ))
550 },
551 ),
552 ),
553 (
554 user_analytics::PATH,
555 mount(app, user_analytics::screen, &[], user_analytics::renderer),
556 ),
557 (
558 payout_summary::PATH,
559 served_panel_mount(
560 app,
561 payout_summary::REGION,
562 &[],
563 payout_summary::renderer,
564 |viewer, _| {
565 let (balance, payouts_enabled) = payout_summary::reading(viewer)?;
566 Ok(payout_summary::card_serve(
567 &residuals::PAYOUT_SUMMARY,
568 balance.as_ref(),
569 payouts_enabled,
570 ))
571 },
572 ),
573 ),
574 // A nest that answers no address of its own: the Members panel is read
575 // through its Askama route, which keeps a conditional GET, and only its
576 // writes are described. `03c0977b`; see `writes_only`.
577 (
578 project_members::NEST,
579 writes_only(app, project_members::WRITES, project_members::renderer),
580 ),
581 // The item dashboard's Sales panel: a parameterized read address, so
582 // the read stays on Askama and only the Refund write is described.
583 // `b25dd957`; see `writes_only`.
584 (
585 item_sales::NEST,
586 writes_only(app, item_sales::WRITES, item_sales::renderer),
587 ),
588 (
589 forum_memberships::SETTINGS_PATH,
590 served_panel_mount(
591 app,
592 forum_memberships::SETTINGS_REGION,
593 &[],
594 forum_memberships::renderer,
595 |viewer, _| {
596 let (memberships, base) = forum_memberships::settings_reading(viewer)?;
597 Ok(forum_memberships::settings_pane_serve(
598 &residuals::FORUMS_SETTINGS,
599 &memberships,
600 &base,
601 ))
602 },
603 ),
604 ),
605 ]
606 }
607
608 /// Every described screen that owns its whole document, mounted.
609 ///
610 /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`].
611 pub fn document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
612 vec![
613 (
614 feeds::PATH,
615 served_gated_mount(app, feeds::PATH, feeds::renderer, |viewer, carried| {
616 // One read, borrowed twice: the document says which page it is
617 // and the markup fills from the same rows.
618 let loaded = feeds::reading(viewer, carried)?;
619 Ok(Served {
620 screen: feeds::page_screen(&loaded.page()),
621 markup: feeds::page_region_serve(&residuals::FEED, &loaded.page()).into(),
622 })
623 }),
624 ),
625 (
626 export_portal::PATH,
627 served_gated_mount(
628 app,
629 export_portal::PATH,
630 export_portal::renderer,
631 |viewer, _| {
632 let page = export_portal::reading(viewer)?;
633 Ok(Served {
634 screen: export_portal::page_screen(&page),
635 markup: export_portal::page_region_serve(&residuals::EXPORT_PORTAL, &page)
636 .into(),
637 })
638 },
639 ),
640 ),
641 ]
642 }
643
644 /// The gate in front of every document nest: a reader or a branded refusal.
645 async fn signed_in(
646 axum::extract::State(app): axum::extract::State<AppState>,
647 request: axum::extract::Request,
648 next: axum::middleware::Next,
649 ) -> axum::response::Response {
650 use axum::response::IntoResponse as _;
651
652 let Some(session) = request
653 .extensions()
654 .get::<tower_sessions::Session>()
655 .cloned()
656 else {
657 // The session layer runs in front of this. Its absence is wiring.
658 return crate::error::AppError::Internal(anyhow::anyhow!("no session layer"))
659 .into_response();
660 };
661 match crate::auth::authenticate(&session, &app).await {
662 Ok(_) => next.run(request).await,
663 Err(refusal) => refusal.into_response(),
664 }
665 }
666
667 /// Every described document a reader with no session can reach, mounted.
668 ///
669 /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in
670 /// [`document_mounts`] nor in [`public_mounts`].
671 pub fn public_document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
672 vec![
673 (
674 team::PATH,
675 served_document_mount(app, team::PATH, team::renderer, |_, _| {
676 Ok(Served {
677 screen: team::page_screen(),
678 markup: residuals::settled(&residuals::TEAM),
679 })
680 }),
681 ),
682 (
683 use_cases::PATH,
684 served_document_mount(app, use_cases::PATH, use_cases::renderer, |viewer, _| {
685 // One read of the prices, stating the document and filling the
686 // nine holes.
687 let prices = use_cases::prices(viewer);
688 Ok(Served {
689 screen: use_cases::page_screen(&prices),
690 markup: use_cases::page_region_serve(&residuals::USE_CASES, &prices).into(),
691 })
692 }),
693 ),
694 (
695 policy::PATH,
696 served_document_mount(app, policy::PATH, policy::renderer, |_, _| {
697 Ok(Served {
698 screen: policy::page_screen(),
699 markup: residuals::settled(&residuals::POLICY),
700 })
701 }),
702 ),
703 (
704 fan_plus::PATH,
705 served_document_mount(
706 app,
707 fan_plus::PATH,
708 fan_plus::renderer,
709 |viewer, carried| {
710 // One read of the membership, deciding both the document and
711 // which branches are filled. Read twice, the two could disagree
712 // and the page would state one reader and draw another.
713 let (standing, just_subscribed) = fan_plus::reading(viewer, carried)?;
714 Ok(Served {
715 screen: fan_plus::page_screen(&standing, just_subscribed),
716 markup: fan_plus::page_region_serve(
717 &residuals::FAN_PLUS,
718 &standing,
719 just_subscribed,
720 )
721 .into(),
722 })
723 },
724 ),
725 ),
726 (
727 creators::PATH,
728 served_document_mount(app, creators::PATH, creators::renderer, |viewer, _| {
729 // One read of the standing, the count and the prices, stating
730 // the document and filling the holes.
731 let (standing, total_creators, prices) = creators::reading(viewer)?;
732 Ok(Served {
733 screen: creators::page_screen(&standing, total_creators, &prices),
734 markup: creators::page_region_serve(
735 &residuals::CREATORS,
736 &standing,
737 total_creators,
738 &prices,
739 )
740 .into(),
741 })
742 }),
743 ),
744 (
745 collections::PATH,
746 served_document_mount(
747 app,
748 collections::PATH,
749 collections::renderer,
750 |viewer, carried| {
751 // The address's captures and the row they resolve to, read
752 // once. `Carried` carries them because a screen on the seam
753 // never reaches the adapter and so has no `Request`.
754 let loaded = collections::reading(viewer, carried)?;
755 Ok(Served {
756 screen: collections::page_screen(&loaded),
757 markup: collections::page_region_serve(&residuals::COLLECTIONS, &loaded)
758 .into(),
759 })
760 },
761 ),
762 ),
763 // The git browse tree carries a per-IP cap on every read, and a
764 // described document taking one of its addresses has to carry it too:
765 // these routes walk bare repositories on disk. The mount is an
766 // `axum::Router`, so the layer goes on here rather than through a
767 // parameter, and the limiter is rebuilt from the same constants
768 // `routes::git` reads. See `git_repos`'s module header. Both git
769 // listings mount here, and a module that is declared but not mounted is
770 // an address nothing answers: `git_explore` shipped that way and its own
771 // tests did not run either, because an undeclared module is not compiled.
772 (
773 git_explore::PATH,
774 served_document_mount(
775 app,
776 git_explore::PATH,
777 git_explore::renderer,
778 |viewer, carried| {
779 let loaded = git_explore::reading(viewer, carried)?;
780 Ok(Served {
781 screen: git_explore::page_screen(&loaded),
782 markup: git_explore::page_region_serve(&residuals::GIT_EXPLORE, &loaded)
783 .into(),
784 })
785 },
786 )
787 .layer(tower_governor::GovernorLayer::new(
788 crate::helpers::rate_limiter_ms(
789 crate::constants::GIT_BROWSE_RATE_LIMIT_MS,
790 crate::constants::GIT_BROWSE_RATE_LIMIT_BURST,
791 ),
792 )),
793 ),
794 // The first screen on the seam behind a rate limiter, and the layer
795 // goes on here exactly as it does for the adapted mounts beside it:
796 // `served_document_mount` answers an `axum::Router` too.
797 (
798 git_repos::PATH,
799 served_document_mount(
800 app,
801 git_repos::PATH,
802 git_repos::renderer,
803 |viewer, carried| {
804 let loaded = git_repos::reading(viewer, carried)?;
805 Ok(Served {
806 screen: git_repos::page_screen(&loaded),
807 markup: git_repos::page_region_serve(&residuals::GIT_REPOS, &loaded).into(),
808 })
809 },
810 )
811 .layer(tower_governor::GovernorLayer::new(
812 crate::helpers::rate_limiter_ms(
813 crate::constants::GIT_BROWSE_RATE_LIMIT_MS,
814 crate::constants::GIT_BROWSE_RATE_LIMIT_BURST,
815 ),
816 )),
817 ),
818 ]
819 }
820
821 /// A panel whose region is already written, mounted.
822 ///
823 /// The seam's mount for a screen in [`PATHS`], which is a different answer from
824 /// a document's: a panel is fetched by a page that already exists, so what it
825 /// returns is a fragment plus the `HX-Retarget` naming the region it changed.
826 /// There is no document, no shell and no `Screen` -- which is why this is not
827 /// [`served_document_mount`] with a flag.
828 ///
829 /// # The nest is still the adapter's, and the read is merged in front
830 ///
831 /// A panel screen's writes stay on the adapter: they answer a `Response` the
832 /// router builds per request and there is nothing to derive about a deletion.
833 /// So the nest is [`nest`] with no read registered, and the seam's `GET /` goes
834 /// on the router in front of it. The adapter mounts as a fallback, deliberately
835 /// (see `quasi_axum::Adapter::into_router`), so a route added here wins without
836 /// anything being taken away.
837 ///
838 /// # The headers are the ones `quasi_http` would have set
839 ///
840 /// `Content-Type: text/html; charset=utf-8` and `HX-Retarget: #region`, which is
841 /// exactly what `respond` emits for `Outcome::Fragment`. Written out rather than
842 /// reached through, because reaching through means building the `Response` this
843 /// path exists to avoid building.
844 fn served_panel_mount(
845 app: &AppState,
846 region: &'static str,
847 writes: &[(quasi_router::Method, &'static str, Screen)],
848 renderer: fn(&Viewer) -> quasi_webview::Webview,
849 answer: fn(&Viewer, &Carried) -> Result<String, quasi_router::RouteError>,
850 ) -> axum::Router {
851 let held = app.clone();
852 nest(app, None, writes, renderer).route(
853 "/",
854 axum::routing::get(move |mut parts: axum::http::request::Parts| {
855 let app = held.clone();
856 async move {
857 use axum::response::IntoResponse as _;
858
859 // The gate is `Audience::Reader`, the same as the nest beside
860 // it, so a signed-out request is refused before the handler
861 // runs. A panel is fetched by a page that already checked, so
862 // the adapter's bare refusal is the right answer here and the
863 // branded 401 is the document mounts'.
864 let viewer = match viewer_factory(app, Audience::Reader)(&parts).await {
865 Ok(viewer) => viewer,
866 Err(refusal) => {
867 return axum::http::StatusCode::from_u16(refusal.class.http_status())
868 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
869 .into_response();
870 }
871 };
872
873 let carried = Carried::of(&mut parts).await;
874
875 // On a blocking thread. See `served` for why.
876 let Ok(answered) =
877 tokio::task::spawn_blocking(move || answer(&viewer, &carried)).await
878 else {
879 // A panic in the answer. Reported as ours, because it is.
880 return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
881 };
882
883 match answered {
884 Ok(markup) => (
885 [(quasi_axum::htmx::RETARGET, format!("#{region}"))],
886 axum::response::Html(markup),
887 )
888 .into_response(),
889 Err(refusal) => axum::http::StatusCode::from_u16(refusal.class.http_status())
890 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
891 .into_response(),
892 }
893 }
894 }),
895 )
896 }
897
898 /// A public document whose regions are already written, mounted.
899 ///
900 /// The seam's mount, shared by every screen on it. It replaced an adapter mount
901 /// that built a `quasi_axum::Adapter`, whose whole job is to call a handler that
902 /// answers a `Screen` and render it, and there is no `Screen` here to answer
903 /// with -- so the two could never have been one, and once `/git` moved across on
904 /// 2026-09-08 there was no public document left on the adapter and that mount
905 /// was deleted, and so was the handler it called. The
906 /// markup was derived on a build machine, so answering is writing the document
907 /// around it (quasicoherent `793d99dd`).
908 ///
909 /// `body` is where the two kinds of screen differ, and it is the only place
910 /// they do. A screen that reads nothing has a residual of one literal, so its
911 /// `body` hands back `residual.settled()` and copies nothing. A screen that
912 /// reads something has holes, so its `body` calls the generated filler, which
913 /// walks the residual and writes the request's values into the gaps. Neither
914 /// builds a `Node`, which is the property the seam exists for; a `Cow` is what
915 /// lets the first path stay a borrow while the second returns a `String`.
916 ///
917 /// `answer` produces the document and its markup **together, from one read**.
918 /// They are not two jobs: a screen states its own title and measure from the
919 /// same values that fill its holes, and `/fan-plus` reads a subscription to
920 /// decide both. Two closures would read it twice per request and could disagree
921 /// between the two reads, which is a worse bug than the one this mount exists to
922 /// avoid.
923 ///
924 /// It takes the viewer because a document is not derivable: the shell carries
925 /// whoever is looking. It takes what the address carried because a screen may
926 /// read it: `/fan-plus` shows a welcome banner only on the way back from
927 /// checkout, which is `?subscribed=true` and is carried nowhere else.
928 ///
929 /// It is fallible for the reason every other screen is. A screen here answers no
930 /// `Screen`, so it never reaches the adapter and cannot use the adapter's
931 /// refusal path; answering something plausible instead would be worse than
932 /// answering nothing, because a member whose subscription could not be read
933 /// would be shown the page that asks them to subscribe. The status comes off the
934 /// `RouteError` exactly as `quasi_axum` takes it, and the body is empty for
935 /// `quasi_axum::unresolved`'s reason: nothing was reached, so there is nothing to
936 /// say that the status does not.
937 ///
938 /// Pairing a body with the wrong screen serves one page's markup inside
939 /// another's document, which nothing here can catch; what catches it is
940 /// `residuals::tests`, which produces each screen's markup both ways and
941 /// compares them.
942 fn served_document_mount(
943 app: &AppState,
944 path: &'static str,
945 renderer: fn(&Viewer) -> quasi_webview::Webview,
946 answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>,
947 ) -> axum::Router {
948 served(app, path, Audience::Anyone, renderer, answer)
949 }
950
951 /// A gated document whose regions are already written, mounted.
952 ///
953 /// [`served_document_mount`] with the two things a public mount leaves out put
954 /// back: the factory is built on [`Audience::Reader`], so a signed-out request
955 /// is refused rather than answered, and [`signed_in`] runs in front so that
956 /// refusal is the branded 401 with its way back in rather than a bare 403. The
957 /// two are written out separately rather than parameterised, because what a
958 /// mount refuses is what a test can press it for.
959 fn served_gated_mount(
960 app: &AppState,
961 path: &'static str,
962 renderer: fn(&Viewer) -> quasi_webview::Webview,
963 answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>,
964 ) -> axum::Router {
965 served(app, path, Audience::Reader, renderer, answer)
966 }
967
968 /// The seam's mount, under either audience.
969 fn served(
970 app: &AppState,
971 path: &'static str,
972 audience: Audience,
973 renderer: fn(&Viewer) -> quasi_webview::Webview,
974 answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>,
975 ) -> axum::Router {
976 let gated = matches!(audience, Audience::Reader);
977 let held = app.clone();
978 let router = axum::Router::new().route(
979 path,
980 axum::routing::get(move |mut parts: axum::http::request::Parts| {
981 let app = held.clone();
982 async move {
983 use axum::response::IntoResponse as _;
984
985 // Under `Audience::Anyone` a reader is never refused for being
986 // signed out, so the error arm there is the session layer or
987 // the CSRF store failing, which is this server being broken
988 // rather than this page being unreachable. Under
989 // `Audience::Reader` the gate in front has already refused a
990 // visitor, so it is the same failure. Same answer the adapter
991 // gives every other screen for it.
992 let Ok(viewer) = viewer_factory(app.clone(), audience)(&parts).await else {
993 return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
994 };
995
996 let carried = Carried::of(&mut parts).await;
997
998 // On a blocking thread, for the reason `quasi_axum` dispatches
999 // every other screen on one: `answer` reaches the database
1000 // through `Viewer::block_on`, and `block_on` from a runtime
1001 // worker panics with "Cannot start a runtime from within a
1002 // runtime". This mount is the one dispatch in the tree that is
1003 // ours rather than the adapter's, so it is the one place that
1004 // has to say so.
1005 let held = (viewer, carried);
1006 let Ok((answered, held)) = tokio::task::spawn_blocking(move || {
1007 let outcome = answer(&held.0, &held.1);
1008 (outcome, held)
1009 })
1010 .await
1011 else {
1012 // A panic in the answer. Reported as ours, because it is,
1013 // and the same way the adapter reports one.
1014 return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
1015 };
1016 let viewer = held.0;
1017
1018 match answered {
1019 Ok(Served { screen, markup }) => {
1020 axum::response::Html(renderer(&viewer).served(&screen, &markup))
1021 .into_response()
1022 }
1023 // `http_status` answers a bare `u16`, and a status this
1024 // server cannot spell is this server being broken rather
1025 // than the reader being refused.
1026 Err(refusal) => axum::http::StatusCode::from_u16(refusal.class.http_status())
1027 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
1028 .into_response(),
1029 }
1030 }
1031 }),
1032 );
1033
1034 if gated {
1035 router.layer(axum::middleware::from_fn_with_state(app.clone(), signed_in))
1036 } else {
1037 router
1038 }
1039 }
1040
1041 /// What a screen on the seam answers with: its document, and the markup to draw
1042 /// inside it.
1043 ///
1044 /// The pair rather than two returns, because they have to come from one read.
1045 /// `Webview::served` carries the caveat worth keeping next to the call: the
1046 /// markup has to have come from this renderer, which is what `residuals::tests`
1047 /// asserts and nothing at this level can.
1048 pub struct Served {
1049 /// The document: the title, the measure, the shell.
1050 pub screen: quasi_router::Screen,
1051 /// The regions, already written. Borrowed when the screen reads nothing.
1052 pub markup: std::borrow::Cow<'static, str>,
1053 }
1054
1055 /// What the address carried, for a screen on the seam that reads it.
1056 ///
1057 /// The adapter builds a `quasi_router::Request` and hands every other screen
1058 /// one; a screen here answers no `Screen`, so it never reaches the adapter and
1059 /// there is no `Request` to read. This is that much of one: the query, parsed
1060 /// the same way, and nothing else. Kept to what a served document actually
1061 /// needs rather than growing into a second `Request` nobody asked for.
1062 pub struct Carried {
1063 /// The query the reader arrived with.
1064 query: std::collections::HashMap<String, String>,
1065 /// What the address pattern caught, for a screen at a parameterised
1066 /// address.
1067 captures: std::collections::HashMap<String, String>,
1068 }
1069
1070 impl Carried {
1071 /// What this request carried, out of the parts the adapter never sees.
1072 ///
1073 /// # The captures are axum's, and they are percent-decoded
1074 ///
1075 /// A screen on the seam answers no `Screen`, so its address is matched by
1076 /// the axum route this mount registers rather than by
1077 /// `quasi_router::Router`. The two agree about which requests match and
1078 /// differ in one thing: axum decodes `%2f`-style escapes in a capture and
1079 /// quasi hands the raw segment over. So `/c/ada/a%2Db` reaches the seam as
1080 /// slug `a-b` and reaches the described twin as `a%2Db`.
1081 ///
1082 /// Stated rather than reconciled. The difference is a widening toward the
1083 /// spelling RFC 3986 says the address means, it lands on the same row, and
1084 /// the alternative is a second path matcher in this crate.
1085 async fn of(parts: &mut axum::http::request::Parts) -> Self {
1086 use axum::extract::FromRequestParts as _;
1087
1088 let query = parts
1089 .uri
1090 .query()
1091 .map(|query| {
1092 url::form_urlencoded::parse(query.as_bytes())
1093 .map(|(key, value)| (key.into_owned(), value.into_owned()))
1094 .collect()
1095 })
1096 .unwrap_or_default();
1097
1098 // An address with no captures in it has no `UrlParams` extension at
1099 // all, which is a refusal here and an empty map rather than a failure:
1100 // `/policy` carries none and asks for none.
1101 let captures = axum::extract::RawPathParams::from_request_parts(parts, &())
1102 .await
1103 .map(|params| {
1104 params
1105 .iter()
1106 .map(|(key, value)| (key.to_owned(), value.to_owned()))
1107 .collect()
1108 })
1109 .unwrap_or_default();
1110
1111 Self { query, captures }
1112 }
1113
1114 /// Whether the address carried `key` set to exactly `value`.
1115 #[must_use]
1116 pub fn says(&self, key: &str, value: &str) -> bool {
1117 self.query.get(key).is_some_and(|held| held.trim() == value)
1118 }
1119
1120 /// What the query carried under `key`, if anything.
1121 #[must_use]
1122 pub fn asked(&self, key: &str) -> Option<&str> {
1123 self.query.get(key).map(String::as_str)
1124 }
1125
1126 /// What the address pattern caught under `name`.
1127 ///
1128 /// Fallible with the refusal a missing capture deserves rather than an
1129 /// `Option`: a capture the pattern declares is always present, so absence
1130 /// is this mount being registered at an address that does not name it, and
1131 /// every caller would write the same `ok_or_else`.
1132 pub fn capture(&self, name: &str) -> Result<&str, quasi_router::RouteError> {
1133 self.captures
1134 .get(name)
1135 .map(String::as_str)
1136 .ok_or_else(|| quasi_router::RouteError::not_found("no such page"))
1137 }
1138 }
1139
1140 /// Every described screen a reader with no session can reach, mounted.
1141 ///
1142 /// Separate from [`mounts`] because of the factory, not because of the address:
1143 /// [`viewer_factory`] resolves a session and refuses without one, which is the
1144 /// right answer for every screen behind a login and the wrong one for a
1145 /// marketing page. A public screen takes a state resolved once at startup, so
1146 /// its adapter is `Adapter::new` rather than `Adapter::per_viewer`.
1147 ///
1148 /// Deliberately not in [`PATHS`]. That list is what the CSRF probe skips and
1149 /// what `tests/workflows/described_screens.rs` presses with a signed-in
1150 /// fixture, and both readings are about screens that hold a session. A public
1151 /// screen registers no mutating route, so the probe has nothing to skip.
1152 pub fn public_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
1153 vec![
1154 (pricing::PATH, pricing_mount(app)),
1155 (shortcuts::PATH, shortcuts_mount()),
1156 ]
1157 }
1158
1159 /// The keyboard-shortcuts overlay, on the same shell every described screen
1160 /// gets so the listing looks like the site it is drawn over.
1161 ///
1162 /// Its own mount rather than a route inside the pricing nest: an overlay
1163 /// reachable from every screen is not one screen's route, and the binding's
1164 /// address is absolute in the emitted markup (`e0c0d991`).
1165 fn shortcuts_mount() -> axum::Router {
1166 quasi_axum::Adapter::new(
1167 shortcuts::router(),
1168 std::sync::Arc::new(()),
1169 std::sync::Arc::new(pricing::renderer()),
1170 )
1171 .into_router()
1172 }
1173
1174 /// The fee calculator's own nest: the page and the recompute it answers.
1175 fn pricing_mount(app: &AppState) -> axum::Router {
1176 let state = std::sync::Arc::new(pricing::Pricing {
1177 billing: crate::Billing::from_ref(app),
1178 founder_window_open: app.config.creator_pricing.founder_window_open,
1179 changelog_published: crate::changelog::is_published(),
1180 });
1181 let router = quasi_router::Router::<pricing::Pricing>::new()
1182 .get("/", pricing::screen)
1183 .get("/compare", pricing::compare);
1184 quasi_axum::Adapter::new(router, state, std::sync::Arc::new(pricing::renderer())).into_router()
1185 }
1186
1187 declare! {
1188 /// This server's own copy, as markdown.
1189 ///
1190 /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so
1191 /// its links carry `nofollow`, its raw markup is dropped and fetchable
1192 /// schemes are filtered. That is right for a forum post and wrong for a
1193 /// page's own sentence, and until quasi grew the trust axis every described
1194 /// page here was telling crawlers not to follow its own links
1195 /// (quasicoherent `24a3b1df`).
1196 ///
1197 /// The two axes stay separate in quasi -- `Richness` is what the format may
1198 /// express, `Trust` is who wrote it -- and this is the one combination this
1199 /// server reaches for often enough to name: a sentence, ours. A screen
1200 /// wanting tables says so with `Node::richness`, and a screen carrying a
1201 /// reader's markdown keeps `Node::rich`.
1202 ///
1203 /// **The test for using it is authorship, not tidiness.** The string has to
1204 /// be a literal in this repository, or interpolated from a value that
1205 /// cannot carry markup. A creator's description reaching a screen through
1206 /// the database is `Node::rich` however well-behaved it has been.
1207 /// # Constant, and deliberately not staged
1208 ///
1209 /// An `include` of this with a literal folds to the `#[constant]` shim and
1210 /// is derived once, which is what `/policy` uses six times over.
1211 ///
1212 /// `#[staged]` was tried and is wrong. The parameter is the **markdown
1213 /// source**, so staging replaces the whole document with one sentinel: the
1214 /// derivation renders `<p>ZQH...HQZ</p>` and the filler writes the request's
1215 /// markdown into that paragraph unparsed. `git_repos` is where it showed --
1216 /// a fenced code block came back as a literal ``` inside a `<p>`.
1217 ///
1218 /// # A staged screen that wants a value inside prose writes the `rich`
1219 ///
1220 /// Not because prose cannot be staged, but because the hole has to be in
1221 /// the **source** rather than around it. `rich "... [name]({base}) ..."`
1222 /// written inside the staged shape puts a sentinel in the markdown, so
1223 /// docengine parses it and the residual holds the markup with the value's
1224 /// own gap in it; the same sentence formatted first and handed here does
1225 /// not. `git_repos` and `forum_memberships` are the two sites, and each
1226 /// inlines this shape's two-line body with a note saying so.
1227 ///
1228 /// What a staged `rich` then requires is that the value cannot change how
1229 /// the source parses -- which is the rule this shape already carries for a
1230 /// different reason. A value that could alter the parse could carry markup,
1231 /// and such a value wants `Node::rich`, untrusted and not on the seam.
1232 #[must_use]
1233 #[constant]
1234 pub shape own_prose(source: impl Into<String>) -> Node;
1235
1236 rich source {
1237 trust quasi_router::Trust::Trusted;
1238 }
1239 }
1240
1241 /// A handler, spelled once so the screens and the mount agree about it.
1242 pub type Screen =
1243 fn(&Viewer, quasi_router::Request) -> Result<quasi_router::Response, quasi_router::RouteError>;
1244
1245 /// One screen behind the adapter, answering the root of its own nest.
1246 ///
1247 /// The tab is `/` because the nest has already taken the address off: a screen
1248 /// mounted at its own tab endpoint sees one route and never has to agree with
1249 /// the prefix twice.
1250 ///
1251 /// # Why a screen serves its own writes
1252 ///
1253 /// `writes` registers routes under the same nest, and a destructive control on
1254 /// a described screen should address one of them rather than the API route the
1255 /// Askama version used. Decision 7 is the reason: a described route answers with
1256 /// a `Response::Fragment` naming the region it changed, so the answer lands where
1257 /// it belongs and carries the screen's own markup.
1258 ///
1259 /// An API route can do neither, and both described screens that called one were
1260 /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx
1261 /// request with the whole re-rendered Askama list, and with no target htmx put
1262 /// that table inside the button that was pressed. `DELETE /api/contacts/{id}`
1263 /// answers 204, which htmx is configured never to swap, so the row stays on
1264 /// screen after a successful revoke. Neither is visible to a test that checks
1265 /// only that the address exists.
1266 ///
1267 /// The write still goes through the same CSRF envelope: `crate::csrf` nests this
1268 /// service under an Auto posture, and the core module attaches the token to
1269 /// every htmx request on the page.
1270 fn mount(
1271 app: &AppState,
1272 screen: Screen,
1273 writes: &[(quasi_router::Method, &'static str, Screen)],
1274 renderer: fn(&Viewer) -> quasi_webview::Webview,
1275 ) -> axum::Router {
1276 nest(app, Some(screen), writes, renderer)
1277 }
1278
1279 /// A nest that serves writes and answers no address of its own.
1280 ///
1281 /// The hole it fills: a panel whose route answers a conditional GET cannot be a
1282 /// mounted screen, because
1283 /// [`mount`] has no way to say "304 if the cache generation has not moved". So
1284 /// it stays a fill on its Askama handler -- and a fill has no nest, so it had
1285 /// nowhere to put its writes, so its controls kept addressing API routes that
1286 /// answer 200 or 204 and cannot name the region they changed. The patch for
1287 /// that was `data-after`, the private dispatcher vocabulary in
1288 /// `frontend/src/core/dispatch.ts` that this conversion exists to retire.
1289 ///
1290 /// This is the other half of such a panel: the read keeps its Askama route and
1291 /// its ETag, and the writes get described routes that answer
1292 /// `Response::Fragment` naming the panel's region, exactly as a mounted
1293 /// screen's do.
1294 ///
1295 /// # The cost, stated once rather than per panel
1296 ///
1297 /// One panel is then served by two routers, and the read and the write are no
1298 /// longer visible in one place. That is the trade: the alternative was nine
1299 /// tabs converting their markup while keeping their JS, which lowers no seal
1300 /// and is not what S4 is for.
1301 ///
1302 /// # The address is a fixed prefix, and the ids go inside it
1303 ///
1304 /// A nest is mounted at a fixed path, which is also why `project_analytics` is
1305 /// a fill rather than a mounted screen. Path parameters live in the inner
1306 /// router, which does support them: `ssh_keys` already registers `/keys/{id}`
1307 /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids
1308 /// in the inner paths and does not reuse the API route's address. That API
1309 /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did
1310 /// when `ssh_keys` moved its controls off it.
1311 fn writes_only(
1312 app: &AppState,
1313 writes: &[(quasi_router::Method, &'static str, Screen)],
1314 renderer: fn(&Viewer) -> quasi_webview::Webview,
1315 ) -> axum::Router {
1316 nest(app, None, writes, renderer)
1317 }
1318
1319 /// The router both of the above build, with or without a root GET.
1320 fn nest(
1321 app: &AppState,
1322 screen: Option<Screen>,
1323 writes: &[(quasi_router::Method, &'static str, Screen)],
1324 renderer: fn(&Viewer) -> quasi_webview::Webview,
1325 ) -> axum::Router {
1326 let mut quasi = quasi_router::Router::<Viewer>::new();
1327 if let Some(screen) = screen {
1328 quasi = quasi.get("/", screen);
1329 }
1330 for (method, path, handler) in writes {
1331 quasi = match method {
1332 quasi_router::Method::Delete => quasi.delete(path, *handler),
1333 quasi_router::Method::Put => quasi.put(path, *handler),
1334 quasi_router::Method::Get => quasi.get(path, *handler),
1335 quasi_router::Method::Post => quasi.post(path, *handler),
1336 };
1337 }
1338 quasi_axum::Adapter::per_viewer(
1339 quasi,
1340 viewer_factory(app.clone(), Audience::Reader),
1341 move |viewer, _, _| renderer(viewer),
1342 )
1343 .into_router()
1344 }
1345
1346 #[cfg(test)]
1347 mod tests {
1348 use super::*;
1349
1350 /// The whole of what `own_prose` is for: a sentence this server wrote is
1351 /// trusted, so quasi stops telling crawlers not to follow its own links.
1352 /// `Node::rich` alone is untrusted and is the right default for a forum
1353 /// post; the two are one call apart and read the same at a glance.
1354 #[test]
1355 fn our_own_sentence_is_trusted_and_a_readers_is_not() {
1356 let ours = own_prose("Read the [docs](/docs).");
1357 let theirs = quasi_router::Node::rich("Read the [docs](/docs).");
1358
1359 assert!(matches!(
1360 ours,
1361 quasi_router::Node::Rich {
1362 trust: quasi_router::Trust::Trusted,
1363 ..
1364 }
1365 ));
1366 assert!(matches!(
1367 theirs,
1368 quasi_router::Node::Rich {
1369 trust: quasi_router::Trust::Untrusted,
1370 ..
1371 }
1372 ));
1373 }
1374
1375 #[test]
1376 fn every_screen_is_listed_in_paths() {
1377 // The two lists are written by hand and read by two different things,
1378 // so the check is that adding a screen to `mounts` and forgetting
1379 // `PATHS` fails here rather than silently weakening the CSRF coverage
1380 // probe's skip list.
1381 assert_eq!(
1382 PATHS.len(),
1383 SCREENS.len(),
1384 "PATHS and SCREENS describe the same screens"
1385 );
1386
1387 let source = include_str!("mod.rs");
1388 let mounted = source
1389 .split_once("pub fn mounts(")
1390 .expect("mounts exists")
1391 .1
1392 .split_once("\n}")
1393 .expect("mounts ends")
1394 .0;
1395 // Counted as `mount(` since `64b33b26`. `mounts` used to push
1396 // conditionally into a Vec, one `mounted.push((` per screen the switch
1397 // had on; it returns a `vec![..]` literal now because every screen is
1398 // mounted unconditionally, so the thing to count is the adapter call
1399 // each entry makes. Not `mount(app,`: rustfmt breaks the longer entries
1400 // across lines and that token then finds three of the six.
1401 //
1402 // `served_panel_mount(` ends in the same token and is counted with
1403 // them, which is what this wants: a panel that moves onto the residual
1404 // seam is still a screen `PATHS` has to list. `writes_only(` does not,
1405 // which is also right -- those nests answer no address of their own.
1406 let registered = mounted.matches("mount(").count();
1407 assert_eq!(
1408 registered,
1409 PATHS.len(),
1410 "mounts registers {registered} screens, PATHS lists {}",
1411 PATHS.len()
1412 );
1413 }
1414
1415 #[test]
1416 fn every_document_screen_is_listed_in_document_paths() {
1417 let source = include_str!("mod.rs");
1418 let mounted = source
1419 .split_once("pub fn document_mounts(")
1420 .expect("document_mounts exists")
1421 .1
1422 .split_once("\n}")
1423 .expect("document_mounts ends")
1424 .0;
1425 // Two mount styles, and both are gated documents, for the same
1426 // reason `every_public_document_is_listed_in_public_document_paths`
1427 // counts two: a screen on the residual seam has no `Screen` to answer
1428 // with, so it takes [`served_gated_mount`] rather than the adapter.
1429 // Counting only the first would let a screen leave `DOCUMENT_PATHS`
1430 // unnoticed by moving onto the seam.
1431 let adapted = mounted.matches("document_mount(").count();
1432 let served = mounted.matches("served_gated_mount(").count();
1433 assert_eq!(
1434 adapted + served,
1435 DOCUMENT_PATHS.len(),
1436 "document_mounts and DOCUMENT_PATHS describe the same screens"
1437 );
1438 }
1439
1440 #[test]
1441 fn every_public_document_is_listed_in_public_document_paths() {
1442 let source = include_str!("mod.rs");
1443 let mounted = source
1444 .split_once("pub fn public_document_mounts(")
1445 .expect("public_document_mounts exists")
1446 .1
1447 .split_once("\n}")
1448 .expect("public_document_mounts ends")
1449 .0;
1450 // Both mount styles, though only one of them has a caller left. Every
1451 // public document is on the residual seam as of 2026-09-08 and takes
1452 // `served_document_mount`, which answers no `Screen`; the adapter mount
1453 // that answered one is gone. The first count stays because what this
1454 // test exists to catch is a screen leaving `PUBLIC_DOCUMENT_PATHS`
1455 // unnoticed by changing how it is mounted, and a count that only knows
1456 // today's style would not catch it changing back.
1457 let adapted = mounted.matches("public_document_mount(").count();
1458 let served = mounted.matches("served_document_mount(").count();
1459 assert_eq!(
1460 adapted + served,
1461 PUBLIC_DOCUMENT_PATHS.len(),
1462 "public_document_mounts and PUBLIC_DOCUMENT_PATHS describe the same screens"
1463 );
1464 }
1465
1466 /// Every git address carries the browse limiter, checked in the source
1467 /// because there is nothing to ask an `axum::Router` about afterwards.
1468 ///
1469 /// This is the check the whole `/git` conversion turns on. The tree's reads
1470 /// sit under one `route_layer` in `routes::git`, so an address lifted out of
1471 /// it and mounted here loses that layer silently: nothing fails to compile,
1472 /// no test fails, and a route that walks bare repositories on disk stops
1473 /// being capped. A conversion that forgets it fails here instead.
1474 #[test]
1475 fn every_described_git_address_keeps_the_browse_limiter() {
1476 let source = include_str!("mod.rs");
1477 let mounted = source
1478 .split_once("pub fn public_document_mounts(")
1479 .expect("public_document_mounts exists")
1480 .1
1481 .split_once("\n}")
1482 .expect("public_document_mounts ends")
1483 .0;
1484
1485 let git_paths = PUBLIC_DOCUMENT_PATHS
1486 .iter()
1487 .filter(|path| path.starts_with("/git"))
1488 .count();
1489 assert!(git_paths > 0, "no git document is mounted yet");
1490 assert_eq!(
1491 mounted.matches("GIT_BROWSE_RATE_LIMIT_MS").count(),
1492 git_paths,
1493 "a described /git address is mounted without the browse limiter"
1494 );
1495 }
1496
1497 /// The two document lists differ by exactly one thing, and it is the one
1498 /// that matters: a gated document refuses a visitor, a public one renders to
1499 /// them. Nothing else about the mount changes, so a screen in the wrong list
1500 /// is a page that 401s or a page that leaks, depending on the direction.
1501 #[test]
1502 fn no_document_is_in_both_lists() {
1503 for path in PUBLIC_DOCUMENT_PATHS {
1504 assert!(
1505 !DOCUMENT_PATHS.contains(path),
1506 "{path} is mounted both gated and public"
1507 );
1508 }
1509 }
1510
1511 #[test]
1512 fn a_path_is_claimed_by_exactly_one_screen() {
1513 // Two screens on one address is an axum panic at startup, and the two
1514 // forum-memberships screens are the near miss: one module, two paths.
1515 let mut seen = PATHS.to_vec();
1516 seen.extend_from_slice(DOCUMENT_PATHS);
1517 seen.extend_from_slice(PUBLIC_DOCUMENT_PATHS);
1518 seen.sort_unstable();
1519 let before = seen.len();
1520 seen.dedup();
1521 assert_eq!(
1522 before,
1523 seen.len(),
1524 "two screens claim one address: {seen:?}"
1525 );
1526 }
1527 }
1528