Skip to main content

max / makenotwork

29.3 KB · 686 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
41 pub mod blog_delete_act;
42 pub mod buyer_contacts;
43 pub mod custom_page;
44 pub mod discover_search;
45 pub mod discover_typeahead;
46 pub mod embeds;
47 pub mod export_act;
48 pub mod feeds;
49 pub mod forum_memberships;
50 pub mod item_files;
51 pub mod item_sales;
52 pub mod item_tabs;
53 pub mod library_contacts;
54 pub mod library_tabs;
55 pub mod media_picker;
56 pub mod payout_summary;
57 pub mod pricing;
58 pub mod project_analytics;
59 pub mod project_content;
60 pub mod project_members;
61 pub mod project_overview;
62 pub mod project_tabs;
63 pub mod rich_field;
64 pub mod schedule_field;
65 pub mod settings_tabs;
66 pub mod shortcuts;
67 pub mod ssh_keys;
68 pub mod upload_field;
69 pub mod user_analytics;
70 pub mod user_projects;
71 pub mod user_support;
72 pub mod user_tabs;
73 pub mod version_delete_act;
74 pub mod widgets;
75
76 /// The state one request is answered against.
77 ///
78 /// Built per request by the adapter's factory, which is the whole of quasi's
79 /// answer to a server: everything resolvable from the request head is loaded
80 /// before the sync router runs, and a handler reads it off `&S` the way every
81 /// other host's handler reads its app.
82 pub struct Viewer {
83 /// The long-lived application state. Cloning it clones handles, not data.
84 pub app: AppState,
85 /// Who is asking. Resolved and revocation-checked by
86 /// [`crate::auth::authenticate`], the same path the extractor takes.
87 pub user: SessionUser,
88 /// The runtime the request arrived on, so a sync handler can reach the
89 /// async database. Captured in the factory rather than read inside the
90 /// handler: `Handle::current` works on a blocking thread today, and
91 /// depending on that is depending on where quasi-axum happens to dispatch.
92 pub runtime: Handle,
93 /// This session's CSRF token, for the shell to hand to the document.
94 ///
95 /// Resolved in the factory rather than in a renderer because minting one is
96 /// an async session write and a renderer is sync. It is the same token the
97 /// Askama pages carry: [`crate::csrf::get_or_create_token`] is
98 /// get-or-create, so a described page and a templated one in the same
99 /// session agree, and validation is one comparison either way.
100 pub csrf: String,
101 /// This request's session-tracking id, when it has one.
102 ///
103 /// The same class of fact as [`csrf`](Self::csrf) and resolved the same
104 /// way: reading it is an async session lookup, so the factory does it and
105 /// the sync handler reads the answer off `&S`. It is what lets a screen
106 /// tell the reader's own row apart from the rest, which
107 /// `user_sessions` needs twice over: the `Current` badge, and the one row
108 /// that offers no `Sign out`.
109 ///
110 /// `None` is a real state rather than a failure. A session predating
111 /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a
112 /// screen answering for one marks no row as current.
113 pub session_id: Option<crate::db::UserSessionId>,
114 /// Markup for the bespoke regions this request's screen describes.
115 ///
116 /// The seam between a handler and its renderer, and the reason it has to be
117 /// here rather than in either of them: a `Region::Bespoke` is filled on the
118 /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the
119 /// handler has answered, and the renderer factory is handed `&S` and the
120 /// answer. So the handler writes what it drew here and the renderer reads
121 /// it back off the same state. Both see one instance: the adapter builds a
122 /// single `Arc<Viewer>` per request and passes it to the router and then to
123 /// the factory.
124 ///
125 /// Behind a lock because the handler holds `&Viewer` and runs on a blocking
126 /// thread. Uncontended in practice: one request writes it, then one
127 /// renderer reads it, never at once.
128 fills: std::sync::Mutex<std::collections::HashMap<String, String>>,
129 }
130
131 impl Viewer {
132 /// Run a database future from inside a sync handler.
133 ///
134 /// The blocking hop, named in one place so the thing being measured is
135 /// countable rather than spread across every handler. Every call holds this
136 /// blocking thread until the query answers.
137 pub fn block_on<F: Future>(&self, future: F) -> F::Output {
138 self.runtime.block_on(future)
139 }
140
141 /// Hand the renderer the markup for one bespoke region.
142 ///
143 /// Called by a handler while it builds its description, keyed by the slot
144 /// id the description gives that region. Markup, not text: a bespoke region
145 /// is the app's own and is not escaped, which is the whole of what makes it
146 /// bespoke and the whole of why a handler must not put a reader's string in
147 /// one without escaping it first.
148 pub fn fill(&self, slot_id: impl Into<String>, markup: impl Into<String>) {
149 if let Ok(mut fills) = self.fills.lock() {
150 fills.insert(slot_id.into(), markup.into());
151 }
152 }
153
154 /// The shell every described screen is drawn in.
155 ///
156 /// Here rather than in each screen's `renderer` because of what it carries:
157 /// a described page whose shell does not declare the session token has
158 /// every write on it refused, and the five screens that wrote
159 /// `Shell::under("/static")` out by hand were five chances to forget. A
160 /// screen that wants more says so on top of this; a screen that says
161 /// nothing gets the token anyway.
162 ///
163 /// The layer order matches `crate::shell`, which is the Askama half of the
164 /// same document: `makeover` is prepended by the renderer, and the site's
165 /// own sheets live in `components`.
166 #[must_use]
167 pub fn shell(&self) -> quasi_webview::Shell {
168 quasi_webview::Shell::under("/static")
169 .layered(["base", "components", "responsive"])
170 // Every described write is an htmx request, and htmx inherits this
171 // from the body, so one declaration covers the whole document.
172 .sending("X-CSRF-Token", &self.csrf)
173 }
174
175 /// The shell a described screen that owns its whole DOCUMENT is drawn in.
176 ///
177 /// [`shell`](Self::shell) is right for a fragment landing inside an Askama
178 /// page, which already has the head, the tail and the token meta. A
179 /// document owes all three itself: [`crate::shell::described`] is the same
180 /// builder `base.html` renders through, [`crate::shell::body_last`] is the
181 /// toast container and the classic shims, and the token meta is what the
182 /// pre-module scripts read.
183 ///
184 /// The meta is not redundant with `Shell::sending`. That covers htmx, and
185 /// `frontend/src/core/net.ts`, `frontend/src/core/htmx-glue.ts`,
186 /// `static/passkey.js` and `static/project-sections.js` all read
187 /// `meta[name=csrf-token]` instead. `/pricing` needed none of it because it
188 /// holds no session.
189 #[must_use]
190 pub fn document_shell(&self) -> quasi_webview::Shell {
191 crate::shell::described()
192 .sending("X-CSRF-Token", &self.csrf)
193 .with_body_last(crate::shell::body_last())
194 .with_chrome(crate::quasi::shortcuts::chrome())
195 .with_head(format!(
196 "<meta name=\"csrf-token\" content=\"{}\">",
197 crate::helpers::escape_html(&self.csrf)
198 ))
199 }
200
201 /// Everything the handler drew, for the renderer to mount.
202 pub fn drawn(&self) -> std::collections::HashMap<String, String> {
203 self.fills.lock().map(|f| f.clone()).unwrap_or_default()
204 }
205 }
206
207 /// Build the state factory the adapter calls per request.
208 ///
209 /// Refuses with `Unauthorized` when there is no session to resolve, which the
210 /// adapter turns into a bare status with no body. That is the right shape here
211 /// and not a shortcut: a store that will not answer is not a signed-out reader,
212 /// and rendering a sign-in notice would need the renderer that is built from
213 /// the state that could not be resolved.
214 fn viewer_factory(
215 app: AppState,
216 ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static {
217 move |parts| {
218 let app = app.clone();
219 let runtime = Handle::current();
220 // Taken out of the head first, so the future owns what it needs.
221 let session = parts.extensions.get::<tower_sessions::Session>().cloned();
222 Box::pin(async move {
223 let Some(session) = session else {
224 // The session layer runs in front of this. Its absence is a
225 // wiring mistake rather than a signed-out reader.
226 return Err(quasi_router::RouteError::internal("no session layer"));
227 };
228 match crate::auth::authenticate(&session, &app).await {
229 Ok(user) => {
230 // Before the handler runs, because the write that mints a
231 // token has to finish on the session this request holds.
232 // A failure here is the session store, not the reader.
233 let csrf = crate::csrf::get_or_create_token(&session)
234 .await
235 .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
236 // Same reason as `csrf`: an async read a sync handler
237 // cannot do. Absent on a legacy session, which is a state
238 // the screens describe rather than an error.
239 let session_id = session
240 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
241 .await
242 .ok()
243 .flatten();
244 Ok(Viewer {
245 app,
246 user,
247 runtime,
248 csrf,
249 session_id,
250 fills: std::sync::Mutex::default(),
251 })
252 }
253 Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")),
254 }
255 })
256 }
257 }
258
259 /// Every converted screen that is switched on, with the address it answers.
260 ///
261 /// One mount per screen rather than one router for all of them, because axum
262 /// strips a nest's prefix before the inner service sees the request: a single
263 /// nest covering both would have to sit at a prefix the Askama routes also live
264 /// under, and matchit refuses to hold a wildcard beside the parameterised routes
265 /// already there. Measured, not assumed: nesting at `/dashboard/project` panics
266 /// at startup against `/dashboard/project/{slug}/tabs/overview`.
267 ///
268 /// The list is empty when nothing is switched on, so the caller registers its
269 /// Askama routes exactly as before and the adapter is not in the stack at all. A
270 /// conversion is a startup-time choice: config is read once, and a per-request
271 /// branch would pay for a switch that never moves.
272 /// Every address a described screen can claim, switched on or not.
273 ///
274 /// `mounts` returns only what is currently on, which depends on config. This is
275 /// the whole set, and it exists for the CSRF coverage test: the manifest that
276 /// test reads is a process-global, so a test that switches a screen on leaves an
277 /// entry behind for a path the default router does not serve, and the probe
278 /// reads that as a route that lost its protection. The list lets it skip exactly
279 /// those and nothing else.
280 ///
281 /// Checked against `mounts` below rather than trusted, since a screen added to
282 /// one and not the other is the obvious way for this to rot.
283 pub const PATHS: &[&str] = &[
284 ssh_keys::PATH,
285 library_contacts::PATH,
286 buyer_contacts::PATH,
287 user_analytics::PATH,
288 payout_summary::PATH,
289 forum_memberships::LIBRARY_PATH,
290 forum_memberships::SETTINGS_PATH,
291 ];
292
293 /// Every described screen that owns its whole document, with the address it
294 /// answers.
295 ///
296 /// Its own list rather than an entry in [`PATHS`], for the same reason
297 /// [`public_mounts`] keeps its own: what a nest answers with decides what a
298 /// test can assert about it. A panel screen answers `Response::Fragment` and
299 /// names the region it changed, which `tests/workflows/described_screens.rs`
300 /// checks by reading `HX-Retarget` off every entry in [`PATHS`]. A document
301 /// answers `Outcome::Screen`, which sets no such header and is not a defect.
302 ///
303 /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen
304 /// registers no mutating route, so it has nothing to skip here either.
305 pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH];
306
307 /// Every screen's switch name, in the same order as [`PATHS`].
308 ///
309 /// Test-only: the switches themselves are read from each screen's own `SCREEN`
310 /// in `mounts`, and this exists so the consistency check below has both halves
311 /// to compare. Kept beside `PATHS` rather than inside the test module, because
312 /// the pairing is the thing being asserted and splitting them is how they drift.
313 #[cfg(test)]
314 const SCREENS: &[&str] = &[
315 ssh_keys::SCREEN,
316 library_contacts::SCREEN,
317 buyer_contacts::SCREEN,
318 user_analytics::SCREEN,
319 payout_summary::SCREEN,
320 forum_memberships::LIBRARY_SCREEN,
321 forum_memberships::SETTINGS_SCREEN,
322 ];
323
324 /// Every described screen, mounted.
325 ///
326 /// Unconditional since `64b33b26`. Each entry used to be gated on
327 /// `described(app, ..)` reading `QUASI_SCREENS`, so a screen could be switched
328 /// off and its Askama rendering registered instead. There is no Askama
329 /// rendering to fall back to any more, and the route tables no longer register
330 /// one, so a screen missing from this list is an address nothing answers.
331 pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
332 vec![
333 (
334 ssh_keys::PATH,
335 mount(app, ssh_keys::screen, ssh_keys::WRITES, ssh_keys::renderer),
336 ),
337 (
338 library_contacts::PATH,
339 mount(
340 app,
341 library_contacts::screen,
342 library_contacts::WRITES,
343 library_contacts::renderer,
344 ),
345 ),
346 // One module, two screens: the library tab and the settings section are
347 // the same table under different chrome.
348 (
349 forum_memberships::LIBRARY_PATH,
350 mount(
351 app,
352 forum_memberships::library_screen,
353 &[],
354 forum_memberships::renderer,
355 ),
356 ),
357 (
358 buyer_contacts::PATH,
359 mount(app, buyer_contacts::screen, &[], buyer_contacts::renderer),
360 ),
361 (
362 user_analytics::PATH,
363 mount(app, user_analytics::screen, &[], user_analytics::renderer),
364 ),
365 (
366 payout_summary::PATH,
367 mount(app, payout_summary::screen, &[], payout_summary::renderer),
368 ),
369 // A nest that answers no address of its own: the Members panel is read
370 // through its Askama route, which keeps a conditional GET, and only its
371 // writes are described. `03c0977b`; see `writes_only`.
372 (
373 project_members::NEST,
374 writes_only(app, project_members::WRITES, project_members::renderer),
375 ),
376 // The item dashboard's Sales panel: a parameterized read address, so
377 // the read stays on Askama and only the Refund write is described.
378 // `b25dd957`; see `writes_only`.
379 (
380 item_sales::NEST,
381 writes_only(app, item_sales::WRITES, item_sales::renderer),
382 ),
383 (
384 forum_memberships::SETTINGS_PATH,
385 mount(
386 app,
387 forum_memberships::settings_screen,
388 &[],
389 forum_memberships::renderer,
390 ),
391 ),
392 ]
393 }
394
395 /// Every described screen that owns its whole document, mounted.
396 ///
397 /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`].
398 pub fn document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
399 vec![(
400 feeds::PATH,
401 document_mount(app, feeds::PATH, feeds::screen, feeds::renderer),
402 )]
403 }
404
405 /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches.
406 ///
407 /// The difference is what a signed-out reader gets. [`viewer_factory`] refuses
408 /// with `denied` and quasi-axum answers that as a bare 403 with no body: right
409 /// for a panel fetched by a page that already checked, wrong for an address a
410 /// person can type. `tests/workflows/pages.rs::unauthorized_page_offers_login_and_signup`
411 /// is the shipped rule, so the gate runs [`crate::auth::authenticate`] in front
412 /// and answers whatever that refuses with, which renders the branded 401 with
413 /// its way back in. It is the same call the `AuthUser` extractor makes, so the
414 /// two paths cannot disagree about who is signed in.
415 ///
416 /// It costs one extra session read on this nest: the gate resolves the session
417 /// and the factory resolves it again. Stated rather than optimised, because the
418 /// alternative is a viewer whose `user` is optional, which is the signed-out
419 /// question the feed conversion deliberately did not answer.
420 ///
421 /// # The address is registered whole, not as `/`
422 ///
423 /// [`mount`]'s nests are mounted with `nest_service`, which strips the prefix
424 /// before the adapter sees the request. A document screen is mounted with
425 /// `CsrfRouter::route_service` instead (see there for why), which strips
426 /// nothing, so the router inside answers the address the reader typed.
427 fn document_mount(
428 app: &AppState,
429 path: &'static str,
430 screen: Screen,
431 renderer: fn(&Viewer) -> quasi_webview::Webview,
432 ) -> axum::Router {
433 let router = quasi_router::Router::<Viewer>::new().get(path, screen);
434 quasi_axum::Adapter::per_viewer(router, viewer_factory(app.clone()), move |viewer, _, _| {
435 renderer(viewer)
436 })
437 .into_router()
438 .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in))
439 }
440
441 /// The gate in front of every document nest: a reader or a branded refusal.
442 async fn signed_in(
443 axum::extract::State(app): axum::extract::State<AppState>,
444 request: axum::extract::Request,
445 next: axum::middleware::Next,
446 ) -> axum::response::Response {
447 use axum::response::IntoResponse as _;
448
449 let Some(session) = request
450 .extensions()
451 .get::<tower_sessions::Session>()
452 .cloned()
453 else {
454 // The session layer runs in front of this. Its absence is wiring.
455 return crate::error::AppError::Internal(anyhow::anyhow!("no session layer"))
456 .into_response();
457 };
458 match crate::auth::authenticate(&session, &app).await {
459 Ok(_) => next.run(request).await,
460 Err(refusal) => refusal.into_response(),
461 }
462 }
463
464 /// Every described screen a reader with no session can reach, mounted.
465 ///
466 /// Separate from [`mounts`] because of the factory, not because of the address:
467 /// [`viewer_factory`] resolves a session and refuses without one, which is the
468 /// right answer for every screen behind a login and the wrong one for a
469 /// marketing page. A public screen takes a state resolved once at startup, so
470 /// its adapter is `Adapter::new` rather than `Adapter::per_viewer`.
471 ///
472 /// Deliberately not in [`PATHS`]. That list is what the CSRF probe skips and
473 /// what `tests/workflows/described_screens.rs` presses with a signed-in
474 /// fixture, and both readings are about screens that hold a session. A public
475 /// screen registers no mutating route, so the probe has nothing to skip.
476 pub fn public_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
477 vec![
478 (pricing::PATH, pricing_mount(app)),
479 (shortcuts::PATH, shortcuts_mount()),
480 ]
481 }
482
483 /// The keyboard-shortcuts overlay, on the same shell every described screen
484 /// gets so the listing looks like the site it is drawn over.
485 ///
486 /// Its own mount rather than a route inside the pricing nest: an overlay
487 /// reachable from every screen is not one screen's route, and the binding's
488 /// address is absolute in the emitted markup (`e0c0d991`).
489 fn shortcuts_mount() -> axum::Router {
490 quasi_axum::Adapter::new(
491 shortcuts::router(),
492 std::sync::Arc::new(()),
493 std::sync::Arc::new(pricing::renderer()),
494 )
495 .into_router()
496 }
497
498 /// The fee calculator's own nest: the page and the recompute it answers.
499 fn pricing_mount(app: &AppState) -> axum::Router {
500 let state = std::sync::Arc::new(pricing::Pricing {
501 billing: crate::Billing::from_ref(app),
502 founder_window_open: app.config.creator_pricing.founder_window_open,
503 changelog_published: crate::changelog::is_published(),
504 });
505 let router = quasi_router::Router::<pricing::Pricing>::new()
506 .get("/", pricing::screen)
507 .get("/compare", pricing::compare);
508 quasi_axum::Adapter::new(router, state, std::sync::Arc::new(pricing::renderer())).into_router()
509 }
510
511 /// A handler, spelled once so the screens and the mount agree about it.
512 pub type Screen =
513 fn(&Viewer, quasi_router::Request) -> Result<quasi_router::Response, quasi_router::RouteError>;
514
515 /// One screen behind the adapter, answering the root of its own nest.
516 ///
517 /// The tab is `/` because the nest has already taken the address off: a screen
518 /// mounted at its own tab endpoint sees one route and never has to agree with
519 /// the prefix twice.
520 ///
521 /// # Why a screen serves its own writes
522 ///
523 /// `writes` registers routes under the same nest, and a destructive control on
524 /// a described screen should address one of them rather than the API route the
525 /// Askama version used. Decision 7 is the reason: a described route answers with
526 /// a `Response::Fragment` naming the region it changed, so the answer lands where
527 /// it belongs and carries the screen's own markup.
528 ///
529 /// An API route can do neither, and both described screens that called one were
530 /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx
531 /// request with the whole re-rendered Askama list, and with no target htmx put
532 /// that table inside the button that was pressed. `DELETE /api/contacts/{id}`
533 /// answers 204, which htmx is configured never to swap, so the row stays on
534 /// screen after a successful revoke. Neither is visible to a test that checks
535 /// only that the address exists.
536 ///
537 /// The write still goes through the same CSRF envelope: `crate::csrf` nests this
538 /// service under an Auto posture, and the core module attaches the token to
539 /// every htmx request on the page.
540 fn mount(
541 app: &AppState,
542 screen: Screen,
543 writes: &[(quasi_router::Method, &'static str, Screen)],
544 renderer: fn(&Viewer) -> quasi_webview::Webview,
545 ) -> axum::Router {
546 nest(app, Some(screen), writes, renderer)
547 }
548
549 /// A nest that serves writes and answers no address of its own.
550 ///
551 /// The hole it fills: a panel whose route answers a conditional GET cannot be a
552 /// mounted screen, because
553 /// [`mount`] has no way to say "304 if the cache generation has not moved". So
554 /// it stays a fill on its Askama handler -- and a fill has no nest, so it had
555 /// nowhere to put its writes, so its controls kept addressing API routes that
556 /// answer 200 or 204 and cannot name the region they changed. The patch for
557 /// that was `data-after`, the private dispatcher vocabulary in
558 /// `frontend/src/core/dispatch.ts` that this conversion exists to retire.
559 ///
560 /// This is the other half of such a panel: the read keeps its Askama route and
561 /// its ETag, and the writes get described routes that answer
562 /// `Response::Fragment` naming the panel's region, exactly as a mounted
563 /// screen's do.
564 ///
565 /// # The cost, stated once rather than per panel
566 ///
567 /// One panel is then served by two routers, and the read and the write are no
568 /// longer visible in one place. That is the trade: the alternative was nine
569 /// tabs converting their markup while keeping their JS, which lowers no seal
570 /// and is not what S4 is for.
571 ///
572 /// # The address is a fixed prefix, and the ids go inside it
573 ///
574 /// A nest is mounted at a fixed path, which is also why `project_analytics` is
575 /// a fill rather than a mounted screen. Path parameters live in the inner
576 /// router, which does support them: `ssh_keys` already registers `/keys/{id}`
577 /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids
578 /// in the inner paths and does not reuse the API route's address. That API
579 /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did
580 /// when `ssh_keys` moved its controls off it.
581 fn writes_only(
582 app: &AppState,
583 writes: &[(quasi_router::Method, &'static str, Screen)],
584 renderer: fn(&Viewer) -> quasi_webview::Webview,
585 ) -> axum::Router {
586 nest(app, None, writes, renderer)
587 }
588
589 /// The router both of the above build, with or without a root GET.
590 fn nest(
591 app: &AppState,
592 screen: Option<Screen>,
593 writes: &[(quasi_router::Method, &'static str, Screen)],
594 renderer: fn(&Viewer) -> quasi_webview::Webview,
595 ) -> axum::Router {
596 let mut quasi = quasi_router::Router::<Viewer>::new();
597 if let Some(screen) = screen {
598 quasi = quasi.get("/", screen);
599 }
600 for (method, path, handler) in writes {
601 quasi = match method {
602 quasi_router::Method::Delete => quasi.delete(path, *handler),
603 quasi_router::Method::Put => quasi.put(path, *handler),
604 quasi_router::Method::Get => quasi.get(path, *handler),
605 quasi_router::Method::Post => quasi.post(path, *handler),
606 };
607 }
608 quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), move |viewer, _, _| {
609 renderer(viewer)
610 })
611 .into_router()
612 }
613
614 #[cfg(test)]
615 mod tests {
616 use super::*;
617
618 #[test]
619 fn every_screen_is_listed_in_paths() {
620 // The two lists are written by hand and read by two different things,
621 // so the check is that adding a screen to `mounts` and forgetting
622 // `PATHS` fails here rather than silently weakening the CSRF coverage
623 // probe's skip list.
624 assert_eq!(
625 PATHS.len(),
626 SCREENS.len(),
627 "PATHS and SCREENS describe the same screens"
628 );
629
630 let source = include_str!("mod.rs");
631 let mounted = source
632 .split_once("pub fn mounts(")
633 .expect("mounts exists")
634 .1
635 .split_once("\n}")
636 .expect("mounts ends")
637 .0;
638 // Counted as `mount(` since `64b33b26`. `mounts` used to push
639 // conditionally into a Vec, one `mounted.push((` per screen the switch
640 // had on; it returns a `vec![..]` literal now because every screen is
641 // mounted unconditionally, so the thing to count is the adapter call
642 // each entry makes. Not `mount(app,`: rustfmt breaks the longer entries
643 // across lines and that token then finds three of the six.
644 let registered = mounted.matches("mount(").count();
645 assert_eq!(
646 registered,
647 PATHS.len(),
648 "mounts registers {registered} screens, PATHS lists {}",
649 PATHS.len()
650 );
651 }
652
653 #[test]
654 fn every_document_screen_is_listed_in_document_paths() {
655 let source = include_str!("mod.rs");
656 let mounted = source
657 .split_once("pub fn document_mounts(")
658 .expect("document_mounts exists")
659 .1
660 .split_once("\n}")
661 .expect("document_mounts ends")
662 .0;
663 assert_eq!(
664 mounted.matches("document_mount(").count(),
665 DOCUMENT_PATHS.len(),
666 "document_mounts and DOCUMENT_PATHS describe the same screens"
667 );
668 }
669
670 #[test]
671 fn a_path_is_claimed_by_exactly_one_screen() {
672 // Two screens on one address is an axum panic at startup, and the two
673 // forum-memberships screens are the near miss: one module, two paths.
674 let mut seen = PATHS.to_vec();
675 seen.extend_from_slice(DOCUMENT_PATHS);
676 seen.sort_unstable();
677 let before = seen.len();
678 seen.dedup();
679 assert_eq!(
680 before,
681 seen.len(),
682 "two screens claim one address: {seen:?}"
683 );
684 }
685 }
686