Skip to main content

max / makenotwork

42.5 KB · 1013 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 tokio::runtime::Handle;
35
36 use axum::extract::FromRef;
37
38 use crate::AppState;
39 use crate::auth::SessionUser;
40 pub mod auth_pages;
41
42 pub mod buyer_contacts;
43 pub mod cart_act;
44 pub mod clip_acts;
45 pub mod collections;
46 pub mod creators;
47 pub mod custom_page;
48 pub mod discover_search;
49 pub mod discover_typeahead;
50 pub mod embeds;
51 pub mod export_act;
52 pub mod export_portal;
53 pub mod fan_plus;
54 pub mod feeds;
55 pub mod follow;
56 pub mod forum_memberships;
57 pub mod git_explore;
58 pub mod git_repos;
59 pub mod item_files;
60 pub mod item_sales;
61 pub mod item_tabs;
62 pub mod library_acts;
63 pub mod library_contacts;
64 pub mod library_tabs;
65 pub mod license_key_act;
66 pub mod link_remove_act;
67 pub mod media_picker;
68 pub mod payout_summary;
69 pub mod policy;
70 pub mod pricing;
71 pub mod project;
72 pub mod project_analytics;
73 pub mod project_blog;
74 pub mod project_content;
75 pub mod project_members;
76 pub mod project_overview;
77 pub mod project_tabs;
78 pub mod promo_code_acts;
79 pub mod repo_acts;
80 pub mod rich_field;
81 pub mod schedule_field;
82 pub mod session_acts;
83 pub mod settings_tabs;
84 pub mod shortcuts;
85 pub mod ssh_keys;
86 pub mod team;
87 pub mod tip;
88 pub mod upload_field;
89 pub mod use_cases;
90 pub mod user;
91 pub mod user_analytics;
92 pub mod user_projects;
93 pub mod user_support;
94 pub mod user_tabs;
95 pub mod version_delete_act;
96 pub mod widgets;
97
98 /// The state one request is answered against.
99 ///
100 /// Built per request by the adapter's factory, which is the whole of quasi's
101 /// answer to a server: everything resolvable from the request head is loaded
102 /// before the sync router runs, and a handler reads it off `&S` the way every
103 /// other host's handler reads its app.
104 pub struct Viewer {
105 /// The long-lived application state. Cloning it clones handles, not data.
106 pub app: AppState,
107 /// Who is asking, when anybody is.
108 ///
109 /// Resolved and revocation-checked by [`crate::auth::authenticate`], the
110 /// same path the extractor takes.
111 ///
112 /// `None` only on a mount built with [`Audience::Anyone`], which is the
113 /// public documents: a screen a reader can reach with no session, whose
114 /// header and controls differ by whether one is held. Every other mount
115 /// refuses before the handler runs, so a screen behind [`mount`],
116 /// [`writes_only`] or [`document_mount`] can read it through
117 /// [`reader`](Self::reader) and never see the refusal that method can
118 /// return.
119 ///
120 /// The field is `pub` and the accessor exists beside it because both
121 /// readings are legitimate: a gated screen wants the user and treats
122 /// absence as impossible, and a public screen wants the option and treats
123 /// absence as an ordinary state.
124 pub user: Option<SessionUser>,
125 /// The runtime the request arrived on, so a sync handler can reach the
126 /// async database. Captured in the factory rather than read inside the
127 /// handler: `Handle::current` works on a blocking thread today, and
128 /// depending on that is depending on where quasi-axum happens to dispatch.
129 pub runtime: Handle,
130 /// This session's CSRF token, for the shell to hand to the document.
131 ///
132 /// Resolved in the factory rather than in a renderer because minting one is
133 /// an async session write and a renderer is sync. It is the same token the
134 /// Askama pages carry: [`crate::csrf::get_or_create_token`] is
135 /// get-or-create, so a described page and a templated one in the same
136 /// session agree, and validation is one comparison either way.
137 pub csrf: String,
138 /// This request's session-tracking id, when it has one.
139 ///
140 /// The same class of fact as [`csrf`](Self::csrf) and resolved the same
141 /// way: reading it is an async session lookup, so the factory does it and
142 /// the sync handler reads the answer off `&S`. It is what lets a screen
143 /// tell the reader's own row apart from the rest, which
144 /// `user_sessions` needs twice over: the `Current` badge, and the one row
145 /// that offers no `Sign out`.
146 ///
147 /// `None` is a real state rather than a failure. A session predating
148 /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a
149 /// screen answering for one marks no row as current.
150 pub session_id: Option<crate::db::UserSessionId>,
151 /// Markup for the bespoke regions this request's screen describes.
152 ///
153 /// The seam between a handler and its renderer, and the reason it has to be
154 /// here rather than in either of them: a `Region::Handover` is filled on the
155 /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the
156 /// handler has answered, and the renderer factory is handed `&S` and the
157 /// answer. So the handler writes what it drew here and the renderer reads
158 /// it back off the same state. Both see one instance: the adapter builds a
159 /// single `Arc<Viewer>` per request and passes it to the router and then to
160 /// the factory.
161 ///
162 /// Behind a lock because the handler holds `&Viewer` and runs on a blocking
163 /// thread. Uncontended in practice: one request writes it, then one
164 /// renderer reads it, never at once.
165 fills: std::sync::Mutex<std::collections::HashMap<String, String>>,
166 }
167
168 impl Viewer {
169 /// Run a database future from inside a sync handler.
170 ///
171 /// The blocking hop, named in one place so the thing being measured is
172 /// countable rather than spread across every handler. Every call holds this
173 /// blocking thread until the query answers.
174 pub fn block_on<F: Future>(&self, future: F) -> F::Output {
175 self.runtime.block_on(future)
176 }
177
178 /// Who is asking, on a mount that guarantees somebody is.
179 ///
180 /// The gated mounts resolve the session before the handler runs and refuse
181 /// without one, so this cannot fail there. It returns a `Result` rather
182 /// than unwrapping because that guarantee lives in the mount rather than in
183 /// the type: if a screen is ever moved onto a public mount, the failure is
184 /// a refusal the reader can read instead of a panic in a blocking thread.
185 ///
186 /// A screen that genuinely serves both audiences reads
187 /// [`user`](Self::user) directly instead.
188 pub fn reader(&self) -> Result<&SessionUser, quasi_router::RouteError> {
189 self.user
190 .as_ref()
191 .ok_or_else(|| quasi_router::RouteError::denied("sign in to continue"))
192 }
193
194 /// Hand the renderer the markup for one bespoke region.
195 ///
196 /// Called by a handler while it builds its description, keyed by the slot
197 /// id the description gives that region. Markup, not text: a bespoke region
198 /// is the app's own and is not escaped, which is the whole of what makes it
199 /// bespoke and the whole of why a handler must not put a reader's string in
200 /// one without escaping it first.
201 pub fn fill(&self, slot_id: impl Into<String>, markup: impl Into<String>) {
202 if let Ok(mut fills) = self.fills.lock() {
203 fills.insert(slot_id.into(), markup.into());
204 }
205 }
206
207 /// The shell every described screen is drawn in.
208 ///
209 /// Here rather than in each screen's `renderer` because of what it carries:
210 /// a described page whose shell does not declare the session token has
211 /// every write on it refused, and the five screens that wrote
212 /// `Shell::under("/static")` out by hand were five chances to forget. A
213 /// screen that wants more says so on top of this; a screen that says
214 /// nothing gets the token anyway.
215 ///
216 /// The layer order matches `crate::shell`, which is the Askama half of the
217 /// same document: `makeover` is prepended by the renderer, and the site's
218 /// own sheets live in `components`.
219 #[must_use]
220 pub fn shell(&self) -> quasi_webview::Shell {
221 quasi_webview::Shell::under("/static")
222 .layered(["base", "components", "responsive"])
223 // Every described write is an htmx request, and htmx inherits this
224 // from the body, so one declaration covers the whole document.
225 .sending("X-CSRF-Token", &self.csrf)
226 }
227
228 /// The shell a described screen that owns its whole DOCUMENT is drawn in.
229 ///
230 /// [`shell`](Self::shell) is right for a fragment landing inside an Askama
231 /// page, which already has the head, the tail and the token meta. A
232 /// document owes all three itself: [`crate::shell::described`] is the same
233 /// builder `base.html` renders through, [`crate::shell::body_last`] is the
234 /// toast container and the classic shims, and the token meta is what the
235 /// pre-module scripts read.
236 ///
237 /// The meta is not redundant with `Shell::sending`. That covers htmx, and
238 /// `frontend/src/core/net.ts`, `frontend/src/core/htmx-glue.ts`,
239 /// `static/passkey.js` and `static/project-sections.js` all read
240 /// `meta[name=csrf-token]` instead. `/pricing` needed none of it because it
241 /// holds no session.
242 #[must_use]
243 pub fn document_shell(&self) -> quasi_webview::Shell {
244 crate::shell::described()
245 .sending("X-CSRF-Token", &self.csrf)
246 .with_body_last(crate::shell::body_last())
247 .with_chrome(crate::quasi::shortcuts::chrome())
248 .with_head(format!(
249 "<meta name=\"csrf-token\" content=\"{}\">",
250 crate::helpers::escape_html(&self.csrf)
251 ))
252 }
253
254 /// Everything the handler drew, for the renderer to mount.
255 pub fn drawn(&self) -> std::collections::HashMap<String, String> {
256 self.fills.lock().map(|f| f.clone()).unwrap_or_default()
257 }
258 }
259
260 /// Who a mount is willing to answer.
261 ///
262 /// The signed-out question `b5cbb646` left open, answered here rather than by a
263 /// second state type. Two mounts, one `Viewer`: what differs between a panel
264 /// behind a login and a public page is whether a missing session ends the
265 /// request, and that is one branch in the factory rather than a parallel
266 /// hierarchy of states, factories and renderer signatures.
267 #[derive(Clone, Copy, PartialEq, Eq)]
268 enum Audience {
269 /// A session is required, and its absence ends the request.
270 ///
271 /// Every panel and every document behind a login. The screens built on this
272 /// read [`Viewer::reader`] and never see it fail.
273 Reader,
274 /// A session is read when there is one, and its absence is an ordinary
275 /// state the screen describes.
276 ///
277 /// The public documents: `/team` and the rest of the `pages/` screens that
278 /// read the same to a visitor and to a reader, and differ only in the
279 /// header they carry. A screen here still gets a CSRF token, because the
280 /// header's own controls post.
281 Anyone,
282 }
283
284 /// Build the state factory the adapter calls per request.
285 ///
286 /// On [`Audience::Reader`] it refuses with `Unauthorized` when there is no
287 /// session to resolve, which the adapter turns into a bare status with no body.
288 /// That is the right shape there and not a shortcut: a store that will not
289 /// answer is not a signed-out reader, and rendering a sign-in notice would need
290 /// the renderer that is built from the state that could not be resolved.
291 ///
292 /// On [`Audience::Anyone`] a failed `authenticate` is not a refusal, it is a
293 /// visitor: the viewer is built with `user: None` and the request goes on. The
294 /// CSRF token is minted either way, since it is the session's rather than the
295 /// user's and a sessionless form still needs one.
296 ///
297 /// The absent session layer stays an internal error on both, because that is
298 /// wiring rather than an audience.
299 fn viewer_factory(
300 app: AppState,
301 audience: Audience,
302 ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static {
303 move |parts| {
304 let app = app.clone();
305 let runtime = Handle::current();
306 // Taken out of the head first, so the future owns what it needs.
307 let session = parts.extensions.get::<tower_sessions::Session>().cloned();
308 Box::pin(async move {
309 let Some(session) = session else {
310 // The session layer runs in front of this. Its absence is a
311 // wiring mistake rather than a signed-out reader.
312 return Err(quasi_router::RouteError::internal("no session layer"));
313 };
314 let user = match crate::auth::authenticate(&session, &app).await {
315 Ok(user) => Some(user),
316 Err(_) if audience == Audience::Anyone => None,
317 Err(_) => {
318 return Err(quasi_router::RouteError::denied("sign in to continue"));
319 }
320 };
321 // Before the handler runs, because the write that mints a token has
322 // to finish on the session this request holds. A failure here is
323 // the session store, not the reader.
324 let csrf = crate::csrf::get_or_create_token(&session)
325 .await
326 .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
327 // Same reason as `csrf`: an async read a sync handler cannot do.
328 // Absent on a legacy session, which is a state the screens describe
329 // rather than an error.
330 let session_id = session
331 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
332 .await
333 .ok()
334 .flatten();
335 Ok(Viewer {
336 app,
337 user,
338 runtime,
339 csrf,
340 session_id,
341 fills: std::sync::Mutex::default(),
342 })
343 })
344 }
345 }
346
347 /// Every converted screen that is switched on, with the address it answers.
348 ///
349 /// One mount per screen rather than one router for all of them, because axum
350 /// strips a nest's prefix before the inner service sees the request: a single
351 /// nest covering both would have to sit at a prefix the Askama routes also live
352 /// under, and matchit refuses to hold a wildcard beside the parameterised routes
353 /// already there. Measured, not assumed: nesting at `/dashboard/project` panics
354 /// at startup against `/dashboard/project/{slug}/tabs/overview`.
355 ///
356 /// The list is empty when nothing is switched on, so the caller registers its
357 /// Askama routes exactly as before and the adapter is not in the stack at all. A
358 /// conversion is a startup-time choice: config is read once, and a per-request
359 /// branch would pay for a switch that never moves.
360 /// Every address a described screen can claim, switched on or not.
361 ///
362 /// `mounts` returns only what is currently on, which depends on config. This is
363 /// the whole set, and it exists for the CSRF coverage test: the manifest that
364 /// test reads is a process-global, so a test that switches a screen on leaves an
365 /// entry behind for a path the default router does not serve, and the probe
366 /// reads that as a route that lost its protection. The list lets it skip exactly
367 /// those and nothing else.
368 ///
369 /// Checked against `mounts` below rather than trusted, since a screen added to
370 /// one and not the other is the obvious way for this to rot.
371 pub const PATHS: &[&str] = &[
372 ssh_keys::PATH,
373 library_contacts::PATH,
374 buyer_contacts::PATH,
375 user_analytics::PATH,
376 payout_summary::PATH,
377 forum_memberships::LIBRARY_PATH,
378 forum_memberships::SETTINGS_PATH,
379 ];
380
381 /// Every described screen that owns its whole document, with the address it
382 /// answers.
383 ///
384 /// Its own list rather than an entry in [`PATHS`], for the same reason
385 /// [`public_mounts`] keeps its own: what a nest answers with decides what a
386 /// test can assert about it. A panel screen answers `Response::Fragment` and
387 /// names the region it changed, which `tests/workflows/described_screens.rs`
388 /// checks by reading `HX-Retarget` off every entry in [`PATHS`]. A document
389 /// answers `Outcome::Screen`, which sets no such header and is not a defect.
390 ///
391 /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen
392 /// registers no mutating route, so it has nothing to skip here either.
393 pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH, export_portal::PATH];
394
395 /// Every described document a reader with no session can reach, with the
396 /// address it answers.
397 ///
398 /// Its own list beside [`DOCUMENT_PATHS`] for the same reason that one sits
399 /// beside [`PATHS`]: what a mount answers with decides what a test can assert
400 /// about it. These answer `Outcome::Screen` like the gated documents, and
401 /// differ in that a signed-out request is a render rather than a 401, which is
402 /// what `tests/workflows/pages.rs` presses them for.
403 ///
404 /// Not in [`public_mounts`], which is the other public list and a different
405 /// mechanism: those screens resolve nothing per request and take a state built
406 /// once at startup. These resolve a session when there is one, so they carry a
407 /// per-request viewer and can mint a CSRF token for the form on them.
408 pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[
409 team::PATH,
410 use_cases::PATH,
411 policy::PATH,
412 fan_plus::PATH,
413 creators::PATH,
414 collections::PATH,
415 git_explore::PATH,
416 git_repos::PATH,
417 ];
418
419 /// Every screen's switch name, in the same order as [`PATHS`].
420 ///
421 /// Test-only: the switches themselves are read from each screen's own `SCREEN`
422 /// in `mounts`, and this exists so the consistency check below has both halves
423 /// to compare. Kept beside `PATHS` rather than inside the test module, because
424 /// the pairing is the thing being asserted and splitting them is how they drift.
425 #[cfg(test)]
426 const SCREENS: &[&str] = &[
427 ssh_keys::SCREEN,
428 library_contacts::SCREEN,
429 buyer_contacts::SCREEN,
430 user_analytics::SCREEN,
431 payout_summary::SCREEN,
432 forum_memberships::LIBRARY_SCREEN,
433 forum_memberships::SETTINGS_SCREEN,
434 ];
435
436 /// Every described screen, mounted.
437 ///
438 /// Unconditional since `64b33b26`. Each entry used to be gated on
439 /// `described(app, ..)` reading `QUASI_SCREENS`, so a screen could be switched
440 /// off and its Askama rendering registered instead. There is no Askama
441 /// rendering to fall back to any more, and the route tables no longer register
442 /// one, so a screen missing from this list is an address nothing answers.
443 pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
444 vec![
445 (
446 ssh_keys::PATH,
447 mount(app, ssh_keys::screen, ssh_keys::WRITES, ssh_keys::renderer),
448 ),
449 (
450 library_contacts::PATH,
451 mount(
452 app,
453 library_contacts::screen,
454 library_contacts::WRITES,
455 library_contacts::renderer,
456 ),
457 ),
458 // One module, two screens: the library tab and the settings section are
459 // the same table under different chrome.
460 (
461 forum_memberships::LIBRARY_PATH,
462 mount(
463 app,
464 forum_memberships::library_screen,
465 &[],
466 forum_memberships::renderer,
467 ),
468 ),
469 (
470 buyer_contacts::PATH,
471 mount(app, buyer_contacts::screen, &[], buyer_contacts::renderer),
472 ),
473 (
474 user_analytics::PATH,
475 mount(app, user_analytics::screen, &[], user_analytics::renderer),
476 ),
477 (
478 payout_summary::PATH,
479 mount(app, payout_summary::screen, &[], payout_summary::renderer),
480 ),
481 // A nest that answers no address of its own: the Members panel is read
482 // through its Askama route, which keeps a conditional GET, and only its
483 // writes are described. `03c0977b`; see `writes_only`.
484 (
485 project_members::NEST,
486 writes_only(app, project_members::WRITES, project_members::renderer),
487 ),
488 // The item dashboard's Sales panel: a parameterized read address, so
489 // the read stays on Askama and only the Refund write is described.
490 // `b25dd957`; see `writes_only`.
491 (
492 item_sales::NEST,
493 writes_only(app, item_sales::WRITES, item_sales::renderer),
494 ),
495 (
496 forum_memberships::SETTINGS_PATH,
497 mount(
498 app,
499 forum_memberships::settings_screen,
500 &[],
501 forum_memberships::renderer,
502 ),
503 ),
504 ]
505 }
506
507 /// Every described screen that owns its whole document, mounted.
508 ///
509 /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`].
510 pub fn document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
511 vec![
512 (
513 feeds::PATH,
514 document_mount(app, feeds::PATH, feeds::screen, feeds::renderer),
515 ),
516 (
517 export_portal::PATH,
518 document_mount(
519 app,
520 export_portal::PATH,
521 export_portal::screen,
522 export_portal::renderer,
523 ),
524 ),
525 ]
526 }
527
528 /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches.
529 ///
530 /// The difference is what a signed-out reader gets. [`viewer_factory`] refuses
531 /// with `denied` and quasi-axum answers that as a bare 403 with no body: right
532 /// for a panel fetched by a page that already checked, wrong for an address a
533 /// person can type. `tests/workflows/pages.rs::unauthorized_page_offers_login_and_signup`
534 /// is the shipped rule, so the gate runs [`crate::auth::authenticate`] in front
535 /// and answers whatever that refuses with, which renders the branded 401 with
536 /// its way back in. It is the same call the `AuthUser` extractor makes, so the
537 /// two paths cannot disagree about who is signed in.
538 ///
539 /// It costs one extra session read on this nest: the gate resolves the session
540 /// and the factory resolves it again. Stated rather than optimised, because the
541 /// alternative is a viewer whose `user` is optional, which is the signed-out
542 /// question the feed conversion deliberately did not answer.
543 ///
544 /// # The address is registered whole, not as `/`
545 ///
546 /// [`mount`]'s nests are mounted with `nest_service`, which strips the prefix
547 /// before the adapter sees the request. A document screen is mounted with
548 /// `CsrfRouter::route_service` instead (see there for why), which strips
549 /// nothing, so the router inside answers the address the reader typed.
550 fn document_mount(
551 app: &AppState,
552 path: &'static str,
553 screen: Screen,
554 renderer: fn(&Viewer) -> quasi_webview::Webview,
555 ) -> axum::Router {
556 let router = quasi_router::Router::<Viewer>::new().get(path, screen);
557 quasi_axum::Adapter::per_viewer(
558 router,
559 viewer_factory(app.clone(), Audience::Reader),
560 move |viewer, _, _| renderer(viewer),
561 )
562 .into_router()
563 .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in))
564 }
565
566 /// The gate in front of every document nest: a reader or a branded refusal.
567 async fn signed_in(
568 axum::extract::State(app): axum::extract::State<AppState>,
569 request: axum::extract::Request,
570 next: axum::middleware::Next,
571 ) -> axum::response::Response {
572 use axum::response::IntoResponse as _;
573
574 let Some(session) = request
575 .extensions()
576 .get::<tower_sessions::Session>()
577 .cloned()
578 else {
579 // The session layer runs in front of this. Its absence is wiring.
580 return crate::error::AppError::Internal(anyhow::anyhow!("no session layer"))
581 .into_response();
582 };
583 match crate::auth::authenticate(&session, &app).await {
584 Ok(_) => next.run(request).await,
585 Err(refusal) => refusal.into_response(),
586 }
587 }
588
589 /// Every described document a reader with no session can reach, mounted.
590 ///
591 /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in
592 /// [`document_mounts`] nor in [`public_mounts`].
593 pub fn public_document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
594 vec![
595 (
596 team::PATH,
597 public_document_mount(app, team::PATH, team::screen, team::renderer),
598 ),
599 (
600 use_cases::PATH,
601 public_document_mount(app, use_cases::PATH, use_cases::screen, use_cases::renderer),
602 ),
603 (
604 policy::PATH,
605 public_document_mount(app, policy::PATH, policy::screen, policy::renderer),
606 ),
607 (
608 fan_plus::PATH,
609 public_document_mount(app, fan_plus::PATH, fan_plus::screen, fan_plus::renderer),
610 ),
611 (
612 creators::PATH,
613 public_document_mount(app, creators::PATH, creators::screen, creators::renderer),
614 ),
615 (
616 collections::PATH,
617 public_document_mount(
618 app,
619 collections::PATH,
620 collections::screen,
621 collections::renderer,
622 ),
623 ),
624 // The git browse tree carries a per-IP cap on every read, and a
625 // described document taking one of its addresses has to carry it too:
626 // these routes walk bare repositories on disk. The mount is an
627 // `axum::Router`, so the layer goes on here rather than through a
628 // parameter, and the limiter is rebuilt from the same constants
629 // `routes::git` reads. See `git_repos`'s module header. Both git
630 // listings mount here, and a module that is declared but not mounted is
631 // an address nothing answers: `git_explore` shipped that way and its own
632 // tests did not run either, because an undeclared module is not compiled.
633 (
634 git_explore::PATH,
635 public_document_mount(
636 app,
637 git_explore::PATH,
638 git_explore::screen,
639 git_explore::renderer,
640 )
641 .layer(tower_governor::GovernorLayer::new(
642 crate::helpers::rate_limiter_ms(
643 crate::constants::GIT_BROWSE_RATE_LIMIT_MS,
644 crate::constants::GIT_BROWSE_RATE_LIMIT_BURST,
645 ),
646 )),
647 ),
648 (
649 git_repos::PATH,
650 public_document_mount(app, git_repos::PATH, git_repos::screen, git_repos::renderer)
651 .layer(tower_governor::GovernorLayer::new(
652 crate::helpers::rate_limiter_ms(
653 crate::constants::GIT_BROWSE_RATE_LIMIT_MS,
654 crate::constants::GIT_BROWSE_RATE_LIMIT_BURST,
655 ),
656 )),
657 ),
658 ]
659 }
660
661 /// A described document a reader NAVIGATES to with or without a session.
662 ///
663 /// [`document_mount`] with the two things that make it gated removed: the
664 /// factory is built on [`Audience::Anyone`], so a signed-out request builds a
665 /// viewer rather than being refused, and there is no [`signed_in`] layer in
666 /// front of it, so nothing turns that into a 401. What is left is identical,
667 /// including the exact-address registration -- see [`document_mount`] for why a
668 /// document is not mounted as a nest.
669 ///
670 /// The screens here are the sessionless pages: the ones whose whole purpose is
671 /// to be reachable by somebody who cannot sign in. A page that merely *reads*
672 /// better when signed in is still gated; the test is whether refusing a visitor
673 /// is the right answer.
674 ///
675 /// # Layers go on the returned router, at the call site
676 ///
677 /// This returns an `axum::Router`, so a mount that needs middleware takes it
678 /// with `.layer(..)` where it is registered rather than through a parameter
679 /// here. That matters because the middleware is per-address rather than per
680 /// mount kind: the git browse addresses carry a per-IP cap and the marketing
681 /// pages carry none, and threading an `Option<Layer>` through every call would
682 /// make the mount know about a policy that belongs to the route.
683 ///
684 /// **Check what the address carried before moving it.** A route lifted out of a
685 /// router with a `route_layer` silently loses that layer, and for the git tree
686 /// that would be a rate limit removed from a route that walks repositories on
687 /// disk. See `git_repos`.
688 fn public_document_mount(
689 app: &AppState,
690 path: &'static str,
691 screen: Screen,
692 renderer: fn(&Viewer) -> quasi_webview::Webview,
693 ) -> axum::Router {
694 let router = quasi_router::Router::<Viewer>::new().get(path, screen);
695 quasi_axum::Adapter::per_viewer(
696 router,
697 viewer_factory(app.clone(), Audience::Anyone),
698 move |viewer, _, _| renderer(viewer),
699 )
700 .into_router()
701 }
702
703 /// Every described screen a reader with no session can reach, mounted.
704 ///
705 /// Separate from [`mounts`] because of the factory, not because of the address:
706 /// [`viewer_factory`] resolves a session and refuses without one, which is the
707 /// right answer for every screen behind a login and the wrong one for a
708 /// marketing page. A public screen takes a state resolved once at startup, so
709 /// its adapter is `Adapter::new` rather than `Adapter::per_viewer`.
710 ///
711 /// Deliberately not in [`PATHS`]. That list is what the CSRF probe skips and
712 /// what `tests/workflows/described_screens.rs` presses with a signed-in
713 /// fixture, and both readings are about screens that hold a session. A public
714 /// screen registers no mutating route, so the probe has nothing to skip.
715 pub fn public_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
716 vec![
717 (pricing::PATH, pricing_mount(app)),
718 (shortcuts::PATH, shortcuts_mount()),
719 ]
720 }
721
722 /// The keyboard-shortcuts overlay, on the same shell every described screen
723 /// gets so the listing looks like the site it is drawn over.
724 ///
725 /// Its own mount rather than a route inside the pricing nest: an overlay
726 /// reachable from every screen is not one screen's route, and the binding's
727 /// address is absolute in the emitted markup (`e0c0d991`).
728 fn shortcuts_mount() -> axum::Router {
729 quasi_axum::Adapter::new(
730 shortcuts::router(),
731 std::sync::Arc::new(()),
732 std::sync::Arc::new(pricing::renderer()),
733 )
734 .into_router()
735 }
736
737 /// The fee calculator's own nest: the page and the recompute it answers.
738 fn pricing_mount(app: &AppState) -> axum::Router {
739 let state = std::sync::Arc::new(pricing::Pricing {
740 billing: crate::Billing::from_ref(app),
741 founder_window_open: app.config.creator_pricing.founder_window_open,
742 changelog_published: crate::changelog::is_published(),
743 });
744 let router = quasi_router::Router::<pricing::Pricing>::new()
745 .get("/", pricing::screen)
746 .get("/compare", pricing::compare);
747 quasi_axum::Adapter::new(router, state, std::sync::Arc::new(pricing::renderer())).into_router()
748 }
749
750 /// This server's own copy, as markdown.
751 ///
752 /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so its
753 /// links carry `nofollow`, its raw markup is dropped and fetchable schemes are
754 /// filtered. That is right for a forum post and wrong for a page's own
755 /// sentence, and until quasi grew the trust axis every described page here was
756 /// telling crawlers not to follow its own links (quasicoherent `24a3b1df`).
757 ///
758 /// The two axes stay separate in quasi -- `Richness` is what the format may
759 /// express, `Trust` is who wrote it -- and this is the one combination this
760 /// server reaches for often enough to name: a sentence, ours. A screen wanting
761 /// tables says so with `Node::richness`, and a screen carrying a reader's
762 /// markdown keeps `Node::rich`.
763 ///
764 /// **The test for using it is authorship, not tidiness.** The string has to be
765 /// a literal in this repository, or interpolated from a value that cannot carry
766 /// markup. A creator's description reaching a screen through the database is
767 /// `Node::rich` however well-behaved it has been.
768 #[must_use]
769 pub fn own_prose(source: impl Into<String>) -> quasi_router::Node {
770 quasi_router::Node::rich(source).trust(quasi_router::Trust::Trusted)
771 }
772
773 /// A handler, spelled once so the screens and the mount agree about it.
774 pub type Screen =
775 fn(&Viewer, quasi_router::Request) -> Result<quasi_router::Response, quasi_router::RouteError>;
776
777 /// One screen behind the adapter, answering the root of its own nest.
778 ///
779 /// The tab is `/` because the nest has already taken the address off: a screen
780 /// mounted at its own tab endpoint sees one route and never has to agree with
781 /// the prefix twice.
782 ///
783 /// # Why a screen serves its own writes
784 ///
785 /// `writes` registers routes under the same nest, and a destructive control on
786 /// a described screen should address one of them rather than the API route the
787 /// Askama version used. Decision 7 is the reason: a described route answers with
788 /// a `Response::Fragment` naming the region it changed, so the answer lands where
789 /// it belongs and carries the screen's own markup.
790 ///
791 /// An API route can do neither, and both described screens that called one were
792 /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx
793 /// request with the whole re-rendered Askama list, and with no target htmx put
794 /// that table inside the button that was pressed. `DELETE /api/contacts/{id}`
795 /// answers 204, which htmx is configured never to swap, so the row stays on
796 /// screen after a successful revoke. Neither is visible to a test that checks
797 /// only that the address exists.
798 ///
799 /// The write still goes through the same CSRF envelope: `crate::csrf` nests this
800 /// service under an Auto posture, and the core module attaches the token to
801 /// every htmx request on the page.
802 fn mount(
803 app: &AppState,
804 screen: Screen,
805 writes: &[(quasi_router::Method, &'static str, Screen)],
806 renderer: fn(&Viewer) -> quasi_webview::Webview,
807 ) -> axum::Router {
808 nest(app, Some(screen), writes, renderer)
809 }
810
811 /// A nest that serves writes and answers no address of its own.
812 ///
813 /// The hole it fills: a panel whose route answers a conditional GET cannot be a
814 /// mounted screen, because
815 /// [`mount`] has no way to say "304 if the cache generation has not moved". So
816 /// it stays a fill on its Askama handler -- and a fill has no nest, so it had
817 /// nowhere to put its writes, so its controls kept addressing API routes that
818 /// answer 200 or 204 and cannot name the region they changed. The patch for
819 /// that was `data-after`, the private dispatcher vocabulary in
820 /// `frontend/src/core/dispatch.ts` that this conversion exists to retire.
821 ///
822 /// This is the other half of such a panel: the read keeps its Askama route and
823 /// its ETag, and the writes get described routes that answer
824 /// `Response::Fragment` naming the panel's region, exactly as a mounted
825 /// screen's do.
826 ///
827 /// # The cost, stated once rather than per panel
828 ///
829 /// One panel is then served by two routers, and the read and the write are no
830 /// longer visible in one place. That is the trade: the alternative was nine
831 /// tabs converting their markup while keeping their JS, which lowers no seal
832 /// and is not what S4 is for.
833 ///
834 /// # The address is a fixed prefix, and the ids go inside it
835 ///
836 /// A nest is mounted at a fixed path, which is also why `project_analytics` is
837 /// a fill rather than a mounted screen. Path parameters live in the inner
838 /// router, which does support them: `ssh_keys` already registers `/keys/{id}`
839 /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids
840 /// in the inner paths and does not reuse the API route's address. That API
841 /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did
842 /// when `ssh_keys` moved its controls off it.
843 fn writes_only(
844 app: &AppState,
845 writes: &[(quasi_router::Method, &'static str, Screen)],
846 renderer: fn(&Viewer) -> quasi_webview::Webview,
847 ) -> axum::Router {
848 nest(app, None, writes, renderer)
849 }
850
851 /// The router both of the above build, with or without a root GET.
852 fn nest(
853 app: &AppState,
854 screen: Option<Screen>,
855 writes: &[(quasi_router::Method, &'static str, Screen)],
856 renderer: fn(&Viewer) -> quasi_webview::Webview,
857 ) -> axum::Router {
858 let mut quasi = quasi_router::Router::<Viewer>::new();
859 if let Some(screen) = screen {
860 quasi = quasi.get("/", screen);
861 }
862 for (method, path, handler) in writes {
863 quasi = match method {
864 quasi_router::Method::Delete => quasi.delete(path, *handler),
865 quasi_router::Method::Put => quasi.put(path, *handler),
866 quasi_router::Method::Get => quasi.get(path, *handler),
867 quasi_router::Method::Post => quasi.post(path, *handler),
868 };
869 }
870 quasi_axum::Adapter::per_viewer(
871 quasi,
872 viewer_factory(app.clone(), Audience::Reader),
873 move |viewer, _, _| renderer(viewer),
874 )
875 .into_router()
876 }
877
878 #[cfg(test)]
879 mod tests {
880 use super::*;
881
882 #[test]
883 fn every_screen_is_listed_in_paths() {
884 // The two lists are written by hand and read by two different things,
885 // so the check is that adding a screen to `mounts` and forgetting
886 // `PATHS` fails here rather than silently weakening the CSRF coverage
887 // probe's skip list.
888 assert_eq!(
889 PATHS.len(),
890 SCREENS.len(),
891 "PATHS and SCREENS describe the same screens"
892 );
893
894 let source = include_str!("mod.rs");
895 let mounted = source
896 .split_once("pub fn mounts(")
897 .expect("mounts exists")
898 .1
899 .split_once("\n}")
900 .expect("mounts ends")
901 .0;
902 // Counted as `mount(` since `64b33b26`. `mounts` used to push
903 // conditionally into a Vec, one `mounted.push((` per screen the switch
904 // had on; it returns a `vec![..]` literal now because every screen is
905 // mounted unconditionally, so the thing to count is the adapter call
906 // each entry makes. Not `mount(app,`: rustfmt breaks the longer entries
907 // across lines and that token then finds three of the six.
908 let registered = mounted.matches("mount(").count();
909 assert_eq!(
910 registered,
911 PATHS.len(),
912 "mounts registers {registered} screens, PATHS lists {}",
913 PATHS.len()
914 );
915 }
916
917 #[test]
918 fn every_document_screen_is_listed_in_document_paths() {
919 let source = include_str!("mod.rs");
920 let mounted = source
921 .split_once("pub fn document_mounts(")
922 .expect("document_mounts exists")
923 .1
924 .split_once("\n}")
925 .expect("document_mounts ends")
926 .0;
927 assert_eq!(
928 mounted.matches("document_mount(").count(),
929 DOCUMENT_PATHS.len(),
930 "document_mounts and DOCUMENT_PATHS describe the same screens"
931 );
932 }
933
934 #[test]
935 fn every_public_document_is_listed_in_public_document_paths() {
936 let source = include_str!("mod.rs");
937 let mounted = source
938 .split_once("pub fn public_document_mounts(")
939 .expect("public_document_mounts exists")
940 .1
941 .split_once("\n}")
942 .expect("public_document_mounts ends")
943 .0;
944 assert_eq!(
945 mounted.matches("public_document_mount(").count(),
946 PUBLIC_DOCUMENT_PATHS.len(),
947 "public_document_mounts and PUBLIC_DOCUMENT_PATHS describe the same screens"
948 );
949 }
950
951 /// Every git address carries the browse limiter, checked in the source
952 /// because there is nothing to ask an `axum::Router` about afterwards.
953 ///
954 /// This is the check the whole `/git` conversion turns on. The tree's reads
955 /// sit under one `route_layer` in `routes::git`, so an address lifted out of
956 /// it and mounted here loses that layer silently: nothing fails to compile,
957 /// no test fails, and a route that walks bare repositories on disk stops
958 /// being capped. A conversion that forgets it fails here instead.
959 #[test]
960 fn every_described_git_address_keeps_the_browse_limiter() {
961 let source = include_str!("mod.rs");
962 let mounted = source
963 .split_once("pub fn public_document_mounts(")
964 .expect("public_document_mounts exists")
965 .1
966 .split_once("\n}")
967 .expect("public_document_mounts ends")
968 .0;
969
970 let git_paths = PUBLIC_DOCUMENT_PATHS
971 .iter()
972 .filter(|path| path.starts_with("/git"))
973 .count();
974 assert!(git_paths > 0, "no git document is mounted yet");
975 assert_eq!(
976 mounted.matches("GIT_BROWSE_RATE_LIMIT_MS").count(),
977 git_paths,
978 "a described /git address is mounted without the browse limiter"
979 );
980 }
981
982 /// The two document lists differ by exactly one thing, and it is the one
983 /// that matters: a gated document refuses a visitor, a public one renders to
984 /// them. Nothing else about the mount changes, so a screen in the wrong list
985 /// is a page that 401s or a page that leaks, depending on the direction.
986 #[test]
987 fn no_document_is_in_both_lists() {
988 for path in PUBLIC_DOCUMENT_PATHS {
989 assert!(
990 !DOCUMENT_PATHS.contains(path),
991 "{path} is mounted both gated and public"
992 );
993 }
994 }
995
996 #[test]
997 fn a_path_is_claimed_by_exactly_one_screen() {
998 // Two screens on one address is an axum panic at startup, and the two
999 // forum-memberships screens are the near miss: one module, two paths.
1000 let mut seen = PATHS.to_vec();
1001 seen.extend_from_slice(DOCUMENT_PATHS);
1002 seen.extend_from_slice(PUBLIC_DOCUMENT_PATHS);
1003 seen.sort_unstable();
1004 let before = seen.len();
1005 seen.dedup();
1006 assert_eq!(
1007 before,
1008 seen.len(),
1009 "two screens claim one address: {seen:?}"
1010 );
1011 }
1012 }
1013