max / makenotwork
29 files changed,
+319 insertions,
-180 deletions
| @@ -609,6 +609,14 @@ | |||
| 609 | 609 | .fold(csrf_routes, |routes, (path, described)| { | |
| 610 | 610 | routes.route_service(path, described) | |
| 611 | 611 | }); | |
| 612 | + | // The public documents: an exact address like the gated ones, and no | |
| 613 | + | // session gate in front, so a visitor is rendered to rather than refused. | |
| 614 | + | // See `quasi::public_document_mounts`. | |
| 615 | + | let csrf_routes = quasi::public_document_mounts(state) | |
| 616 | + | .into_iter() | |
| 617 | + | .fold(csrf_routes, |routes, (path, described)| { | |
| 618 | + | routes.route_service(path, described) | |
| 619 | + | }); | |
| 612 | 620 | // The public half of the description layer. Same envelope, different | |
| 613 | 621 | // factory: these screens resolve no session. See `quasi::public_mounts`. | |
| 614 | 622 | let csrf_routes = quasi::public_mounts(state) |
| @@ -139,6 +139,26 @@ | |||
| 139 | 139 | format!("<a href=\"#{target}\" class=\"skip-to-main\">Skip to main content</a>") | |
| 140 | 140 | } | |
| 141 | 141 | ||
| 142 | + | /// The wordmark the sessionless pages open with. | |
| 143 | + | /// | |
| 144 | + | /// Ten templates wrote this same `<h1>` out by hand -- `login`, `two_factor`, | |
| 145 | + | /// `forgot_password`, `reset_password`, `confirm_delete`, `acknowledge`, | |
| 146 | + | /// `oauth_authorize`, `sandbox`, `purchase` and `index` -- which is ten places | |
| 147 | + | /// for the dot to move. Here for the same reason [`site_header`] is: it is one | |
| 148 | + | /// element of the assembly layer, called from the templates and handed to | |
| 149 | + | /// [`quasi_webview::Shell::with_body_first`] by a screen that owns its own | |
| 150 | + | /// document. | |
| 151 | + | /// | |
| 152 | + | /// Not [`site_header`]'s logo and not a substitute for it. The pages that carry | |
| 153 | + | /// this one carry no header at all: they are the screens a reader reaches | |
| 154 | + | /// without a session, where the nav would offer a Library and a Dashboard the | |
| 155 | + | /// reader cannot open. The dot is `aria-hidden`, matching the logo, so a screen | |
| 156 | + | /// reader hears the name rather than the punctuation. | |
| 157 | + | #[must_use] | |
| 158 | + | pub fn wordmark() -> &'static str { | |
| 159 | + | "<h1 class=\"brand-h1\">Makenot<span class=\"dot\" aria-hidden=\"true\">.</span>work</h1>" | |
| 160 | + | } | |
| 161 | + | ||
| 142 | 162 | /// What every page on this site ends with: the toast container and the classic | |
| 143 | 163 | /// script shims the `data-action` dispatcher resolves through. | |
| 144 | 164 | /// | |
| @@ -443,6 +463,38 @@ | |||
| 443 | 463 | } | |
| 444 | 464 | } | |
| 445 | 465 | ||
| 466 | + | /// One spelling of the wordmark, enforced the way the layout class is. | |
| 467 | + | /// | |
| 468 | + | /// Ten templates wrote the same `<h1>` before [`wordmark`] existed, so the | |
| 469 | + | /// obvious failure is an eleventh pasted from one of them. The three | |
| 470 | + | /// `brand-h1` headings that remain say something else -- "Account Deleted", | |
| 471 | + | /// "Email preferences", and the email-result title -- and are not the | |
| 472 | + | /// wordmark, so the check is for the wordmark's own text. | |
| 473 | + | #[test] | |
| 474 | + | fn no_template_still_writes_the_wordmark_by_hand() { | |
| 475 | + | let mut offenders = Vec::new(); | |
| 476 | + | for entry in walk("templates", "html") { | |
| 477 | + | let source = std::fs::read_to_string(&entry).expect("a template reads"); | |
| 478 | + | for (at, line) in source.lines().enumerate() { | |
| 479 | + | if line.contains("brand-h1") && line.contains("Makenot") { | |
| 480 | + | offenders.push(format!("{}:{}", entry.display(), at + 1)); | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | } | |
| 484 | + | assert!( | |
| 485 | + | offenders.is_empty(), | |
| 486 | + | "call crate::shell::wordmark instead: {offenders:?}" | |
| 487 | + | ); | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | #[test] | |
| 491 | + | fn the_wordmark_hides_the_dot_from_a_screen_reader() { | |
| 492 | + | // Matching the header's logo, which has always done this: a reader | |
| 493 | + | // hears the name rather than the punctuation inside it. | |
| 494 | + | assert!(wordmark().contains(r#"<span class="dot" aria-hidden="true">"#)); | |
| 495 | + | assert!(site_header(None, None).contains(r#"class="dot" aria-hidden="true""#)); | |
| 496 | + | } | |
| 497 | + | ||
| 446 | 498 | #[test] | |
| 447 | 499 | fn no_template_still_writes_a_layout_class_by_hand() { | |
| 448 | 500 | // The done-condition, checked rather than remembered: the layout axis |
| @@ -11118,47 +11118,14 @@ | |||
| 11118 | 11118 | ||
| 11119 | 11119 | /* ===== Team page ===== */ | |
| 11120 | 11120 | ||
| 11121 | - | .team-page { max-width: 56rem; margin: 0 auto; } | |
| 11122 | - | .team-intro { | |
| 11123 | - | font-size: var(--text-body); | |
| 11124 | - | line-height: 1.55; | |
| 11125 | - | margin: 0 0 var(--gap-page); | |
| 11126 | - | opacity: 0.85; | |
| 11127 | - | } | |
| 11128 | - | .team-section { margin-top: var(--gap-page); } | |
| 11129 | - | .team-section + .team-section { margin-top: var(--gap-page); } | |
| 11130 | - | .team-card-experience { | |
| 11131 | - | margin: var(--gap-section) 0 var(--gap-peer); | |
| 11132 | - | line-height: 1.5; | |
| 11133 | - | } | |
| 11134 | - | .team-card-links { | |
| 11135 | - | margin: var(--gap-peer) 0; | |
| 11136 | - | font-size: var(--text-body); | |
| 11137 | - | } | |
| 11138 | - | .team-card-link { font-weight: 500; } | |
| 11139 | - | .team-card-bio { | |
| 11140 | - | margin-top: var(--gap-section); | |
| 11141 | - | border-top: 1px solid var(--border); | |
| 11142 | - | padding-top: var(--gap-section); | |
| 11143 | - | } | |
| 11144 | - | .team-card-bio > summary { | |
| 11145 | - | cursor: pointer; | |
| 11146 | - | font-weight: 500; | |
| 11147 | - | font-size: var(--text-body); | |
| 11148 | - | list-style: none; | |
| 11149 | - | } | |
| 11150 | - | .team-card-bio > summary::-webkit-details-marker { display: none; } | |
| 11151 | - | .team-card-bio > summary::before { | |
| 11152 | - | content: "+ "; | |
| 11153 | - | display: inline-block; | |
| 11154 | - | width: 1.2em; | |
| 11155 | - | opacity: 0.6; | |
| 11156 | - | } | |
| 11157 | - | .team-card-bio[open] > summary::before { content: "− "; } | |
| 11158 | - | .team-card-bio > p { | |
| 11159 | - | margin-top: var(--gap-section); | |
| 11160 | - | line-height: 1.6; | |
| 11161 | - | } | |
| 11121 | + | /* The whole block below this line went with `templates/pages/team.html` when | |
| 11122 | + | `/team` moved to the description layer. What is left is the measure, which a | |
| 11123 | + | described document carries on <main> rather than on a container div: the | |
| 11124 | + | screen has no `.container`, exactly as `.feed-page main` found. The intro | |
| 11125 | + | paragraph, the section spacing, the card's parts and the bio disclosure are | |
| 11126 | + | all drawn by the design system now, off `Node::text`, `Node::section`, | |
| 11127 | + | `Node::list` and a selective region. */ | |
| 11128 | + | .team-page main { max-width: 56rem; margin: 0 auto; } | |
| 11162 | 11129 | ||
| 11163 | 11130 | /* ── SyncKit billing panel ── | |
| 11164 | 11131 | Per-app accordion shown inside the SyncKit dashboard tab (both user and |
| @@ -70,20 +70,18 @@ | |||
| 70 | 70 | let contacts = viewer | |
| 71 | 71 | .block_on(db::transactions::get_seller_contacts( | |
| 72 | 72 | &viewer.app.db, | |
| 73 | - | viewer.user.id, | |
| 73 | + | viewer.reader()?.id, | |
| 74 | 74 | )) | |
| 75 | 75 | .map_err(|_| RouteError::internal("your contacts could not be read"))?; | |
| 76 | 76 | ||
| 77 | + | let currency = viewer.reader()?.settlement_currency; | |
| 77 | 78 | let buyers: Vec<BuyerView> = contacts | |
| 78 | 79 | .into_iter() | |
| 79 | 80 | .map(|contact| BuyerView { | |
| 80 | 81 | username: contact.username, | |
| 81 | 82 | email: contact.email, | |
| 82 | 83 | purchases: contact.total_purchases.to_string(), | |
| 83 | - | spent: crate::formatting::format_revenue( | |
| 84 | - | contact.total_spent_cents, | |
| 85 | - | viewer.user.settlement_currency, | |
| 86 | - | ), | |
| 84 | + | spent: crate::formatting::format_revenue(contact.total_spent_cents, currency), | |
| 87 | 85 | last_purchase: contact.last_purchase_at.format("%b %-d, %Y").to_string(), | |
| 88 | 86 | }) | |
| 89 | 87 | .collect(); |
| @@ -164,7 +164,7 @@ | |||
| 164 | 164 | ||
| 165 | 165 | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 166 | 166 | let page = viewer | |
| 167 | - | .block_on(load(&viewer.app.db, viewer.user.id)) | |
| 167 | + | .block_on(load(&viewer.app.db, viewer.reader()?.id)) | |
| 168 | 168 | .map_err(|_| RouteError::internal("your exports could not be read"))?; | |
| 169 | 169 | Ok(page_screen(&page).into()) | |
| 170 | 170 | } | |
| @@ -253,7 +253,7 @@ | |||
| 253 | 253 | Webview::new().with_shell(viewer.document_shell().with_body_first(format!( | |
| 254 | 254 | "{}{}", | |
| 255 | 255 | crate::shell::skip_link(PAGE_REGION), | |
| 256 | - | crate::shell::site_header(Some(&viewer.user), Some(&viewer.csrf)), | |
| 256 | + | crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)), | |
| 257 | 257 | ))) | |
| 258 | 258 | } | |
| 259 | 259 |
| @@ -250,7 +250,7 @@ | |||
| 250 | 250 | .get("page") | |
| 251 | 251 | .and_then(|value| value.trim().parse::<u32>().ok()); | |
| 252 | 252 | let loaded = viewer | |
| 253 | - | .block_on(load(&viewer.app.db, viewer.user.id, asked)) | |
| 253 | + | .block_on(load(&viewer.app.db, viewer.reader()?.id, asked)) | |
| 254 | 254 | .map_err(|_| RouteError::internal("your feed could not be read"))?; | |
| 255 | 255 | Ok(page_screen(&loaded.page()).into()) | |
| 256 | 256 | } | |
| @@ -283,7 +283,7 @@ | |||
| 283 | 283 | Webview::new().with_shell(viewer.document_shell().with_body_first(format!( | |
| 284 | 284 | "{}{}", | |
| 285 | 285 | crate::shell::skip_link(PAGE_REGION), | |
| 286 | - | crate::shell::site_header(Some(&viewer.user), Some(&viewer.csrf)), | |
| 286 | + | crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)), | |
| 287 | 287 | ))) | |
| 288 | 288 | } | |
| 289 | 289 |
| @@ -82,7 +82,7 @@ | |||
| 82 | 82 | let Some(base) = configured_base(viewer) else { | |
| 83 | 83 | return Ok(Response::fragment(LIBRARY_REGION, library_pane(&[], ""))); | |
| 84 | 84 | }; | |
| 85 | - | let memberships = fetch(viewer, &base); | |
| 85 | + | let memberships = fetch(viewer, viewer.reader()?, &base); | |
| 86 | 86 | Ok(Response::fragment( | |
| 87 | 87 | LIBRARY_REGION, | |
| 88 | 88 | library_pane(&memberships, &base), | |
| @@ -98,7 +98,7 @@ | |||
| 98 | 98 | pub fn settings_screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 99 | 99 | let base = configured_base(viewer) | |
| 100 | 100 | .ok_or_else(|| RouteError::not_found("forums are not configured"))?; | |
| 101 | - | let memberships = fetch(viewer, &base); | |
| 101 | + | let memberships = fetch(viewer, viewer.reader()?, &base); | |
| 102 | 102 | Ok(Response::fragment( | |
| 103 | 103 | SETTINGS_REGION, | |
| 104 | 104 | settings_pane(&memberships, &base), | |
| @@ -116,9 +116,9 @@ | |||
| 116 | 116 | /// cannot reach an optional integration should say the reader has no | |
| 117 | 117 | /// memberships, not replace the settings pane with a stack trace, and both | |
| 118 | 118 | /// Askama handlers already made that choice. | |
| 119 | - | fn fetch(viewer: &Viewer, base: &str) -> Vec<MembershipView> { | |
| 120 | - | let url = format!("{base}/api/user/{}/summary", viewer.user.id); | |
| 121 | - | let username = viewer.user.username.as_ref(); | |
| 119 | + | fn fetch(viewer: &Viewer, reader: &crate::auth::SessionUser, base: &str) -> Vec<MembershipView> { | |
| 120 | + | let url = format!("{base}/api/user/{}/summary", reader.id); | |
| 121 | + | let username = reader.username.as_ref(); | |
| 122 | 122 | ||
| 123 | 123 | // The blocking hop, and the long one. See the module header. | |
| 124 | 124 | let body = viewer.block_on(async { |
| @@ -207,7 +207,7 @@ | |||
| 207 | 207 | .block_on(crate::payments::refund::refund( | |
| 208 | 208 | &viewer.app.db, | |
| 209 | 209 | viewer.app.payment_caps.refundable.as_ref(), | |
| 210 | - | &viewer.user, | |
| 210 | + | viewer.reader()?, | |
| 211 | 211 | item, | |
| 212 | 212 | transaction, | |
| 213 | 213 | )) | |
| @@ -241,7 +241,7 @@ | |||
| 241 | 241 | .block_on(db::transactions::get_sales_by_item( | |
| 242 | 242 | &viewer.app.db, | |
| 243 | 243 | item, | |
| 244 | - | viewer.user.id, | |
| 244 | + | viewer.reader()?.id, | |
| 245 | 245 | )) | |
| 246 | 246 | .map_err(|_| RouteError::internal("the sales could not be read"))?; | |
| 247 | 247 |
| @@ -69,7 +69,7 @@ | |||
| 69 | 69 | ||
| 70 | 70 | /// The tab. | |
| 71 | 71 | pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 72 | - | let user_id = viewer.user.id; | |
| 72 | + | let user_id = viewer.reader()?.id; | |
| 73 | 73 | ||
| 74 | 74 | let shared = viewer | |
| 75 | 75 | .block_on(db::transactions::get_shared_creators( | |
| @@ -97,16 +97,14 @@ | |||
| 97 | 97 | Vec::new() | |
| 98 | 98 | }; | |
| 99 | 99 | ||
| 100 | + | let currency = viewer.reader()?.settlement_currency; | |
| 100 | 101 | let buyers: Vec<BuyerView> = buyers | |
| 101 | 102 | .into_iter() | |
| 102 | 103 | .map(|c| BuyerView { | |
| 103 | 104 | username: c.username, | |
| 104 | 105 | email: c.email, | |
| 105 | 106 | purchases: c.total_purchases.to_string(), | |
| 106 | - | spent: crate::formatting::format_revenue( | |
| 107 | - | c.total_spent_cents, | |
| 108 | - | viewer.user.settlement_currency, | |
| 109 | - | ), | |
| 107 | + | spent: crate::formatting::format_revenue(c.total_spent_cents, currency), | |
| 110 | 108 | last_purchase: c.last_purchase_at.format("%b %d, %Y").to_string(), | |
| 111 | 109 | }) | |
| 112 | 110 | .collect(); | |
| @@ -144,7 +142,7 @@ | |||
| 144 | 142 | viewer | |
| 145 | 143 | .block_on(db::transactions::revoke_contact_sharing( | |
| 146 | 144 | &viewer.app.db, | |
| 147 | - | viewer.user.id, | |
| 145 | + | viewer.reader()?.id, | |
| 148 | 146 | seller, | |
| 149 | 147 | )) | |
| 150 | 148 | .map_err(|_| RouteError::internal("that sharing could not be revoked"))?; |
| @@ -66,6 +66,7 @@ | |||
| 66 | 66 | pub mod settings_tabs; | |
| 67 | 67 | pub mod shortcuts; | |
| 68 | 68 | pub mod ssh_keys; | |
| 69 | + | pub mod team; | |
| 69 | 70 | pub mod upload_field; | |
| 70 | 71 | pub mod user_analytics; | |
| 71 | 72 | pub mod user_projects; | |
| @@ -83,9 +84,24 @@ | |||
| 83 | 84 | pub struct Viewer { | |
| 84 | 85 | /// The long-lived application state. Cloning it clones handles, not data. | |
| 85 | 86 | pub app: AppState, | |
| 86 | - | /// Who is asking. Resolved and revocation-checked by | |
| 87 | - | /// [`crate::auth::authenticate`], the same path the extractor takes. | |
| 88 | - | pub user: SessionUser, | |
| 87 | + | /// Who is asking, when anybody is. | |
| 88 | + | /// | |
| 89 | + | /// Resolved and revocation-checked by [`crate::auth::authenticate`], the | |
| 90 | + | /// same path the extractor takes. | |
| 91 | + | /// | |
| 92 | + | /// `None` only on a mount built with [`Audience::Anyone`], which is the | |
| 93 | + | /// public documents: a screen a reader can reach with no session, whose | |
| 94 | + | /// header and controls differ by whether one is held. Every other mount | |
| 95 | + | /// refuses before the handler runs, so a screen behind [`mount`], | |
| 96 | + | /// [`writes_only`] or [`document_mount`] can read it through | |
| 97 | + | /// [`reader`](Self::reader) and never see the refusal that method can | |
| 98 | + | /// return. | |
| 99 | + | /// | |
| 100 | + | /// The field is `pub` and the accessor exists beside it because both | |
| 101 | + | /// readings are legitimate: a gated screen wants the user and treats | |
| 102 | + | /// absence as impossible, and a public screen wants the option and treats | |
| 103 | + | /// absence as an ordinary state. | |
| 104 | + | pub user: Option<SessionUser>, | |
| 89 | 105 | /// The runtime the request arrived on, so a sync handler can reach the | |
| 90 | 106 | /// async database. Captured in the factory rather than read inside the | |
| 91 | 107 | /// handler: `Handle::current` works on a blocking thread today, and | |
| @@ -139,6 +155,22 @@ | |||
| 139 | 155 | self.runtime.block_on(future) | |
| 140 | 156 | } | |
| 141 | 157 | ||
| 158 | + | /// Who is asking, on a mount that guarantees somebody is. | |
| 159 | + | /// | |
| 160 | + | /// The gated mounts resolve the session before the handler runs and refuse | |
| 161 | + | /// without one, so this cannot fail there. It returns a `Result` rather | |
| 162 | + | /// than unwrapping because that guarantee lives in the mount rather than in | |
| 163 | + | /// the type: if a screen is ever moved onto a public mount, the failure is | |
| 164 | + | /// a refusal the reader can read instead of a panic in a blocking thread. | |
| 165 | + | /// | |
| 166 | + | /// A screen that genuinely serves both audiences reads | |
| 167 | + | /// [`user`](Self::user) directly instead. | |
| 168 | + | pub fn reader(&self) -> Result<&SessionUser, quasi_router::RouteError> { | |
| 169 | + | self.user | |
| 170 | + | .as_ref() | |
| 171 | + | .ok_or_else(|| quasi_router::RouteError::denied("sign in to continue")) | |
| 172 | + | } | |
| 173 | + | ||
| 142 | 174 | /// Hand the renderer the markup for one bespoke region. | |
| 143 | 175 | /// | |
| 144 | 176 | /// Called by a handler while it builds its description, keyed by the slot | |
| @@ -205,15 +237,48 @@ | |||
| 205 | 237 | } | |
| 206 | 238 | } | |
| 207 | 239 | ||
| 240 | + | /// Who a mount is willing to answer. | |
| 241 | + | /// | |
| 242 | + | /// The signed-out question `b5cbb646` left open, answered here rather than by a | |
| 243 | + | /// second state type. Two mounts, one `Viewer`: what differs between a panel | |
| 244 | + | /// behind a login and a public page is whether a missing session ends the | |
| 245 | + | /// request, and that is one branch in the factory rather than a parallel | |
| 246 | + | /// hierarchy of states, factories and renderer signatures. | |
| 247 | + | #[derive(Clone, Copy, PartialEq, Eq)] | |
| 248 | + | enum Audience { | |
| 249 | + | /// A session is required, and its absence ends the request. | |
| 250 | + | /// | |
| 251 | + | /// Every panel and every document behind a login. The screens built on this | |
| 252 | + | /// read [`Viewer::reader`] and never see it fail. | |
| 253 | + | Reader, | |
| 254 | + | /// A session is read when there is one, and its absence is an ordinary | |
| 255 | + | /// state the screen describes. | |
| 256 | + | /// | |
| 257 | + | /// The public documents: `/team` and the rest of the `pages/` screens that | |
| 258 | + | /// read the same to a visitor and to a reader, and differ only in the | |
| 259 | + | /// header they carry. A screen here still gets a CSRF token, because the | |
| 260 | + | /// header's own controls post. | |
| 261 | + | Anyone, | |
| 262 | + | } | |
| 263 | + | ||
| 208 | 264 | /// Build the state factory the adapter calls per request. | |
| 209 | 265 | /// | |
| 210 | - | /// Refuses with `Unauthorized` when there is no session to resolve, which the | |
| 211 | - | /// adapter turns into a bare status with no body. That is the right shape here | |
| 212 | - | /// and not a shortcut: a store that will not answer is not a signed-out reader, | |
| 213 | - | /// and rendering a sign-in notice would need the renderer that is built from | |
| 214 | - | /// the state that could not be resolved. | |
| 266 | + | /// On [`Audience::Reader`] it refuses with `Unauthorized` when there is no | |
| 267 | + | /// session to resolve, which the adapter turns into a bare status with no body. | |
| 268 | + | /// That is the right shape there and not a shortcut: a store that will not | |
| 269 | + | /// answer is not a signed-out reader, and rendering a sign-in notice would need | |
| 270 | + | /// the renderer that is built from the state that could not be resolved. | |
| 271 | + | /// | |
| 272 | + | /// On [`Audience::Anyone`] a failed `authenticate` is not a refusal, it is a | |
| 273 | + | /// visitor: the viewer is built with `user: None` and the request goes on. The | |
| 274 | + | /// CSRF token is minted either way, since it is the session's rather than the | |
| 275 | + | /// user's and a sessionless form still needs one. | |
| 276 | + | /// | |
| 277 | + | /// The absent session layer stays an internal error on both, because that is | |
| 278 | + | /// wiring rather than an audience. | |
| 215 | 279 | fn viewer_factory( | |
| 216 | 280 | app: AppState, | |
| 281 | + | audience: Audience, | |
| 217 | 282 | ) -> impl Fn(&http::request::Parts) -> quasi_axum::StateFuture<Viewer> + Send + Sync + 'static { | |
| 218 | 283 | move |parts| { | |
| 219 | 284 | let app = app.clone(); | |
| @@ -226,33 +291,35 @@ | |||
| 226 | 291 | // wiring mistake rather than a signed-out reader. | |
| 227 | 292 | return Err(quasi_router::RouteError::internal("no session layer")); | |
| 228 | 293 | }; | |
| 229 | - | match crate::auth::authenticate(&session, &app).await { | |
| 230 | - | Ok(user) => { | |
| 231 | - | // Before the handler runs, because the write that mints a | |
| 232 | - | // token has to finish on the session this request holds. | |
| 233 | - | // A failure here is the session store, not the reader. | |
| 234 | - | let csrf = crate::csrf::get_or_create_token(&session) | |
| 235 | - | .await | |
| 236 | - | .map_err(|_| quasi_router::RouteError::internal("csrf token"))?; | |
| 237 | - | // Same reason as `csrf`: an async read a sync handler | |
| 238 | - | // cannot do. Absent on a legacy session, which is a state | |
| 239 | - | // the screens describe rather than an error. | |
| 240 | - | let session_id = session | |
| 241 | - | .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY) | |
| 242 | - | .await | |
| 243 | - | .ok() | |
| 244 | - | .flatten(); | |
| 245 | - | Ok(Viewer { | |
| 246 | - | app, | |
| 247 | - | user, | |
| 248 | - | runtime, | |
| 249 | - | csrf, | |
| 250 | - | session_id, | |
| 251 | - | fills: std::sync::Mutex::default(), | |
| 252 | - | }) | |
| 294 | + | let user = match crate::auth::authenticate(&session, &app).await { | |
| 295 | + | Ok(user) => Some(user), | |
| 296 | + | Err(_) if audience == Audience::Anyone => None, | |
| 297 | + | Err(_) => { | |
| 298 | + | return Err(quasi_router::RouteError::denied("sign in to continue")); | |
| 253 | 299 | } | |
| 254 | - | Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")), | |
| 255 | - | } | |
| 300 | + | }; | |
| 301 | + | // Before the handler runs, because the write that mints a token has | |
| 302 | + | // to finish on the session this request holds. A failure here is | |
| 303 | + | // the session store, not the reader. | |
| 304 | + | let csrf = crate::csrf::get_or_create_token(&session) | |
| 305 | + | .await | |
| 306 | + | .map_err(|_| quasi_router::RouteError::internal("csrf token"))?; | |
| 307 | + | // Same reason as `csrf`: an async read a sync handler cannot do. | |
| 308 | + | // Absent on a legacy session, which is a state the screens describe | |
| 309 | + | // rather than an error. | |
| 310 | + | let session_id = session | |
| 311 | + | .get::<crate::db::UserSessionId>(crate::auth::SESSION_TRACKING_KEY) | |
| 312 | + | .await | |
| 313 | + | .ok() | |
| 314 | + | .flatten(); | |
| 315 | + | Ok(Viewer { | |
| 316 | + | app, | |
| 317 | + | user, | |
| 318 | + | runtime, | |
| 319 | + | csrf, | |
| 320 | + | session_id, | |
| 321 | + | fills: std::sync::Mutex::default(), | |
| 322 | + | }) | |
| 256 | 323 | }) | |
| 257 | 324 | } | |
| 258 | 325 | } | |
| @@ -305,6 +372,21 @@ | |||
| 305 | 372 | /// registers no mutating route, so it has nothing to skip here either. | |
| 306 | 373 | pub const DOCUMENT_PATHS: &[&str] = &[feeds::PATH, export_portal::PATH]; | |
| 307 | 374 | ||
| 375 | + | /// Every described document a reader with no session can reach, with the | |
| 376 | + | /// address it answers. | |
| 377 | + | /// | |
| 378 | + | /// Its own list beside [`DOCUMENT_PATHS`] for the same reason that one sits | |
| 379 | + | /// beside [`PATHS`]: what a mount answers with decides what a test can assert | |
| 380 | + | /// about it. These answer `Outcome::Screen` like the gated documents, and | |
| 381 | + | /// differ in that a signed-out request is a render rather than a 401, which is | |
| 382 | + | /// what `tests/workflows/pages.rs` presses them for. | |
| 383 | + | /// | |
| 384 | + | /// Not in [`public_mounts`], which is the other public list and a different | |
| 385 | + | /// mechanism: those screens resolve nothing per request and take a state built | |
| 386 | + | /// once at startup. These resolve a session when there is one, so they carry a | |
| 387 | + | /// per-request viewer and can mint a CSRF token for the form on them. | |
| 388 | + | pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[team::PATH]; | |
| 389 | + | ||
| 308 | 390 | /// Every screen's switch name, in the same order as [`PATHS`]. | |
| 309 | 391 | /// | |
| 310 | 392 | /// Test-only: the switches themselves are read from each screen's own `SCREEN` | |
| @@ -443,9 +525,11 @@ | |||
| 443 | 525 | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 444 | 526 | ) -> axum::Router { | |
| 445 | 527 | let router = quasi_router::Router::<Viewer>::new().get(path, screen); | |
| 446 | - | quasi_axum::Adapter::per_viewer(router, viewer_factory(app.clone()), move |viewer, _, _| { | |
| 447 | - | renderer(viewer) | |
| 448 | - | }) | |
| 528 | + | quasi_axum::Adapter::per_viewer( | |
| 529 | + | router, | |
| 530 | + | viewer_factory(app.clone(), Audience::Reader), | |
| 531 | + | move |viewer, _, _| renderer(viewer), | |
| 532 | + | ) | |
| 449 | 533 | .into_router() | |
| 450 | 534 | .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in)) | |
| 451 | 535 | } | |
| @@ -473,6 +557,45 @@ | |||
| 473 | 557 | } | |
| 474 | 558 | } | |
| 475 | 559 | ||
| 560 | + | /// Every described document a reader with no session can reach, mounted. | |
| 561 | + | /// | |
| 562 | + | /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in | |
| 563 | + | /// [`document_mounts`] nor in [`public_mounts`]. | |
| 564 | + | pub fn public_document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> { | |
| 565 | + | vec![( | |
| 566 | + | team::PATH, | |
| 567 | + | public_document_mount(app, team::PATH, team::screen, team::renderer), | |
| 568 | + | )] | |
| 569 | + | } | |
| 570 | + | ||
| 571 | + | /// A described document a reader NAVIGATES to with or without a session. | |
| 572 | + | /// | |
| 573 | + | /// [`document_mount`] with the two things that make it gated removed: the | |
| 574 | + | /// factory is built on [`Audience::Anyone`], so a signed-out request builds a | |
| 575 | + | /// viewer rather than being refused, and there is no [`signed_in`] layer in | |
| 576 | + | /// front of it, so nothing turns that into a 401. What is left is identical, | |
| 577 | + | /// including the exact-address registration -- see [`document_mount`] for why a | |
| 578 | + | /// document is not mounted as a nest. | |
| 579 | + | /// | |
| 580 | + | /// The screens here are the sessionless pages: the ones whose whole purpose is | |
| 581 | + | /// to be reachable by somebody who cannot sign in. A page that merely *reads* | |
| 582 | + | /// better when signed in is still gated; the test is whether refusing a visitor | |
| 583 | + | /// is the right answer. | |
| 584 | + | fn public_document_mount( | |
| 585 | + | app: &AppState, | |
| 586 | + | path: &'static str, | |
| 587 | + | screen: Screen, | |
| 588 | + | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 589 | + | ) -> axum::Router { | |
| 590 | + | let router = quasi_router::Router::<Viewer>::new().get(path, screen); | |
| 591 | + | quasi_axum::Adapter::per_viewer( | |
| 592 | + | router, | |
| 593 | + | viewer_factory(app.clone(), Audience::Anyone), | |
| 594 | + | move |viewer, _, _| renderer(viewer), | |
| 595 | + | ) | |
| 596 | + | .into_router() | |
| 597 | + | } | |
| 598 | + | ||
| 476 | 599 | /// Every described screen a reader with no session can reach, mounted. | |
| 477 | 600 | /// | |
| 478 | 601 | /// Separate from [`mounts`] because of the factory, not because of the address: | |
| @@ -617,9 +740,11 @@ | |||
| 617 | 740 | quasi_router::Method::Post => quasi.post(path, *handler), | |
| 618 | 741 | }; | |
| 619 | 742 | } | |
| 620 | - | quasi_axum::Adapter::per_viewer(quasi, viewer_factory(app.clone()), move |viewer, _, _| { | |
| 621 | - | renderer(viewer) | |
| 622 | - | }) | |
| 743 | + | quasi_axum::Adapter::per_viewer( | |
| 744 | + | quasi, | |
| 745 | + | viewer_factory(app.clone(), Audience::Reader), | |
| 746 | + | move |viewer, _, _| renderer(viewer), | |
| 747 | + | ) | |
| 623 | 748 | .into_router() | |
| 624 | 749 | } | |
| 625 | 750 | ||
| @@ -679,12 +804,44 @@ | |||
| 679 | 804 | ); | |
| 680 | 805 | } | |
| 681 | 806 | ||
| 807 | + | #[test] | |
| 808 | + | fn every_public_document_is_listed_in_public_document_paths() { | |
| 809 | + | let source = include_str!("mod.rs"); | |
| 810 | + | let mounted = source | |
| 811 | + | .split_once("pub fn public_document_mounts(") | |
| 812 | + | .expect("public_document_mounts exists") | |
| 813 | + | .1 | |
| 814 | + | .split_once("\n}") | |
| 815 | + | .expect("public_document_mounts ends") | |
| 816 | + | .0; | |
| 817 | + | assert_eq!( | |
| 818 | + | mounted.matches("public_document_mount(").count(), | |
| 819 | + | PUBLIC_DOCUMENT_PATHS.len(), | |
| 820 | + | "public_document_mounts and PUBLIC_DOCUMENT_PATHS describe the same screens" | |
| 821 | + | ); | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | /// The two document lists differ by exactly one thing, and it is the one | |
| 825 | + | /// that matters: a gated document refuses a visitor, a public one renders to | |
| 826 | + | /// them. Nothing else about the mount changes, so a screen in the wrong list | |
| 827 | + | /// is a page that 401s or a page that leaks, depending on the direction. | |
| 828 | + | #[test] | |
| 829 | + | fn no_document_is_in_both_lists() { | |
| 830 | + | for path in PUBLIC_DOCUMENT_PATHS { | |
| 831 | + | assert!( | |
| 832 | + | !DOCUMENT_PATHS.contains(path), | |
| 833 | + | "{path} is mounted both gated and public" | |
| 834 | + | ); | |
| 835 | + | } | |
| 836 | + | } | |
| 837 | + | ||
| 682 | 838 | #[test] | |
| 683 | 839 | fn a_path_is_claimed_by_exactly_one_screen() { | |
| 684 | 840 | // Two screens on one address is an axum panic at startup, and the two | |
| 685 | 841 | // forum-memberships screens are the near miss: one module, two paths. | |
| 686 | 842 | let mut seen = PATHS.to_vec(); | |
| 687 | 843 | seen.extend_from_slice(DOCUMENT_PATHS); | |
| 844 | + | seen.extend_from_slice(PUBLIC_DOCUMENT_PATHS); | |
| 688 | 845 | seen.sort_unstable(); | |
| 689 | 846 | let before = seen.len(); | |
| 690 | 847 | seen.dedup(); |
| @@ -83,7 +83,10 @@ | |||
| 83 | 83 | /// The card. | |
| 84 | 84 | pub fn screen(viewer: &Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 85 | 85 | let user = viewer | |
| 86 | - | .block_on(db::users::get_user_by_id(&viewer.app.db, viewer.user.id)) | |
| 86 | + | .block_on(db::users::get_user_by_id( | |
| 87 | + | &viewer.app.db, | |
| 88 | + | viewer.reader()?.id, | |
| 89 | + | )) | |
| 87 | 90 | .map_err(|_| RouteError::internal("your account could not be read"))? | |
| 88 | 91 | .ok_or_else(|| RouteError::not_found("that account is gone"))?; | |
| 89 | 92 |
| @@ -284,7 +284,7 @@ | |||
| 284 | 284 | .map_err(|_| RouteError::internal("that project could not be read"))? | |
| 285 | 285 | .ok_or_else(|| RouteError::not_found("no such project"))?; | |
| 286 | 286 | ||
| 287 | - | if owned.user_id != viewer.user.id { | |
| 287 | + | if owned.user_id != viewer.reader()?.id { | |
| 288 | 288 | return Err(RouteError::not_found("no such project")); | |
| 289 | 289 | } | |
| 290 | 290 | Ok(project) | |
| @@ -355,7 +355,7 @@ | |||
| 355 | 355 | .map_err(|_| RouteError::internal("that user could not be read"))? | |
| 356 | 356 | .ok_or_else(|| RouteError::conflict("no user by that name"))?; | |
| 357 | 357 | ||
| 358 | - | if member.id == viewer.user.id { | |
| 358 | + | if member.id == viewer.reader()?.id { | |
| 359 | 359 | return Err(RouteError::conflict("You are already the project owner")); | |
| 360 | 360 | } | |
| 361 | 361 | ||
| @@ -372,7 +372,7 @@ | |||
| 372 | 372 | member.id, | |
| 373 | 373 | role, | |
| 374 | 374 | split, | |
| 375 | - | viewer.user.id, | |
| 375 | + | viewer.reader()?.id, | |
| 376 | 376 | )) | |
| 377 | 377 | .map_err(|_| RouteError::internal("that collaborator could not be added"))?; | |
| 378 | 378 | ||
| @@ -380,15 +380,23 @@ | |||
| 380 | 380 | .block_on(db::projects::bump_cache_generation(&viewer.app.db, project)) | |
| 381 | 381 | .map_err(|_| RouteError::internal("the project could not be marked changed"))?; | |
| 382 | 382 | ||
| 383 | - | answer(viewer, project, Some(&invited(viewer, &member, split))) | |
| 383 | + | let owner_currency = viewer.reader()?.settlement_currency; | |
| 384 | + | answer( | |
| 385 | + | viewer, | |
| 386 | + | project, | |
| 387 | + | Some(&invited(owner_currency, &member, split)), | |
| 388 | + | ) | |
| 384 | 389 | } | |
| 385 | 390 | ||
| 386 | 391 | /// What the owner is told, which depends on whose money crosses a currency. | |
| 387 | 392 | /// | |
| 388 | 393 | /// The sentence `add_project_member` answered with, kept rather than lost with | |
| 389 | 394 | /// the endpoint. See the module header. | |
| 390 | - | fn invited(viewer: &Viewer, member: &db::DbUser, split: i16) -> String { | |
| 391 | - | let owner_currency = viewer.user.settlement_currency; | |
| 395 | + | fn invited( | |
| 396 | + | owner_currency: crate::currency::SettlementCurrency, | |
| 397 | + | member: &db::DbUser, | |
| 398 | + | split: i16, | |
| 399 | + | ) -> String { | |
| 392 | 400 | if member.settlement_currency == owner_currency { | |
| 393 | 401 | format!( | |
| 394 | 402 | "Invited @{} to a {split}% split. Their share starts when they accept.", |