Skip to main content

max / makenotwork

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