Skip to main content

max / makenotwork

21.7 KB · 504 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 crate::AppState;
37 use crate::auth::SessionUser;
38
39 pub mod blog_delete_act;
40 pub mod buyer_contacts;
41 pub mod discover_search;
42 pub mod discover_typeahead;
43 pub mod embeds;
44 pub mod export_act;
45 pub mod forum_memberships;
46 pub mod item_sales;
47 pub mod item_tabs;
48 pub mod library_contacts;
49 pub mod library_tabs;
50 pub mod media_picker;
51 pub mod payout_summary;
52 pub mod project_analytics;
53 pub mod project_content;
54 pub mod project_members;
55 pub mod project_overview;
56 pub mod project_tabs;
57 pub mod rich_field;
58 pub mod settings_tabs;
59 pub mod ssh_keys;
60 pub mod upload_field;
61 pub mod user_analytics;
62 pub mod user_projects;
63 pub mod user_support;
64 pub mod user_tabs;
65 pub mod version_delete_act;
66 pub mod widgets;
67
68 /// The state one request is answered against.
69 ///
70 /// Built per request by the adapter's factory, which is the whole of quasi's
71 /// answer to a server: everything resolvable from the request head is loaded
72 /// before the sync router runs, and a handler reads it off `&S` the way every
73 /// other host's handler reads its app.
74 pub struct Viewer {
75 /// The long-lived application state. Cloning it clones handles, not data.
76 pub app: AppState,
77 /// Who is asking. Resolved and revocation-checked by
78 /// [`crate::auth::authenticate`], the same path the extractor takes.
79 pub user: SessionUser,
80 /// The runtime the request arrived on, so a sync handler can reach the
81 /// async database. Captured in the factory rather than read inside the
82 /// handler: `Handle::current` works on a blocking thread today, and
83 /// depending on that is depending on where quasi-axum happens to dispatch.
84 pub runtime: Handle,
85 /// This session's CSRF token, for the shell to hand to the document.
86 ///
87 /// Resolved in the factory rather than in a renderer because minting one is
88 /// an async session write and a renderer is sync. It is the same token the
89 /// Askama pages carry: [`crate::csrf::get_or_create_token`] is
90 /// get-or-create, so a described page and a templated one in the same
91 /// session agree, and validation is one comparison either way.
92 pub csrf: String,
93 /// This request's session-tracking id, when it has one.
94 ///
95 /// The same class of fact as [`csrf`](Self::csrf) and resolved the same
96 /// way: reading it is an async session lookup, so the factory does it and
97 /// the sync handler reads the answer off `&S`. It is what lets a screen
98 /// tell the reader's own row apart from the rest, which
99 /// `user_sessions` needs twice over: the `Current` badge, and the one row
100 /// that offers no `Sign out`.
101 ///
102 /// `None` is a real state rather than a failure. A session predating
103 /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a
104 /// screen answering for one marks no row as current.
105 pub session_id: Option<crate::db::UserSessionId>,
106 /// Markup for the bespoke regions this request's screen describes.
107 ///
108 /// The seam between a handler and its renderer, and the reason it has to be
109 /// here rather than in either of them: a `Region::Bespoke` is filled on the
110 /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the
111 /// handler has answered, and the renderer factory is handed `&S` and the
112 /// answer. So the handler writes what it drew here and the renderer reads
113 /// it back off the same state. Both see one instance: the adapter builds a
114 /// single `Arc<Viewer>` per request and passes it to the router and then to
115 /// the factory.
116 ///
117 /// Behind a lock because the handler holds `&Viewer` and runs on a blocking
118 /// thread. Uncontended in practice: one request writes it, then one
119 /// renderer reads it, never at once.
120 fills: std::sync::Mutex<std::collections::HashMap<String, String>>,
121 }
122
123 impl Viewer {
124 /// Run a database future from inside a sync handler.
125 ///
126 /// The blocking hop, named in one place so the thing being measured is
127 /// countable rather than spread across every handler. Every call holds this
128 /// blocking thread until the query answers.
129 pub fn block_on<F: Future>(&self, future: F) -> F::Output {
130 self.runtime.block_on(future)
131 }
132
133 /// Hand the renderer the markup for one bespoke region.
134 ///
135 /// Called by a handler while it builds its description, keyed by the slot
136 /// id the description gives that region. Markup, not text: a bespoke region
137 /// is the app's own and is not escaped, which is the whole of what makes it
138 /// bespoke and the whole of why a handler must not put a reader's string in
139 /// one without escaping it first.
140 pub fn fill(&self, slot_id: impl Into<String>, markup: impl Into<String>) {
141 if let Ok(mut fills) = self.fills.lock() {
142 fills.insert(slot_id.into(), markup.into());
143 }
144 }
145
146 /// The shell every described screen is drawn in.
147 ///
148 /// Here rather than in each screen's `renderer` because of what it carries:
149 /// a described page whose shell does not declare the session token has
150 /// every write on it refused, and the five screens that wrote
151 /// `Shell::under("/static")` out by hand were five chances to forget. A
152 /// screen that wants more says so on top of this; a screen that says
153 /// nothing gets the token anyway.
154 ///
155 /// The layer order matches `crate::shell`, which is the Askama half of the
156 /// same document: `makeover` is prepended by the renderer, and the site's
157 /// own sheets live in `components`.
158 #[must_use]
159 pub fn shell(&self) -> quasi_webview::Shell {
160 quasi_webview::Shell::under("/static")
161 .layered(["base", "components", "responsive"])
162 // Every described write is an htmx request, and htmx inherits this
163 // from the body, so one declaration covers the whole document.
164 .sending("X-CSRF-Token", &self.csrf)
165 }
166
167 /// Everything the handler drew, for the renderer to mount.
168 pub fn drawn(&self) -> std::collections::HashMap<String, String> {
169 self.fills.lock().map(|f| f.clone()).unwrap_or_default()
170 }
171 }
172
173 /// Build the state factory the adapter calls per request.
174 ///
175 /// Refuses with `Unauthorized` when there is no session to resolve, which the
176 /// adapter turns into a bare status with no body. That is the right shape here
177 /// and not a shortcut: a store that will not answer is not a signed-out reader,
178 /// and rendering a sign-in notice would need the renderer that is built from
179 /// the state that could not be resolved.
180 fn viewer_factory(
181 app: AppState,
182 ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static {
183 move |parts| {
184 let app = app.clone();
185 let runtime = Handle::current();
186 // Taken out of the head first, so the future owns what it needs.
187 let session = parts.extensions.get::<tower_sessions::Session>().cloned();
188 Box::pin(async move {
189 let Some(session) = session else {
190 // The session layer runs in front of this. Its absence is a
191 // wiring mistake rather than a signed-out reader.
192 return Err(quasi_router::RouteError::internal("no session layer"));
193 };
194 match crate::auth::authenticate(&session, &app).await {
195 Ok(user) => {
196 // Before the handler runs, because the write that mints a
197 // token has to finish on the session this request holds.
198 // A failure here is the session store, not the reader.
199 let csrf = crate::csrf::get_or_create_token(&session)
200 .await
201 .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
202 // Same reason as `csrf`: an async read a sync handler
203 // cannot do. Absent on a legacy session, which is a state
204 // the screens describe rather than an error.
205 let session_id = session
206 .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY)
207 .await
208 .ok()
209 .flatten();
210 Ok(Viewer {
211 app,
212 user,
213 runtime,
214 csrf,
215 session_id,
216 fills: std::sync::Mutex::default(),
217 })
218 }
219 Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")),
220 }
221 })
222 }
223 }
224
225 /// Every converted screen that is switched on, with the address it answers.
226 ///
227 /// One mount per screen rather than one router for all of them, because axum
228 /// strips a nest's prefix before the inner service sees the request: a single
229 /// nest covering both would have to sit at a prefix the Askama routes also live
230 /// under, and matchit refuses to hold a wildcard beside the parameterised routes
231 /// already there. Measured, not assumed: nesting at `/dashboard/project` panics
232 /// at startup against `/dashboard/project/{slug}/tabs/overview`.
233 ///
234 /// The list is empty when nothing is switched on, so the caller registers its
235 /// Askama routes exactly as before and the adapter is not in the stack at all. A
236 /// conversion is a startup-time choice: config is read once, and a per-request
237 /// branch would pay for a switch that never moves.
238 /// Every address a described screen can claim, switched on or not.
239 ///
240 /// `mounts` returns only what is currently on, which depends on config. This is
241 /// the whole set, and it exists for the CSRF coverage test: the manifest that
242 /// test reads is a process-global, so a test that switches a screen on leaves an
243 /// entry behind for a path the default router does not serve, and the probe
244 /// reads that as a route that lost its protection. The list lets it skip exactly
245 /// those and nothing else.
246 ///
247 /// Checked against `mounts` below rather than trusted, since a screen added to
248 /// one and not the other is the obvious way for this to rot.
249 pub const PATHS: &[&str] = &[
250 ssh_keys::PATH,
251 library_contacts::PATH,
252 buyer_contacts::PATH,
253 user_analytics::PATH,
254 payout_summary::PATH,
255 forum_memberships::LIBRARY_PATH,
256 forum_memberships::SETTINGS_PATH,
257 ];
258
259 /// Every screen's switch name, in the same order as [`PATHS`].
260 ///
261 /// Test-only: the switches themselves are read from each screen's own `SCREEN`
262 /// in `mounts`, and this exists so the consistency check below has both halves
263 /// to compare. Kept beside `PATHS` rather than inside the test module, because
264 /// the pairing is the thing being asserted and splitting them is how they drift.
265 #[cfg(test)]
266 const SCREENS: &[&str] = &[
267 ssh_keys::SCREEN,
268 library_contacts::SCREEN,
269 buyer_contacts::SCREEN,
270 user_analytics::SCREEN,
271 payout_summary::SCREEN,
272 forum_memberships::LIBRARY_SCREEN,
273 forum_memberships::SETTINGS_SCREEN,
274 ];
275
276 /// Every described screen, mounted.
277 ///
278 /// Unconditional since `64b33b26`. Each entry used to be gated on
279 /// `described(app, ..)` reading `QUASI_SCREENS`, so a screen could be switched
280 /// off and its Askama rendering registered instead. There is no Askama
281 /// rendering to fall back to any more, and the route tables no longer register
282 /// one, so a screen missing from this list is an address nothing answers.
283 pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
284 vec![
285 (
286 ssh_keys::PATH,
287 mount(app, ssh_keys::screen, ssh_keys::WRITES, ssh_keys::renderer),
288 ),
289 (
290 library_contacts::PATH,
291 mount(
292 app,
293 library_contacts::screen,
294 library_contacts::WRITES,
295 library_contacts::renderer,
296 ),
297 ),
298 // One module, two screens: the library tab and the settings section are
299 // the same table under different chrome.
300 (
301 forum_memberships::LIBRARY_PATH,
302 mount(
303 app,
304 forum_memberships::library_screen,
305 &[],
306 forum_memberships::renderer,
307 ),
308 ),
309 (
310 buyer_contacts::PATH,
311 mount(app, buyer_contacts::screen, &[], buyer_contacts::renderer),
312 ),
313 (
314 user_analytics::PATH,
315 mount(app, user_analytics::screen, &[], user_analytics::renderer),
316 ),
317 (
318 payout_summary::PATH,
319 mount(app, payout_summary::screen, &[], payout_summary::renderer),
320 ),
321 // A nest that answers no address of its own: the Members panel is read
322 // through its Askama route, which keeps a conditional GET, and only its
323 // writes are described. `03c0977b`; see `writes_only`.
324 (
325 project_members::NEST,
326 writes_only(app, project_members::WRITES, project_members::renderer),
327 ),
328 // The item dashboard's Sales panel: a parameterized read address, so
329 // the read stays on Askama and only the Refund write is described.
330 // `b25dd957`; see `writes_only`.
331 (
332 item_sales::NEST,
333 writes_only(app, item_sales::WRITES, item_sales::renderer),
334 ),
335 (
336 forum_memberships::SETTINGS_PATH,
337 mount(
338 app,
339 forum_memberships::settings_screen,
340 &[],
341 forum_memberships::renderer,
342 ),
343 ),
344 ]
345 }
346
347 /// A handler, spelled once so the screens and the mount agree about it.
348 pub type Screen =
349 fn(&Viewer, quasi_router::Request) -> Result<quasi_router::Response, quasi_router::RouteError>;
350
351 /// One screen behind the adapter, answering the root of its own nest.
352 ///
353 /// The tab is `/` because the nest has already taken the address off: a screen
354 /// mounted at its own tab endpoint sees one route and never has to agree with
355 /// the prefix twice.
356 ///
357 /// # Why a screen serves its own writes
358 ///
359 /// `writes` registers routes under the same nest, and a destructive control on
360 /// a described screen should address one of them rather than the API route the
361 /// Askama version used. Decision 7 is the reason: a described route answers with
362 /// a `Response::Fragment` naming the region it changed, so the answer lands where
363 /// it belongs and carries the screen's own markup.
364 ///
365 /// An API route can do neither, and both described screens that called one were
366 /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx
367 /// request with the whole re-rendered Askama list, and with no target htmx put
368 /// that table inside the button that was pressed. `DELETE /api/contacts/{id}`
369 /// answers 204, which htmx is configured never to swap, so the row stayed on
370 /// screen after a successful revoke. Both found 2026-08-11 by reading what the
371 /// endpoints return; both were invisible to tests that check the address exists.
372 ///
373 /// The write still goes through the same CSRF envelope: `crate::csrf` nests this
374 /// service under an Auto posture, and the core module attaches the token to
375 /// every htmx request on the page.
376 fn mount(
377 app: &AppState,
378 screen: Screen,
379 writes: &[(quasi_router::Method, &'static str, Screen)],
380 renderer: fn(&Viewer) -> quasi_webview::Webview,
381 ) -> axum::Router {
382 nest(app, Some(screen), writes, renderer)
383 }
384
385 /// A nest that serves writes and answers no address of its own.
386 ///
387 /// `03c0977b`, ruled 2026-08-26 (Max), option (a). The hole it fills: a panel
388 /// whose route answers a conditional GET cannot be a mounted screen, because
389 /// [`mount`] has no way to say "304 if the cache generation has not moved". So
390 /// it stays a fill on its Askama handler -- and a fill has no nest, so it had
391 /// nowhere to put its writes, so its controls kept addressing API routes that
392 /// answer 200 or 204 and cannot name the region they changed. The patch for
393 /// that was `data-after`, the private dispatcher vocabulary in
394 /// `frontend/src/core/dispatch.ts` that this conversion exists to retire.
395 ///
396 /// This is the other half of such a panel: the read keeps its Askama route and
397 /// its ETag, and the writes get described routes that answer
398 /// `Response::Fragment` naming the panel's region, exactly as a mounted
399 /// screen's do.
400 ///
401 /// # The cost, stated once rather than per panel
402 ///
403 /// One panel is then served by two routers, and the read and the write are no
404 /// longer visible in one place. That is the trade: the alternative was nine
405 /// tabs converting their markup while keeping their JS, which lowers no seal
406 /// and is not what S4 is for.
407 ///
408 /// # The address is a fixed prefix, and the ids go inside it
409 ///
410 /// A nest is mounted at a fixed path, which is also why `project_analytics` is
411 /// a fill rather than a mounted screen. Path parameters live in the inner
412 /// router, which does support them: `ssh_keys` already registers `/keys/{id}`
413 /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids
414 /// in the inner paths and does not reuse the API route's address. That API
415 /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did
416 /// when `ssh_keys` moved its controls off it.
417 fn writes_only(
418 app: &AppState,
419 writes: &[(quasi_router::Method, &'static str, Screen)],
420 renderer: fn(&Viewer) -> quasi_webview::Webview,
421 ) -> axum::Router {
422 nest(app, None, writes, renderer)
423 }
424
425 /// The router both of the above build, with or without a root GET.
426 fn nest(
427 app: &AppState,
428 screen: Option<Screen>,
429 writes: &[(quasi_router::Method, &'static str, Screen)],
430 renderer: fn(&Viewer) -> quasi_webview::Webview,
431 ) -> axum::Router {
432 let mut quasi = quasi_router::Router::<Viewer>::new();
433 if let Some(screen) = screen {
434 quasi = quasi.get("/", screen);
435 }
436 for (method, path, handler) in writes {
437 quasi = match method {
438 quasi_router::Method::Delete => quasi.delete(path, *handler),
439 quasi_router::Method::Put => quasi.put(path, *handler),
440 quasi_router::Method::Get => quasi.get(path, *handler),
441 quasi_router::Method::Post => quasi.post(path, *handler),
442 };
443 }
444 quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), move |viewer, _, _| {
445 renderer(viewer)
446 })
447 .into_router()
448 }
449
450 #[cfg(test)]
451 mod tests {
452 use super::*;
453
454 #[test]
455 fn every_screen_is_listed_in_paths() {
456 // The two lists are written by hand and read by two different things,
457 // so the check is that adding a screen to `mounts` and forgetting
458 // `PATHS` fails here rather than silently weakening the CSRF coverage
459 // probe's skip list.
460 assert_eq!(
461 PATHS.len(),
462 SCREENS.len(),
463 "PATHS and SCREENS describe the same screens"
464 );
465
466 let source = include_str!("mod.rs");
467 let mounted = source
468 .split_once("pub fn mounts(")
469 .expect("mounts exists")
470 .1
471 .split_once("\n}")
472 .expect("mounts ends")
473 .0;
474 // Counted as `mount(` since `64b33b26`. `mounts` used to push
475 // conditionally into a Vec, one `mounted.push((` per screen the switch
476 // had on; it returns a `vec![..]` literal now because every screen is
477 // mounted unconditionally, so the thing to count is the adapter call
478 // each entry makes. Not `mount(app,`: rustfmt breaks the longer entries
479 // across lines and that token then finds three of the six.
480 let registered = mounted.matches("mount(").count();
481 assert_eq!(
482 registered,
483 PATHS.len(),
484 "mounts registers {registered} screens, PATHS lists {}",
485 PATHS.len()
486 );
487 }
488
489 #[test]
490 fn a_path_is_claimed_by_exactly_one_screen() {
491 // Two screens on one address is an axum panic at startup, and the two
492 // forum-memberships screens are the near miss: one module, two paths.
493 let mut seen = PATHS.to_vec();
494 seen.sort_unstable();
495 let before = seen.len();
496 seen.dedup();
497 assert_eq!(
498 before,
499 seen.len(),
500 "two screens claim one address: {seen:?}"
501 );
502 }
503 }
504