Skip to main content

max / makenotwork

16.8 KB · 406 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. One screen at a time, each behind
6 //! [`QuasiScreens`](crate::config::QuasiScreens), so a conversion ships when
7 //! its own test is green and reverts by editing an env var.
8 //!
9 //! # Why a per-request state and not a per-request handler argument
10 //!
11 //! `quasi_router::Handler<S> = fn(&S, Request)`. `S` is the app, and on every
12 //! other host in the tree there is one app and one viewer for the life of the
13 //! process. A server has one process and many viewers, and that is the only
14 //! assumption it breaks, so the fix is that the adapter builds `S` per request
15 //! rather than that the handler grows a parameter. `Adapter::per_viewer` is
16 //! that: the factory runs in async context, where the session lookup already
17 //! lives, and hands the sync router a [`Viewer`] with the answer already in it.
18 //!
19 //! # The cost this exists to measure
20 //!
21 //! The router is sync (quasi's decision 6, taken for hosts with no runtime), so
22 //! quasi-axum dispatches on `spawn_blocking` and a handler reaching sqlx does it
23 //! through `Handle::block_on`. Every described request therefore holds a
24 //! blocking-pool thread for the length of its database round trips. That is
25 //! fine on a desktop app over rusqlite and is an open question on the one host
26 //! in the tree with many concurrent readers. It is not arguable, only
27 //! measurable: see `tests/load` and the S3 numbers in the wiki note.
28
29 use tokio::runtime::Handle;
30
31 use crate::AppState;
32 use crate::auth::SessionUser;
33
34 pub mod blog_delete_act;
35 pub mod buyer_contacts;
36 pub mod discover_typeahead;
37 pub mod embeds;
38 pub mod export_act;
39 pub mod forum_memberships;
40 pub mod item_tabs;
41 pub mod library_contacts;
42 pub mod library_tabs;
43 pub mod media_picker;
44 pub mod project_content;
45 pub mod project_tabs;
46 pub mod rich_field;
47 pub mod settings_tabs;
48 pub mod ssh_keys;
49 pub mod upload_field;
50 pub mod user_analytics;
51 pub mod user_tabs;
52 pub mod version_delete_act;
53 pub mod widgets;
54
55 /// The state one request is answered against.
56 ///
57 /// Built per request by the adapter's factory, which is the whole of quasi's
58 /// answer to a server: everything resolvable from the request head is loaded
59 /// before the sync router runs, and a handler reads it off `&S` the way every
60 /// other host's handler reads its app.
61 pub struct Viewer {
62 /// The long-lived application state. Cloning it clones handles, not data.
63 pub app: AppState,
64 /// Who is asking. Resolved and revocation-checked by
65 /// [`crate::auth::authenticate`], the same path the extractor takes.
66 pub user: SessionUser,
67 /// The runtime the request arrived on, so a sync handler can reach the
68 /// async database. Captured in the factory rather than read inside the
69 /// handler: `Handle::current` works on a blocking thread today, and
70 /// depending on that is depending on where quasi-axum happens to dispatch.
71 pub runtime: Handle,
72 /// This session's CSRF token, for the shell to hand to the document.
73 ///
74 /// Resolved in the factory rather than in a renderer because minting one is
75 /// an async session write and a renderer is sync. It is the same token the
76 /// Askama pages carry: [`crate::csrf::get_or_create_token`] is
77 /// get-or-create, so a described page and a templated one in the same
78 /// session agree, and validation is one comparison either way.
79 pub csrf: String,
80 /// Markup for the bespoke regions this request's screen describes.
81 ///
82 /// The seam between a handler and its renderer, and the reason it has to be
83 /// here rather than in either of them: a `Region::Bespoke` is filled on the
84 /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the
85 /// handler has answered, and the renderer factory is handed `&S` and the
86 /// answer. So the handler writes what it drew here and the renderer reads
87 /// it back off the same state. Both see one instance: the adapter builds a
88 /// single `Arc<Viewer>` per request and passes it to the router and then to
89 /// the factory.
90 ///
91 /// Behind a lock because the handler holds `&Viewer` and runs on a blocking
92 /// thread. Uncontended in practice: one request writes it, then one
93 /// renderer reads it, never at once.
94 fills: std::sync::Mutex<std::collections::HashMap<String, String>>,
95 }
96
97 impl Viewer {
98 /// Run a database future from inside a sync handler.
99 ///
100 /// The blocking hop, named in one place so the thing being measured is
101 /// countable rather than spread across every handler. Every call holds this
102 /// blocking thread until the query answers.
103 pub fn block_on<F: Future>(&self, future: F) -> F::Output {
104 self.runtime.block_on(future)
105 }
106
107 /// Hand the renderer the markup for one bespoke region.
108 ///
109 /// Called by a handler while it builds its description, keyed by the slot
110 /// id the description gives that region. Markup, not text: a bespoke region
111 /// is the app's own and is not escaped, which is the whole of what makes it
112 /// bespoke and the whole of why a handler must not put a reader's string in
113 /// one without escaping it first.
114 pub fn fill(&self, slot_id: impl Into<String>, markup: impl Into<String>) {
115 if let Ok(mut fills) = self.fills.lock() {
116 fills.insert(slot_id.into(), markup.into());
117 }
118 }
119
120 /// The shell every described screen is drawn in.
121 ///
122 /// Here rather than in each screen's `renderer` because of what it carries:
123 /// a described page whose shell does not declare the session token has
124 /// every write on it refused, and the five screens that wrote
125 /// `Shell::under("/static")` out by hand were five chances to forget. A
126 /// screen that wants more says so on top of this; a screen that says
127 /// nothing gets the token anyway.
128 ///
129 /// The layer order matches `crate::shell`, which is the Askama half of the
130 /// same document: `makeover` is prepended by the renderer, and the site's
131 /// own sheets live in `components`.
132 #[must_use]
133 pub fn shell(&self) -> quasi_webview::Shell {
134 quasi_webview::Shell::under("/static")
135 .layered(["base", "components", "responsive"])
136 // Every described write is an htmx request, and htmx inherits this
137 // from the body, so one declaration covers the whole document.
138 .sending("X-CSRF-Token", &self.csrf)
139 }
140
141 /// Everything the handler drew, for the renderer to mount.
142 pub fn drawn(&self) -> std::collections::HashMap<String, String> {
143 self.fills.lock().map(|f| f.clone()).unwrap_or_default()
144 }
145 }
146
147 /// Build the state factory the adapter calls per request.
148 ///
149 /// Refuses with `Unauthorized` when there is no session to resolve, which the
150 /// adapter turns into a bare status with no body. That is the right shape here
151 /// and not a shortcut: a store that will not answer is not a signed-out reader,
152 /// and rendering a sign-in notice would need the renderer that is built from
153 /// the state that could not be resolved.
154 fn viewer_factory(
155 app: AppState,
156 ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static {
157 move |parts| {
158 let app = app.clone();
159 let runtime = Handle::current();
160 // Taken out of the head first, so the future owns what it needs.
161 let session = parts.extensions.get::<tower_sessions::Session>().cloned();
162 Box::pin(async move {
163 let Some(session) = session else {
164 // The session layer runs in front of this. Its absence is a
165 // wiring mistake rather than a signed-out reader.
166 return Err(quasi_router::RouteError::internal("no session layer"));
167 };
168 match crate::auth::authenticate(&session, &app).await {
169 Ok(user) => {
170 // Before the handler runs, because the write that mints a
171 // token has to finish on the session this request holds.
172 // A failure here is the session store, not the reader.
173 let csrf = crate::csrf::get_or_create_token(&session)
174 .await
175 .map_err(|_| quasi_router::RouteError::internal("csrf token"))?;
176 Ok(Viewer {
177 app,
178 user,
179 runtime,
180 csrf,
181 fills: std::sync::Mutex::default(),
182 })
183 }
184 Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")),
185 }
186 })
187 }
188 }
189
190 /// Every converted screen that is switched on, with the address it answers.
191 ///
192 /// One mount per screen rather than one router for all of them, because axum
193 /// strips a nest's prefix before the inner service sees the request: a single
194 /// nest covering both would have to sit at a prefix the Askama routes also live
195 /// under, and matchit refuses to hold a wildcard beside the parameterised routes
196 /// already there. Measured, not assumed: nesting at `/dashboard/project` panics
197 /// at startup against `/dashboard/project/{slug}/tabs/overview`.
198 ///
199 /// The list is empty when nothing is switched on, so the caller registers its
200 /// Askama routes exactly as before and the adapter is not in the stack at all. A
201 /// conversion is a startup-time choice: config is read once, and a per-request
202 /// branch would pay for a switch that never moves.
203 /// Every address a described screen can claim, switched on or not.
204 ///
205 /// `mounts` returns only what is currently on, which depends on config. This is
206 /// the whole set, and it exists for the CSRF coverage test: the manifest that
207 /// test reads is a process-global, so a test that switches a screen on leaves an
208 /// entry behind for a path the default router does not serve, and the probe
209 /// reads that as a route that lost its protection. The list lets it skip exactly
210 /// those and nothing else.
211 ///
212 /// Checked against `mounts` below rather than trusted, since a screen added to
213 /// one and not the other is the obvious way for this to rot.
214 pub const PATHS: &[&str] = &[
215 ssh_keys::PATH,
216 library_contacts::PATH,
217 buyer_contacts::PATH,
218 user_analytics::PATH,
219 forum_memberships::LIBRARY_PATH,
220 forum_memberships::SETTINGS_PATH,
221 ];
222
223 /// Every screen's switch name, in the same order as [`PATHS`].
224 ///
225 /// Test-only: the switches themselves are read from each screen's own `SCREEN`
226 /// in `mounts`, and this exists so the consistency check below has both halves
227 /// to compare. Kept beside `PATHS` rather than inside the test module, because
228 /// the pairing is the thing being asserted and splitting them is how they drift.
229 #[cfg(test)]
230 const SCREENS: &[&str] = &[
231 ssh_keys::SCREEN,
232 library_contacts::SCREEN,
233 buyer_contacts::SCREEN,
234 user_analytics::SCREEN,
235 forum_memberships::LIBRARY_SCREEN,
236 forum_memberships::SETTINGS_SCREEN,
237 ];
238
239 pub fn mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
240 let mut mounted = Vec::new();
241
242 if described(app, ssh_keys::SCREEN) {
243 mounted.push((
244 ssh_keys::PATH,
245 mount(app, ssh_keys::screen, ssh_keys::WRITES, ssh_keys::renderer),
246 ));
247 }
248 if described(app, library_contacts::SCREEN) {
249 mounted.push((
250 library_contacts::PATH,
251 mount(
252 app,
253 library_contacts::screen,
254 library_contacts::WRITES,
255 library_contacts::renderer,
256 ),
257 ));
258 }
259 // One module, two screens: the library tab and the settings section are the
260 // same table under different chrome, and each is switched on by itself.
261 if described(app, forum_memberships::LIBRARY_SCREEN) {
262 mounted.push((
263 forum_memberships::LIBRARY_PATH,
264 mount(
265 app,
266 forum_memberships::library_screen,
267 &[],
268 forum_memberships::renderer,
269 ),
270 ));
271 }
272 if described(app, buyer_contacts::SCREEN) {
273 mounted.push((
274 buyer_contacts::PATH,
275 mount(app, buyer_contacts::screen, &[], buyer_contacts::renderer),
276 ));
277 }
278 if described(app, user_analytics::SCREEN) {
279 mounted.push((
280 user_analytics::PATH,
281 mount(app, user_analytics::screen, &[], user_analytics::renderer),
282 ));
283 }
284 if described(app, forum_memberships::SETTINGS_SCREEN) {
285 mounted.push((
286 forum_memberships::SETTINGS_PATH,
287 mount(
288 app,
289 forum_memberships::settings_screen,
290 &[],
291 forum_memberships::renderer,
292 ),
293 ));
294 }
295
296 mounted
297 }
298
299 /// A handler, spelled once so the screens and the mount agree about it.
300 pub type Screen =
301 fn(&Viewer, quasi_router::Request) -> Result<quasi_router::Response, quasi_router::RouteError>;
302
303 /// One screen behind the adapter, answering the root of its own nest.
304 ///
305 /// The tab is `/` because the nest has already taken the address off: a screen
306 /// mounted at its own tab endpoint sees one route and never has to agree with
307 /// the prefix twice.
308 ///
309 /// # Why a screen serves its own writes
310 ///
311 /// `writes` registers routes under the same nest, and a destructive control on
312 /// a described screen should address one of them rather than the API route the
313 /// Askama version used. Decision 7 is the reason: a described route answers with
314 /// a `Response::Fragment` naming the region it changed, so the answer lands where
315 /// it belongs and carries the screen's own markup.
316 ///
317 /// An API route can do neither, and both described screens that called one were
318 /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx
319 /// request with the whole re-rendered Askama list, and with no target htmx put
320 /// that table inside the button that was pressed. `DELETE /api/contacts/{id}`
321 /// answers 204, which htmx is configured never to swap, so the row stayed on
322 /// screen after a successful revoke. Both found 2026-08-11 by reading what the
323 /// endpoints return; both were invisible to tests that check the address exists.
324 ///
325 /// The write still goes through the same CSRF envelope: `crate::csrf` nests this
326 /// service under an Auto posture, and the core module attaches the token to
327 /// every htmx request on the page.
328 fn mount(
329 app: &AppState,
330 screen: Screen,
331 writes: &[(quasi_router::Method, &'static str, Screen)],
332 renderer: fn(&Viewer) -> quasi_webview::Webview,
333 ) -> axum::Router {
334 let mut quasi = quasi_router::Router::<Viewer>::new().get("/", screen);
335 for (method, path, handler) in writes {
336 quasi = match method {
337 quasi_router::Method::Delete => quasi.delete(path, *handler),
338 quasi_router::Method::Put => quasi.put(path, *handler),
339 quasi_router::Method::Get => quasi.get(path, *handler),
340 quasi_router::Method::Post => quasi.post(path, *handler),
341 };
342 }
343 quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), move |viewer, _, _| {
344 renderer(viewer)
345 })
346 .into_router()
347 }
348
349 /// Whether a named screen serves from the description layer.
350 ///
351 /// Read by the route tables, which mount one or the other. Here rather than
352 /// there so the switch and the screens it names stay together.
353 #[must_use]
354 pub fn described(app: &AppState, screen: &str) -> bool {
355 app.config.quasi_screens.enabled(screen)
356 }
357
358 #[cfg(test)]
359 mod tests {
360 use super::*;
361
362 #[test]
363 fn every_screen_is_listed_in_paths() {
364 // The two lists are written by hand and read by two different things,
365 // so the check is that adding a screen to `mounts` and forgetting
366 // `PATHS` fails here rather than silently weakening the CSRF coverage
367 // probe's skip list.
368 assert_eq!(
369 PATHS.len(),
370 SCREENS.len(),
371 "PATHS and SCREENS describe the same screens"
372 );
373
374 let source = include_str!("mod.rs");
375 let mounted = source
376 .split_once("pub fn mounts(")
377 .expect("mounts exists")
378 .1
379 .split_once("\n}")
380 .expect("mounts ends")
381 .0;
382 let registered = mounted.matches("mounted.push((").count();
383 assert_eq!(
384 registered,
385 PATHS.len(),
386 "mounts registers {registered} screens, PATHS lists {}",
387 PATHS.len()
388 );
389 }
390
391 #[test]
392 fn a_path_is_claimed_by_exactly_one_screen() {
393 // Two screens on one address is an axum panic at startup, and the two
394 // forum-memberships screens are the near miss: one module, two paths.
395 let mut seen = PATHS.to_vec();
396 seen.sort_unstable();
397 let before = seen.len();
398 seen.dedup();
399 assert_eq!(
400 before,
401 seen.len(),
402 "two screens claim one address: {seen:?}"
403 );
404 }
405 }
406