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. |
| 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 Handle; |
| 35 | |
| 36 | use FromRef; |
| 37 | |
| 38 | use crateAppState; |
| 39 | use crateSessionUser; |
| 40 | |
| 41 | |
| 42 | |
| 43 | |
| 44 | |
| 45 | |
| 46 | |
| 47 | |
| 48 | |
| 49 | |
| 50 | |
| 51 | |
| 52 | |
| 53 | |
| 54 | |
| 55 | |
| 56 | |
| 57 | |
| 58 | |
| 59 | |
| 60 | |
| 61 | |
| 62 | |
| 63 | |
| 64 | |
| 65 | |
| 66 | |
| 67 | |
| 68 | |
| 69 | |
| 70 | |
| 71 | |
| 72 | |
| 73 | |
| 74 | |
| 75 | |
| 76 | |
| 77 | |
| 78 | |
| 79 | |
| 80 | |
| 81 | |
| 82 | |
| 83 | |
| 84 | |
| 85 | |
| 86 | |
| 87 | |
| 88 | |
| 89 | |
| 90 | |
| 91 | |
| 92 | |
| 93 | |
| 94 | |
| 95 | |
| 96 | |
| 97 | |
| 98 | /// The state one request is answered against. |
| 99 | /// |
| 100 | /// Built per request by the adapter's factory, which is the whole of quasi's |
| 101 | /// answer to a server: everything resolvable from the request head is loaded |
| 102 | /// before the sync router runs, and a handler reads it off `&S` the way every |
| 103 | /// other host's handler reads its app. |
| 104 | |
| 105 | /// The long-lived application state. Cloning it clones handles, not data. |
| 106 | pub app: AppState, |
| 107 | /// Who is asking, when anybody is. |
| 108 | /// |
| 109 | /// Resolved and revocation-checked by [`crate::auth::authenticate`], the |
| 110 | /// same path the extractor takes. |
| 111 | /// |
| 112 | /// `None` only on a mount built with [`Audience::Anyone`], which is the |
| 113 | /// public documents: a screen a reader can reach with no session, whose |
| 114 | /// header and controls differ by whether one is held. Every other mount |
| 115 | /// refuses before the handler runs, so a screen behind [`mount`], |
| 116 | /// [`writes_only`] or [`document_mount`] can read it through |
| 117 | /// [`reader`](Self::reader) and never see the refusal that method can |
| 118 | /// return. |
| 119 | /// |
| 120 | /// The field is `pub` and the accessor exists beside it because both |
| 121 | /// readings are legitimate: a gated screen wants the user and treats |
| 122 | /// absence as impossible, and a public screen wants the option and treats |
| 123 | /// absence as an ordinary state. |
| 124 | pub user: , |
| 125 | /// The runtime the request arrived on, so a sync handler can reach the |
| 126 | /// async database. Captured in the factory rather than read inside the |
| 127 | /// handler: `Handle::current` works on a blocking thread today, and |
| 128 | /// depending on that is depending on where quasi-axum happens to dispatch. |
| 129 | pub runtime: Handle, |
| 130 | /// This session's CSRF token, for the shell to hand to the document. |
| 131 | /// |
| 132 | /// Resolved in the factory rather than in a renderer because minting one is |
| 133 | /// an async session write and a renderer is sync. It is the same token the |
| 134 | /// Askama pages carry: [`crate::csrf::get_or_create_token`] is |
| 135 | /// get-or-create, so a described page and a templated one in the same |
| 136 | /// session agree, and validation is one comparison either way. |
| 137 | pub csrf: String, |
| 138 | /// This request's session-tracking id, when it has one. |
| 139 | /// |
| 140 | /// The same class of fact as [`csrf`](Self::csrf) and resolved the same |
| 141 | /// way: reading it is an async session lookup, so the factory does it and |
| 142 | /// the sync handler reads the answer off `&S`. It is what lets a screen |
| 143 | /// tell the reader's own row apart from the rest, which |
| 144 | /// `user_sessions` needs twice over: the `Current` badge, and the one row |
| 145 | /// that offers no `Sign out`. |
| 146 | /// |
| 147 | /// `None` is a real state rather than a failure. A session predating |
| 148 | /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a |
| 149 | /// screen answering for one marks no row as current. |
| 150 | pub session_id: , |
| 151 | /// Markup for the bespoke regions this request's screen describes. |
| 152 | /// |
| 153 | /// The seam between a handler and its renderer, and the reason it has to be |
| 154 | /// here rather than in either of them: a `Region::Handover` is filled on the |
| 155 | /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the |
| 156 | /// handler has answered, and the renderer factory is handed `&S` and the |
| 157 | /// answer. So the handler writes what it drew here and the renderer reads |
| 158 | /// it back off the same state. Both see one instance: the adapter builds a |
| 159 | /// single `Arc<Viewer>` per request and passes it to the router and then to |
| 160 | /// the factory. |
| 161 | /// |
| 162 | /// Behind a lock because the handler holds `&Viewer` and runs on a blocking |
| 163 | /// thread. Uncontended in practice: one request writes it, then one |
| 164 | /// renderer reads it, never at once. |
| 165 | fills: , |
| 166 | |
| 167 | |
| 168 | |
| 169 | /// Run a database future from inside a sync handler. |
| 170 | /// |
| 171 | /// The blocking hop, named in one place so the thing being measured is |
| 172 | /// countable rather than spread across every handler. Every call holds this |
| 173 | /// blocking thread until the query answers. |
| 174 | |
| 175 | self.runtime.block_on |
| 176 | |
| 177 | |
| 178 | /// Who is asking, on a mount that guarantees somebody is. |
| 179 | /// |
| 180 | /// The gated mounts resolve the session before the handler runs and refuse |
| 181 | /// without one, so this cannot fail there. It returns a `Result` rather |
| 182 | /// than unwrapping because that guarantee lives in the mount rather than in |
| 183 | /// the type: if a screen is ever moved onto a public mount, the failure is |
| 184 | /// a refusal the reader can read instead of a panic in a blocking thread. |
| 185 | /// |
| 186 | /// A screen that genuinely serves both audiences reads |
| 187 | /// [`user`](Self::user) directly instead. |
| 188 | |
| 189 | self.user |
| 190 | .as_ref |
| 191 | .ok_or_else |
| 192 | |
| 193 | |
| 194 | /// Hand the renderer the markup for one bespoke region. |
| 195 | /// |
| 196 | /// Called by a handler while it builds its description, keyed by the slot |
| 197 | /// id the description gives that region. Markup, not text: a bespoke region |
| 198 | /// is the app's own and is not escaped, which is the whole of what makes it |
| 199 | /// bespoke and the whole of why a handler must not put a reader's string in |
| 200 | /// one without escaping it first. |
| 201 | |
| 202 | if let Ok = self.fills.lock |
| 203 | fills.insert; |
| 204 | |
| 205 | |
| 206 | |
| 207 | /// The shell every described screen is drawn in. |
| 208 | /// |
| 209 | /// Here rather than in each screen's `renderer` because of what it carries: |
| 210 | /// a described page whose shell does not declare the session token has |
| 211 | /// every write on it refused, and the five screens that wrote |
| 212 | /// `Shell::under("/static")` out by hand were five chances to forget. A |
| 213 | /// screen that wants more says so on top of this; a screen that says |
| 214 | /// nothing gets the token anyway. |
| 215 | /// |
| 216 | /// The layer order matches `crate::shell`, which is the Askama half of the |
| 217 | /// same document: `makeover` is prepended by the renderer, and the site's |
| 218 | /// own sheets live in `components`. |
| 219 | |
| 220 | |
| 221 | under |
| 222 | .layered |
| 223 | // Every described write is an htmx request, and htmx inherits this |
| 224 | // from the body, so one declaration covers the whole document. |
| 225 | .sending |
| 226 | |
| 227 | |
| 228 | /// The shell a described screen that owns its whole DOCUMENT is drawn in. |
| 229 | /// |
| 230 | /// [`shell`](Self::shell) is right for a fragment landing inside an Askama |
| 231 | /// page, which already has the head, the tail and the token meta. A |
| 232 | /// document owes all three itself: [`crate::shell::described`] is the same |
| 233 | /// builder `base.html` renders through, [`crate::shell::body_last`] is the |
| 234 | /// toast container and the classic shims, and the token meta is what the |
| 235 | /// pre-module scripts read. |
| 236 | /// |
| 237 | /// The meta is not redundant with `Shell::sending`. That covers htmx, and |
| 238 | /// `frontend/src/core/net.ts`, `frontend/src/core/htmx-glue.ts`, |
| 239 | /// `static/passkey.js` and `static/project-sections.js` all read |
| 240 | /// `meta[name=csrf-token]` instead. `/pricing` needed none of it because it |
| 241 | /// holds no session. |
| 242 | |
| 243 | |
| 244 | cratedescribed |
| 245 | .sending |
| 246 | .with_body_last |
| 247 | .with_chrome |
| 248 | .with_head |
| 249 | "<meta name=\"csrf-token\" content=\"{}\">" |
| 250 | crateescape_html |
| 251 | ) |
| 252 | |
| 253 | |
| 254 | /// Everything the handler drew, for the renderer to mount. |
| 255 | |
| 256 | self.fills.lock.map.unwrap_or_default |
| 257 | |
| 258 | |
| 259 | |
| 260 | /// Who a mount is willing to answer. |
| 261 | /// |
| 262 | /// The signed-out question `b5cbb646` left open, answered here rather than by a |
| 263 | /// second state type. Two mounts, one `Viewer`: what differs between a panel |
| 264 | /// behind a login and a public page is whether a missing session ends the |
| 265 | /// request, and that is one branch in the factory rather than a parallel |
| 266 | /// hierarchy of states, factories and renderer signatures. |
| 267 | |
| 268 | |
| 269 | /// A session is required, and its absence ends the request. |
| 270 | /// |
| 271 | /// Every panel and every document behind a login. The screens built on this |
| 272 | /// read [`Viewer::reader`] and never see it fail. |
| 273 | Reader, |
| 274 | /// A session is read when there is one, and its absence is an ordinary |
| 275 | /// state the screen describes. |
| 276 | /// |
| 277 | /// The public documents: `/team` and the rest of the `pages/` screens that |
| 278 | /// read the same to a visitor and to a reader, and differ only in the |
| 279 | /// header they carry. A screen here still gets a CSRF token, because the |
| 280 | /// header's own controls post. |
| 281 | Anyone, |
| 282 | |
| 283 | |
| 284 | /// Build the state factory the adapter calls per request. |
| 285 | /// |
| 286 | /// On [`Audience::Reader`] it refuses with `Unauthorized` when there is no |
| 287 | /// session to resolve, which the adapter turns into a bare status with no body. |
| 288 | /// That is the right shape there and not a shortcut: a store that will not |
| 289 | /// answer is not a signed-out reader, and rendering a sign-in notice would need |
| 290 | /// the renderer that is built from the state that could not be resolved. |
| 291 | /// |
| 292 | /// On [`Audience::Anyone`] a failed `authenticate` is not a refusal, it is a |
| 293 | /// visitor: the viewer is built with `user: None` and the request goes on. The |
| 294 | /// CSRF token is minted either way, since it is the session's rather than the |
| 295 | /// user's and a sessionless form still needs one. |
| 296 | /// |
| 297 | /// The absent session layer stays an internal error on both, because that is |
| 298 | /// wiring rather than an audience. |
| 299 | |
| 300 | app: AppState, |
| 301 | audience: Audience, |
| 302 | + Send + Sync + 'static |
| 303 | move |parts| |
| 304 | let app = app.clone; |
| 305 | let runtime = current; |
| 306 | // Taken out of the head first, so the future owns what it needs. |
| 307 | let session = parts.extensions..cloned; |
| 308 | Boxpin |
| 309 | let Some = session else |
| 310 | // The session layer runs in front of this. Its absence is a |
| 311 | // wiring mistake rather than a signed-out reader. |
| 312 | return Err; |
| 313 | ; |
| 314 | let user = match crateauthenticate.await |
| 315 | Ok => Some, |
| 316 | Err if audience == Anyone => None, |
| 317 | Err => |
| 318 | return Err; |
| 319 | |
| 320 | ; |
| 321 | // Before the handler runs, because the write that mints a token has |
| 322 | // to finish on the session this request holds. A failure here is |
| 323 | // the session store, not the reader. |
| 324 | let csrf = crateget_or_create_token |
| 325 | .await |
| 326 | .map_err?; |
| 327 | // Same reason as `csrf`: an async read a sync handler cannot do. |
| 328 | // Absent on a legacy session, which is a state the screens describe |
| 329 | // rather than an error. |
| 330 | let session_id = session |
| 331 | . |
| 332 | .await |
| 333 | .ok |
| 334 | .flatten; |
| 335 | Ok |
| 336 | app, |
| 337 | user, |
| 338 | runtime, |
| 339 | csrf, |
| 340 | session_id, |
| 341 | fills: default, |
| 342 | |
| 343 | |
| 344 | |
| 345 | |
| 346 | |
| 347 | /// Every converted screen that is switched on, with the address it answers. |
| 348 | /// |
| 349 | /// One mount per screen rather than one router for all of them, because axum |
| 350 | /// strips a nest's prefix before the inner service sees the request: a single |
| 351 | /// nest covering both would have to sit at a prefix the Askama routes also live |
| 352 | /// under, and matchit refuses to hold a wildcard beside the parameterised routes |
| 353 | /// already there. Measured, not assumed: nesting at `/dashboard/project` panics |
| 354 | /// at startup against `/dashboard/project/{slug}/tabs/overview`. |
| 355 | /// |
| 356 | /// The list is empty when nothing is switched on, so the caller registers its |
| 357 | /// Askama routes exactly as before and the adapter is not in the stack at all. A |
| 358 | /// conversion is a startup-time choice: config is read once, and a per-request |
| 359 | /// branch would pay for a switch that never moves. |
| 360 | /// Every address a described screen can claim, switched on or not. |
| 361 | /// |
| 362 | /// `mounts` returns only what is currently on, which depends on config. This is |
| 363 | /// the whole set, and it exists for the CSRF coverage test: the manifest that |
| 364 | /// test reads is a process-global, so a test that switches a screen on leaves an |
| 365 | /// entry behind for a path the default router does not serve, and the probe |
| 366 | /// reads that as a route that lost its protection. The list lets it skip exactly |
| 367 | /// those and nothing else. |
| 368 | /// |
| 369 | /// Checked against `mounts` below rather than trusted, since a screen added to |
| 370 | /// one and not the other is the obvious way for this to rot. |
| 371 | pub const PATHS: & = & |
| 372 | PATH, |
| 373 | PATH, |
| 374 | PATH, |
| 375 | PATH, |
| 376 | PATH, |
| 377 | LIBRARY_PATH, |
| 378 | SETTINGS_PATH, |
| 379 | ]; |
| 380 | |
| 381 | /// Every described screen that owns its whole document, with the address it |
| 382 | /// answers. |
| 383 | /// |
| 384 | /// Its own list rather than an entry in [`PATHS`], for the same reason |
| 385 | /// [`public_mounts`] keeps its own: what a nest answers with decides what a |
| 386 | /// test can assert about it. A panel screen answers `Response::Fragment` and |
| 387 | /// names the region it changed, which `tests/workflows/described_screens.rs` |
| 388 | /// checks by reading `HX-Retarget` off every entry in [`PATHS`]. A document |
| 389 | /// answers `Outcome::Screen`, which sets no such header and is not a defect. |
| 390 | /// |
| 391 | /// The CSRF probe reads [`PATHS`] as its skip list, and a document screen |
| 392 | /// registers no mutating route, so it has nothing to skip here either. |
| 393 | pub const DOCUMENT_PATHS: & = &; |
| 394 | |
| 395 | /// Every described document a reader with no session can reach, with the |
| 396 | /// address it answers. |
| 397 | /// |
| 398 | /// Its own list beside [`DOCUMENT_PATHS`] for the same reason that one sits |
| 399 | /// beside [`PATHS`]: what a mount answers with decides what a test can assert |
| 400 | /// about it. These answer `Outcome::Screen` like the gated documents, and |
| 401 | /// differ in that a signed-out request is a render rather than a 401, which is |
| 402 | /// what `tests/workflows/pages.rs` presses them for. |
| 403 | /// |
| 404 | /// Not in [`public_mounts`], which is the other public list and a different |
| 405 | /// mechanism: those screens resolve nothing per request and take a state built |
| 406 | /// once at startup. These resolve a session when there is one, so they carry a |
| 407 | /// per-request viewer and can mint a CSRF token for the form on them. |
| 408 | pub const PUBLIC_DOCUMENT_PATHS: & = & |
| 409 | PATH, |
| 410 | PATH, |
| 411 | PATH, |
| 412 | PATH, |
| 413 | PATH, |
| 414 | PATH, |
| 415 | PATH, |
| 416 | PATH, |
| 417 | ]; |
| 418 | |
| 419 | /// Every screen's switch name, in the same order as [`PATHS`]. |
| 420 | /// |
| 421 | /// Test-only: the switches themselves are read from each screen's own `SCREEN` |
| 422 | /// in `mounts`, and this exists so the consistency check below has both halves |
| 423 | /// to compare. Kept beside `PATHS` rather than inside the test module, because |
| 424 | /// the pairing is the thing being asserted and splitting them is how they drift. |
| 425 | |
| 426 | const SCREENS: & = & |
| 427 | SCREEN, |
| 428 | SCREEN, |
| 429 | SCREEN, |
| 430 | SCREEN, |
| 431 | SCREEN, |
| 432 | LIBRARY_SCREEN, |
| 433 | SETTINGS_SCREEN, |
| 434 | ]; |
| 435 | |
| 436 | /// Every described screen, mounted. |
| 437 | /// |
| 438 | /// Unconditional since `64b33b26`. Each entry used to be gated on |
| 439 | /// `described(app, ..)` reading `QUASI_SCREENS`, so a screen could be switched |
| 440 | /// off and its Askama rendering registered instead. There is no Askama |
| 441 | /// rendering to fall back to any more, and the route tables no longer register |
| 442 | /// one, so a screen missing from this list is an address nothing answers. |
| 443 | |
| 444 | vec! |
| 445 | |
| 446 | PATH, |
| 447 | mount, |
| 448 | , |
| 449 | |
| 450 | PATH, |
| 451 | mount |
| 452 | app, |
| 453 | screen, |
| 454 | WRITES, |
| 455 | renderer, |
| 456 | , |
| 457 | , |
| 458 | // One module, two screens: the library tab and the settings section are |
| 459 | // the same table under different chrome. |
| 460 | |
| 461 | LIBRARY_PATH, |
| 462 | mount |
| 463 | app, |
| 464 | library_screen, |
| 465 | &, |
| 466 | renderer, |
| 467 | , |
| 468 | , |
| 469 | |
| 470 | PATH, |
| 471 | mount, |
| 472 | , |
| 473 | |
| 474 | PATH, |
| 475 | mount, |
| 476 | , |
| 477 | |
| 478 | PATH, |
| 479 | mount, |
| 480 | , |
| 481 | // A nest that answers no address of its own: the Members panel is read |
| 482 | // through its Askama route, which keeps a conditional GET, and only its |
| 483 | // writes are described. `03c0977b`; see `writes_only`. |
| 484 | |
| 485 | NEST, |
| 486 | writes_only, |
| 487 | , |
| 488 | // The item dashboard's Sales panel: a parameterized read address, so |
| 489 | // the read stays on Askama and only the Refund write is described. |
| 490 | // `b25dd957`; see `writes_only`. |
| 491 | |
| 492 | NEST, |
| 493 | writes_only, |
| 494 | , |
| 495 | |
| 496 | SETTINGS_PATH, |
| 497 | mount |
| 498 | app, |
| 499 | settings_screen, |
| 500 | &, |
| 501 | renderer, |
| 502 | , |
| 503 | , |
| 504 | ] |
| 505 | |
| 506 | |
| 507 | /// Every described screen that owns its whole document, mounted. |
| 508 | /// |
| 509 | /// See [`DOCUMENT_PATHS`] for why these are not in [`mounts`]. |
| 510 | |
| 511 | vec! |
| 512 | |
| 513 | PATH, |
| 514 | document_mount, |
| 515 | , |
| 516 | |
| 517 | PATH, |
| 518 | document_mount |
| 519 | app, |
| 520 | PATH, |
| 521 | screen, |
| 522 | renderer, |
| 523 | , |
| 524 | , |
| 525 | ] |
| 526 | |
| 527 | |
| 528 | /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches. |
| 529 | /// |
| 530 | /// The difference is what a signed-out reader gets. [`viewer_factory`] refuses |
| 531 | /// with `denied` and quasi-axum answers that as a bare 403 with no body: right |
| 532 | /// for a panel fetched by a page that already checked, wrong for an address a |
| 533 | /// person can type. `tests/workflows/pages.rs::unauthorized_page_offers_login_and_signup` |
| 534 | /// is the shipped rule, so the gate runs [`crate::auth::authenticate`] in front |
| 535 | /// and answers whatever that refuses with, which renders the branded 401 with |
| 536 | /// its way back in. It is the same call the `AuthUser` extractor makes, so the |
| 537 | /// two paths cannot disagree about who is signed in. |
| 538 | /// |
| 539 | /// It costs one extra session read on this nest: the gate resolves the session |
| 540 | /// and the factory resolves it again. Stated rather than optimised, because the |
| 541 | /// alternative is a viewer whose `user` is optional, which is the signed-out |
| 542 | /// question the feed conversion deliberately did not answer. |
| 543 | /// |
| 544 | /// # The address is registered whole, not as `/` |
| 545 | /// |
| 546 | /// [`mount`]'s nests are mounted with `nest_service`, which strips the prefix |
| 547 | /// before the adapter sees the request. A document screen is mounted with |
| 548 | /// `CsrfRouter::route_service` instead (see there for why), which strips |
| 549 | /// nothing, so the router inside answers the address the reader typed. |
| 550 | |
| 551 | app: &AppState, |
| 552 | path: &'static str, |
| 553 | screen: Screen, |
| 554 | renderer: fn , |
| 555 | |
| 556 | let router = new.get; |
| 557 | per_viewer |
| 558 | router, |
| 559 | viewer_factory, |
| 560 | move |viewer, _, _| renderer, |
| 561 | |
| 562 | .into_router |
| 563 | .layer |
| 564 | |
| 565 | |
| 566 | /// The gate in front of every document nest: a reader or a branded refusal. |
| 567 | async |
| 568 | State: , |
| 569 | request: Request, |
| 570 | next: Next, |
| 571 | |
| 572 | use IntoResponse as _; |
| 573 | |
| 574 | let Some = request |
| 575 | .extensions |
| 576 | . |
| 577 | .cloned |
| 578 | else |
| 579 | // The session layer runs in front of this. Its absence is wiring. |
| 580 | return crateInternal |
| 581 | .into_response; |
| 582 | ; |
| 583 | match crateauthenticate.await |
| 584 | Ok => next.run.await, |
| 585 | Err => refusal.into_response, |
| 586 | |
| 587 | |
| 588 | |
| 589 | /// Every described document a reader with no session can reach, mounted. |
| 590 | /// |
| 591 | /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in |
| 592 | /// [`document_mounts`] nor in [`public_mounts`]. |
| 593 | |
| 594 | vec! |
| 595 | |
| 596 | PATH, |
| 597 | public_document_mount, |
| 598 | , |
| 599 | |
| 600 | PATH, |
| 601 | public_document_mount, |
| 602 | , |
| 603 | |
| 604 | PATH, |
| 605 | public_document_mount, |
| 606 | , |
| 607 | |
| 608 | PATH, |
| 609 | public_document_mount, |
| 610 | , |
| 611 | |
| 612 | PATH, |
| 613 | public_document_mount, |
| 614 | , |
| 615 | |
| 616 | PATH, |
| 617 | public_document_mount |
| 618 | app, |
| 619 | PATH, |
| 620 | screen, |
| 621 | renderer, |
| 622 | , |
| 623 | , |
| 624 | // The git browse tree carries a per-IP cap on every read, and a |
| 625 | // described document taking one of its addresses has to carry it too: |
| 626 | // these routes walk bare repositories on disk. The mount is an |
| 627 | // `axum::Router`, so the layer goes on here rather than through a |
| 628 | // parameter, and the limiter is rebuilt from the same constants |
| 629 | // `routes::git` reads. See `git_repos`'s module header. Both git |
| 630 | // listings mount here, and a module that is declared but not mounted is |
| 631 | // an address nothing answers: `git_explore` shipped that way and its own |
| 632 | // tests did not run either, because an undeclared module is not compiled. |
| 633 | |
| 634 | PATH, |
| 635 | public_document_mount |
| 636 | app, |
| 637 | PATH, |
| 638 | screen, |
| 639 | renderer, |
| 640 | |
| 641 | .layer |
| 642 | craterate_limiter_ms |
| 643 | crateGIT_BROWSE_RATE_LIMIT_MS, |
| 644 | crateGIT_BROWSE_RATE_LIMIT_BURST, |
| 645 | , |
| 646 | , |
| 647 | , |
| 648 | |
| 649 | PATH, |
| 650 | public_document_mount |
| 651 | .layer |
| 652 | craterate_limiter_ms |
| 653 | crateGIT_BROWSE_RATE_LIMIT_MS, |
| 654 | crateGIT_BROWSE_RATE_LIMIT_BURST, |
| 655 | , |
| 656 | , |
| 657 | , |
| 658 | ] |
| 659 | |
| 660 | |
| 661 | /// A described document a reader NAVIGATES to with or without a session. |
| 662 | /// |
| 663 | /// [`document_mount`] with the two things that make it gated removed: the |
| 664 | /// factory is built on [`Audience::Anyone`], so a signed-out request builds a |
| 665 | /// viewer rather than being refused, and there is no [`signed_in`] layer in |
| 666 | /// front of it, so nothing turns that into a 401. What is left is identical, |
| 667 | /// including the exact-address registration -- see [`document_mount`] for why a |
| 668 | /// document is not mounted as a nest. |
| 669 | /// |
| 670 | /// The screens here are the sessionless pages: the ones whose whole purpose is |
| 671 | /// to be reachable by somebody who cannot sign in. A page that merely *reads* |
| 672 | /// better when signed in is still gated; the test is whether refusing a visitor |
| 673 | /// is the right answer. |
| 674 | /// |
| 675 | /// # Layers go on the returned router, at the call site |
| 676 | /// |
| 677 | /// This returns an `axum::Router`, so a mount that needs middleware takes it |
| 678 | /// with `.layer(..)` where it is registered rather than through a parameter |
| 679 | /// here. That matters because the middleware is per-address rather than per |
| 680 | /// mount kind: the git browse addresses carry a per-IP cap and the marketing |
| 681 | /// pages carry none, and threading an `Option<Layer>` through every call would |
| 682 | /// make the mount know about a policy that belongs to the route. |
| 683 | /// |
| 684 | /// **Check what the address carried before moving it.** A route lifted out of a |
| 685 | /// router with a `route_layer` silently loses that layer, and for the git tree |
| 686 | /// that would be a rate limit removed from a route that walks repositories on |
| 687 | /// disk. See `git_repos`. |
| 688 | |
| 689 | app: &AppState, |
| 690 | path: &'static str, |
| 691 | screen: Screen, |
| 692 | renderer: fn , |
| 693 | |
| 694 | let router = new.get; |
| 695 | per_viewer |
| 696 | router, |
| 697 | viewer_factory, |
| 698 | move |viewer, _, _| renderer, |
| 699 | |
| 700 | .into_router |
| 701 | |
| 702 | |
| 703 | /// Every described screen a reader with no session can reach, mounted. |
| 704 | /// |
| 705 | /// Separate from [`mounts`] because of the factory, not because of the address: |
| 706 | /// [`viewer_factory`] resolves a session and refuses without one, which is the |
| 707 | /// right answer for every screen behind a login and the wrong one for a |
| 708 | /// marketing page. A public screen takes a state resolved once at startup, so |
| 709 | /// its adapter is `Adapter::new` rather than `Adapter::per_viewer`. |
| 710 | /// |
| 711 | /// Deliberately not in [`PATHS`]. That list is what the CSRF probe skips and |
| 712 | /// what `tests/workflows/described_screens.rs` presses with a signed-in |
| 713 | /// fixture, and both readings are about screens that hold a session. A public |
| 714 | /// screen registers no mutating route, so the probe has nothing to skip. |
| 715 | |
| 716 | vec! |
| 717 | , |
| 718 | , |
| 719 | ] |
| 720 | |
| 721 | |
| 722 | /// The keyboard-shortcuts overlay, on the same shell every described screen |
| 723 | /// gets so the listing looks like the site it is drawn over. |
| 724 | /// |
| 725 | /// Its own mount rather than a route inside the pricing nest: an overlay |
| 726 | /// reachable from every screen is not one screen's route, and the binding's |
| 727 | /// address is absolute in the emitted markup (`e0c0d991`). |
| 728 | |
| 729 | new |
| 730 | router, |
| 731 | new, |
| 732 | new, |
| 733 | |
| 734 | .into_router |
| 735 | |
| 736 | |
| 737 | /// The fee calculator's own nest: the page and the recompute it answers. |
| 738 | |
| 739 | let state = new |
| 740 | billing: cratefrom_ref, |
| 741 | founder_window_open: app.config.creator_pricing.founder_window_open, |
| 742 | changelog_published: crateis_published, |
| 743 | ; |
| 744 | let router = new |
| 745 | .get |
| 746 | .get; |
| 747 | new.into_router |
| 748 | |
| 749 | |
| 750 | /// This server's own copy, as markdown. |
| 751 | /// |
| 752 | /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so its |
| 753 | /// links carry `nofollow`, its raw markup is dropped and fetchable schemes are |
| 754 | /// filtered. That is right for a forum post and wrong for a page's own |
| 755 | /// sentence, and until quasi grew the trust axis every described page here was |
| 756 | /// telling crawlers not to follow its own links (quasicoherent `24a3b1df`). |
| 757 | /// |
| 758 | /// The two axes stay separate in quasi -- `Richness` is what the format may |
| 759 | /// express, `Trust` is who wrote it -- and this is the one combination this |
| 760 | /// server reaches for often enough to name: a sentence, ours. A screen wanting |
| 761 | /// tables says so with `Node::richness`, and a screen carrying a reader's |
| 762 | /// markdown keeps `Node::rich`. |
| 763 | /// |
| 764 | /// **The test for using it is authorship, not tidiness.** The string has to be |
| 765 | /// a literal in this repository, or interpolated from a value that cannot carry |
| 766 | /// markup. A creator's description reaching a screen through the database is |
| 767 | /// `Node::rich` however well-behaved it has been. |
| 768 | |
| 769 | |
| 770 | rich.trust |
| 771 | |
| 772 | |
| 773 | /// A handler, spelled once so the screens and the mount agree about it. |
| 774 | pub type Screen = |
| 775 | fn ; |
| 776 | |
| 777 | /// One screen behind the adapter, answering the root of its own nest. |
| 778 | /// |
| 779 | /// The tab is `/` because the nest has already taken the address off: a screen |
| 780 | /// mounted at its own tab endpoint sees one route and never has to agree with |
| 781 | /// the prefix twice. |
| 782 | /// |
| 783 | /// # Why a screen serves its own writes |
| 784 | /// |
| 785 | /// `writes` registers routes under the same nest, and a destructive control on |
| 786 | /// a described screen should address one of them rather than the API route the |
| 787 | /// Askama version used. Decision 7 is the reason: a described route answers with |
| 788 | /// a `Response::Fragment` naming the region it changed, so the answer lands where |
| 789 | /// it belongs and carries the screen's own markup. |
| 790 | /// |
| 791 | /// An API route can do neither, and both described screens that called one were |
| 792 | /// wrong in different ways. `DELETE /api/users/me/ssh-keys/{id}` answers an htmx |
| 793 | /// request with the whole re-rendered Askama list, and with no target htmx put |
| 794 | /// that table inside the button that was pressed. `DELETE /api/contacts/{id}` |
| 795 | /// answers 204, which htmx is configured never to swap, so the row stays on |
| 796 | /// screen after a successful revoke. Neither is visible to a test that checks |
| 797 | /// only that the address exists. |
| 798 | /// |
| 799 | /// The write still goes through the same CSRF envelope: `crate::csrf` nests this |
| 800 | /// service under an Auto posture, and the core module attaches the token to |
| 801 | /// every htmx request on the page. |
| 802 | |
| 803 | app: &AppState, |
| 804 | screen: Screen, |
| 805 | writes: &[], |
| 806 | renderer: fn , |
| 807 | |
| 808 | nest |
| 809 | |
| 810 | |
| 811 | /// A nest that serves writes and answers no address of its own. |
| 812 | /// |
| 813 | /// The hole it fills: a panel whose route answers a conditional GET cannot be a |
| 814 | /// mounted screen, because |
| 815 | /// [`mount`] has no way to say "304 if the cache generation has not moved". So |
| 816 | /// it stays a fill on its Askama handler -- and a fill has no nest, so it had |
| 817 | /// nowhere to put its writes, so its controls kept addressing API routes that |
| 818 | /// answer 200 or 204 and cannot name the region they changed. The patch for |
| 819 | /// that was `data-after`, the private dispatcher vocabulary in |
| 820 | /// `frontend/src/core/dispatch.ts` that this conversion exists to retire. |
| 821 | /// |
| 822 | /// This is the other half of such a panel: the read keeps its Askama route and |
| 823 | /// its ETag, and the writes get described routes that answer |
| 824 | /// `Response::Fragment` naming the panel's region, exactly as a mounted |
| 825 | /// screen's do. |
| 826 | /// |
| 827 | /// # The cost, stated once rather than per panel |
| 828 | /// |
| 829 | /// One panel is then served by two routers, and the read and the write are no |
| 830 | /// longer visible in one place. That is the trade: the alternative was nine |
| 831 | /// tabs converting their markup while keeping their JS, which lowers no seal |
| 832 | /// and is not what S4 is for. |
| 833 | /// |
| 834 | /// # The address is a fixed prefix, and the ids go inside it |
| 835 | /// |
| 836 | /// A nest is mounted at a fixed path, which is also why `project_analytics` is |
| 837 | /// a fill rather than a mounted screen. Path parameters live in the inner |
| 838 | /// router, which does support them: `ssh_keys` already registers `/keys/{id}` |
| 839 | /// and `/tokens/{id}` under its own nest. So a writes-only nest carries its ids |
| 840 | /// in the inner paths and does not reuse the API route's address. That API |
| 841 | /// route stays for API consumers, exactly as `/api/users/me/ssh-keys/{id}` did |
| 842 | /// when `ssh_keys` moved its controls off it. |
| 843 | |
| 844 | app: &AppState, |
| 845 | writes: &[], |
| 846 | renderer: fn , |
| 847 | |
| 848 | nest |
| 849 | |
| 850 | |
| 851 | /// The router both of the above build, with or without a root GET. |
| 852 | |
| 853 | app: &AppState, |
| 854 | screen: , |
| 855 | writes: &[], |
| 856 | renderer: fn , |
| 857 | |
| 858 | let mut quasi = new; |
| 859 | if let Some = screen |
| 860 | quasi = quasi.get; |
| 861 | |
| 862 | for in writes |
| 863 | quasi = match method |
| 864 | Delete => quasi.delete, |
| 865 | Put => quasi.put, |
| 866 | Get => quasi.get, |
| 867 | Post => quasi.post, |
| 868 | ; |
| 869 | |
| 870 | per_viewer |
| 871 | quasi, |
| 872 | viewer_factory, |
| 873 | move |viewer, _, _| renderer, |
| 874 | |
| 875 | .into_router |
| 876 | |
| 877 | |
| 878 | |
| 879 | |
| 880 | use *; |
| 881 | |
| 882 | |
| 883 | |
| 884 | // The two lists are written by hand and read by two different things, |
| 885 | // so the check is that adding a screen to `mounts` and forgetting |
| 886 | // `PATHS` fails here rather than silently weakening the CSRF coverage |
| 887 | // probe's skip list. |
| 888 | assert_eq! |
| 889 | PATHS.len, |
| 890 | SCREENS.len, |
| 891 | "PATHS and SCREENS describe the same screens" |
| 892 | ; |
| 893 | |
| 894 | let source = include_str!; |
| 895 | let mounted = source |
| 896 | .split_once |
| 897 | .expect |
| 898 | .1 |
| 899 | .split_once |
| 900 | .expect |
| 901 | .0; |
| 902 | // Counted as `mount(` since `64b33b26`. `mounts` used to push |
| 903 | // conditionally into a Vec, one `mounted.push((` per screen the switch |
| 904 | // had on; it returns a `vec![..]` literal now because every screen is |
| 905 | // mounted unconditionally, so the thing to count is the adapter call |
| 906 | // each entry makes. Not `mount(app,`: rustfmt breaks the longer entries |
| 907 | // across lines and that token then finds three of the six. |
| 908 | let registered = mounted.matches.count; |
| 909 | assert_eq! |
| 910 | registered, |
| 911 | PATHS.len, |
| 912 | "mounts registers {registered} screens, PATHS lists {}", |
| 913 | PATHS.len |
| 914 | ; |
| 915 | |
| 916 | |
| 917 | |
| 918 | |
| 919 | let source = include_str!; |
| 920 | let mounted = source |
| 921 | .split_once |
| 922 | .expect |
| 923 | .1 |
| 924 | .split_once |
| 925 | .expect |
| 926 | .0; |
| 927 | assert_eq! |
| 928 | mounted.matches.count, |
| 929 | DOCUMENT_PATHS.len, |
| 930 | "document_mounts and DOCUMENT_PATHS describe the same screens" |
| 931 | ; |
| 932 | |
| 933 | |
| 934 | |
| 935 | |
| 936 | let source = include_str!; |
| 937 | let mounted = source |
| 938 | .split_once |
| 939 | .expect |
| 940 | .1 |
| 941 | .split_once |
| 942 | .expect |
| 943 | .0; |
| 944 | assert_eq! |
| 945 | mounted.matches.count, |
| 946 | PUBLIC_DOCUMENT_PATHS.len, |
| 947 | "public_document_mounts and PUBLIC_DOCUMENT_PATHS describe the same screens" |
| 948 | ; |
| 949 | |
| 950 | |
| 951 | /// Every git address carries the browse limiter, checked in the source |
| 952 | /// because there is nothing to ask an `axum::Router` about afterwards. |
| 953 | /// |
| 954 | /// This is the check the whole `/git` conversion turns on. The tree's reads |
| 955 | /// sit under one `route_layer` in `routes::git`, so an address lifted out of |
| 956 | /// it and mounted here loses that layer silently: nothing fails to compile, |
| 957 | /// no test fails, and a route that walks bare repositories on disk stops |
| 958 | /// being capped. A conversion that forgets it fails here instead. |
| 959 | |
| 960 | |
| 961 | let source = include_str!; |
| 962 | let mounted = source |
| 963 | .split_once |
| 964 | .expect |
| 965 | .1 |
| 966 | .split_once |
| 967 | .expect |
| 968 | .0; |
| 969 | |
| 970 | let git_paths = PUBLIC_DOCUMENT_PATHS |
| 971 | .iter |
| 972 | .filter |
| 973 | .count; |
| 974 | assert!; |
| 975 | assert_eq! |
| 976 | mounted.matches.count, |
| 977 | git_paths, |
| 978 | "a described /git address is mounted without the browse limiter" |
| 979 | ; |
| 980 | |
| 981 | |
| 982 | /// The two document lists differ by exactly one thing, and it is the one |
| 983 | /// that matters: a gated document refuses a visitor, a public one renders to |
| 984 | /// them. Nothing else about the mount changes, so a screen in the wrong list |
| 985 | /// is a page that 401s or a page that leaks, depending on the direction. |
| 986 | |
| 987 | |
| 988 | for path in PUBLIC_DOCUMENT_PATHS |
| 989 | assert! |
| 990 | !DOCUMENT_PATHS.contains, |
| 991 | "{path} is mounted both gated and public" |
| 992 | ; |
| 993 | |
| 994 | |
| 995 | |
| 996 | |
| 997 | |
| 998 | // Two screens on one address is an axum panic at startup, and the two |
| 999 | // forum-memberships screens are the near miss: one module, two paths. |
| 1000 | let mut seen = PATHS.to_vec; |
| 1001 | seen.extend_from_slice; |
| 1002 | seen.extend_from_slice; |
| 1003 | seen.sort_unstable; |
| 1004 | let before = seen.len; |
| 1005 | seen.dedup; |
| 1006 | assert_eq! |
| 1007 | before, |
| 1008 | seen.len, |
| 1009 | "two screens claim one address: {seen:?}" |
| 1010 | ; |
| 1011 | |
| 1012 | |
| 1013 |