max / makenotwork
| 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 Handle; |
| 30 | |
| 31 | use crateAppState; |
| 32 | use crateSessionUser; |
| 33 | |
| 34 | |
| 35 | |
| 36 | |
| 37 | |
| 38 | |
| 39 | |
| 40 | |
| 41 | |
| 42 | |
| 43 | |
| 44 | |
| 45 | |
| 46 | |
| 47 | |
| 48 | |
| 49 | |
| 50 | |
| 51 | |
| 52 | |
| 53 | |
| 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 | |
| 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: , |
| 95 | |
| 96 | |
| 97 | |
| 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 | |
| 104 | self.runtime.block_on |
| 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 | |
| 115 | if let Ok = self.fills.lock |
| 116 | fills.insert; |
| 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 | |
| 133 | |
| 134 | under |
| 135 | .layered |
| 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 |
| 139 | |
| 140 | |
| 141 | /// Everything the handler drew, for the renderer to mount. |
| 142 | |
| 143 | self.fills.lock.map.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 | |
| 155 | app: AppState, |
| 156 | + Send + Sync + 'static |
| 157 | move |parts| |
| 158 | let app = app.clone; |
| 159 | let runtime = current; |
| 160 | // Taken out of the head first, so the future owns what it needs. |
| 161 | let session = parts.extensions..cloned; |
| 162 | Boxpin |
| 163 | let Some = 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; |
| 167 | ; |
| 168 | match crateauthenticate.await |
| 169 | Ok => |
| 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 = crateget_or_create_token |
| 174 | .await |
| 175 | .map_err?; |
| 176 | Ok |
| 177 | app, |
| 178 | user, |
| 179 | runtime, |
| 180 | csrf, |
| 181 | fills: default, |
| 182 | |
| 183 | |
| 184 | Err => Err, |
| 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: & = & |
| 215 | PATH, |
| 216 | PATH, |
| 217 | PATH, |
| 218 | PATH, |
| 219 | LIBRARY_PATH, |
| 220 | 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 | |
| 230 | const SCREENS: & = & |
| 231 | SCREEN, |
| 232 | SCREEN, |
| 233 | SCREEN, |
| 234 | SCREEN, |
| 235 | LIBRARY_SCREEN, |
| 236 | SETTINGS_SCREEN, |
| 237 | ]; |
| 238 | |
| 239 | |
| 240 | let mut mounted = Vecnew; |
| 241 | |
| 242 | if described |
| 243 | mounted.push |
| 244 | PATH, |
| 245 | mount, |
| 246 | ; |
| 247 | |
| 248 | if described |
| 249 | mounted.push |
| 250 | PATH, |
| 251 | mount |
| 252 | app, |
| 253 | screen, |
| 254 | WRITES, |
| 255 | 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 |
| 262 | mounted.push |
| 263 | LIBRARY_PATH, |
| 264 | mount |
| 265 | app, |
| 266 | library_screen, |
| 267 | &, |
| 268 | renderer, |
| 269 | , |
| 270 | ; |
| 271 | |
| 272 | if described |
| 273 | mounted.push |
| 274 | PATH, |
| 275 | mount, |
| 276 | ; |
| 277 | |
| 278 | if described |
| 279 | mounted.push |
| 280 | PATH, |
| 281 | mount, |
| 282 | ; |
| 283 | |
| 284 | if described |
| 285 | mounted.push |
| 286 | SETTINGS_PATH, |
| 287 | mount |
| 288 | app, |
| 289 | settings_screen, |
| 290 | &, |
| 291 | 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 ; |
| 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 | |
| 329 | app: &AppState, |
| 330 | screen: Screen, |
| 331 | writes: &[], |
| 332 | renderer: fn , |
| 333 | |
| 334 | let mut quasi = new.get; |
| 335 | for in writes |
| 336 | quasi = match method |
| 337 | Delete => quasi.delete, |
| 338 | Put => quasi.put, |
| 339 | Get => quasi.get, |
| 340 | Post => quasi.post, |
| 341 | ; |
| 342 | |
| 343 | per_viewer |
| 344 | renderer |
| 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 | |
| 354 | |
| 355 | app.config.quasi_screens.enabled |
| 356 | |
| 357 | |
| 358 | |
| 359 | |
| 360 | use *; |
| 361 | |
| 362 | |
| 363 | |
| 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!; |
| 375 | let mounted = source |
| 376 | .split_once |
| 377 | .expect |
| 378 | .1 |
| 379 | .split_once |
| 380 | .expect |
| 381 | .0; |
| 382 | let registered = mounted.matches.count; |
| 383 | assert_eq! |
| 384 | registered, |
| 385 | PATHS.len, |
| 386 | "mounts registers {registered} screens, PATHS lists {}", |
| 387 | PATHS.len |
| 388 | ; |
| 389 | |
| 390 | |
| 391 | |
| 392 | |
| 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 |