max / makenotwork
23 files changed,
+1335 insertions,
-162 deletions
| @@ -88,6 +88,16 @@ dependencies = [ | |||
| 88 | 88 | ] | |
| 89 | 89 | ||
| 90 | 90 | [[package]] | |
| 91 | + | name = "assert-json-diff" | |
| 92 | + | version = "2.0.2" | |
| 93 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 94 | + | checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" | |
| 95 | + | dependencies = [ | |
| 96 | + | "serde", | |
| 97 | + | "serde_json", | |
| 98 | + | ] | |
| 99 | + | ||
| 100 | + | [[package]] | |
| 91 | 101 | name = "async-trait" | |
| 92 | 102 | version = "0.1.89" | |
| 93 | 103 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -985,6 +995,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 985 | 995 | checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" | |
| 986 | 996 | ||
| 987 | 997 | [[package]] | |
| 998 | + | name = "deadpool" | |
| 999 | + | version = "0.12.3" | |
| 1000 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1001 | + | checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" | |
| 1002 | + | dependencies = [ | |
| 1003 | + | "deadpool-runtime", | |
| 1004 | + | "lazy_static", | |
| 1005 | + | "num_cpus", | |
| 1006 | + | "tokio", | |
| 1007 | + | ] | |
| 1008 | + | ||
| 1009 | + | [[package]] | |
| 1010 | + | name = "deadpool-runtime" | |
| 1011 | + | version = "0.1.4" | |
| 1012 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1013 | + | checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" | |
| 1014 | + | ||
| 1015 | + | [[package]] | |
| 988 | 1016 | name = "der" | |
| 989 | 1017 | version = "0.6.1" | |
| 990 | 1018 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1270,6 +1298,7 @@ checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" | |||
| 1270 | 1298 | dependencies = [ | |
| 1271 | 1299 | "futures-channel", | |
| 1272 | 1300 | "futures-core", | |
| 1301 | + | "futures-executor", | |
| 1273 | 1302 | "futures-io", | |
| 1274 | 1303 | "futures-sink", | |
| 1275 | 1304 | "futures-task", | |
| @@ -1355,6 +1384,7 @@ version = "0.3.32" | |||
| 1355 | 1384 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1356 | 1385 | checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" | |
| 1357 | 1386 | dependencies = [ | |
| 1387 | + | "futures-channel", | |
| 1358 | 1388 | "futures-core", | |
| 1359 | 1389 | "futures-io", | |
| 1360 | 1390 | "futures-macro", | |
| @@ -1540,6 +1570,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1540 | 1570 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 1541 | 1571 | ||
| 1542 | 1572 | [[package]] | |
| 1573 | + | name = "hermit-abi" | |
| 1574 | + | version = "0.5.2" | |
| 1575 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1576 | + | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | |
| 1577 | + | ||
| 1578 | + | [[package]] | |
| 1543 | 1579 | name = "hex" | |
| 1544 | 1580 | version = "0.4.3" | |
| 1545 | 1581 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2153,7 +2189,7 @@ dependencies = [ | |||
| 2153 | 2189 | ||
| 2154 | 2190 | [[package]] | |
| 2155 | 2191 | name = "mt-core" | |
| 2156 | - | version = "0.3.4" | |
| 2192 | + | version = "0.3.5" | |
| 2157 | 2193 | dependencies = [ | |
| 2158 | 2194 | "chrono", | |
| 2159 | 2195 | "serde", | |
| @@ -2162,7 +2198,7 @@ dependencies = [ | |||
| 2162 | 2198 | ||
| 2163 | 2199 | [[package]] | |
| 2164 | 2200 | name = "mt-db" | |
| 2165 | - | version = "0.3.4" | |
| 2201 | + | version = "0.3.5" | |
| 2166 | 2202 | dependencies = [ | |
| 2167 | 2203 | "chrono", | |
| 2168 | 2204 | "mt-core", | |
| @@ -2191,7 +2227,7 @@ dependencies = [ | |||
| 2191 | 2227 | ||
| 2192 | 2228 | [[package]] | |
| 2193 | 2229 | name = "multithreaded" | |
| 2194 | - | version = "0.3.4" | |
| 2230 | + | version = "0.3.5" | |
| 2195 | 2231 | dependencies = [ | |
| 2196 | 2232 | "askama", | |
| 2197 | 2233 | "axum", | |
| @@ -2226,6 +2262,7 @@ dependencies = [ | |||
| 2226 | 2262 | "tracing-subscriber", | |
| 2227 | 2263 | "urlencoding", | |
| 2228 | 2264 | "uuid", | |
| 2265 | + | "wiremock", | |
| 2229 | 2266 | ] | |
| 2230 | 2267 | ||
| 2231 | 2268 | [[package]] | |
| @@ -2314,6 +2351,16 @@ dependencies = [ | |||
| 2314 | 2351 | ] | |
| 2315 | 2352 | ||
| 2316 | 2353 | [[package]] | |
| 2354 | + | name = "num_cpus" | |
| 2355 | + | version = "1.17.0" | |
| 2356 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2357 | + | checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" | |
| 2358 | + | dependencies = [ | |
| 2359 | + | "hermit-abi", | |
| 2360 | + | "libc", | |
| 2361 | + | ] | |
| 2362 | + | ||
| 2363 | + | [[package]] | |
| 2317 | 2364 | name = "once_cell" | |
| 2318 | 2365 | version = "1.21.4" | |
| 2319 | 2366 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2765,6 +2812,18 @@ dependencies = [ | |||
| 2765 | 2812 | ] | |
| 2766 | 2813 | ||
| 2767 | 2814 | [[package]] | |
| 2815 | + | name = "regex" | |
| 2816 | + | version = "1.12.3" | |
| 2817 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2818 | + | checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" | |
| 2819 | + | dependencies = [ | |
| 2820 | + | "aho-corasick", | |
| 2821 | + | "memchr", | |
| 2822 | + | "regex-automata", | |
| 2823 | + | "regex-syntax", | |
| 2824 | + | ] | |
| 2825 | + | ||
| 2826 | + | [[package]] | |
| 2768 | 2827 | name = "regex-automata" | |
| 2769 | 2828 | version = "0.4.14" | |
| 2770 | 2829 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4570,6 +4629,29 @@ dependencies = [ | |||
| 4570 | 4629 | ] | |
| 4571 | 4630 | ||
| 4572 | 4631 | [[package]] | |
| 4632 | + | name = "wiremock" | |
| 4633 | + | version = "0.6.5" | |
| 4634 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4635 | + | checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" | |
| 4636 | + | dependencies = [ | |
| 4637 | + | "assert-json-diff", | |
| 4638 | + | "base64", | |
| 4639 | + | "deadpool", | |
| 4640 | + | "futures", | |
| 4641 | + | "http 1.4.0", | |
| 4642 | + | "http-body-util", | |
| 4643 | + | "hyper 1.8.1", | |
| 4644 | + | "hyper-util", | |
| 4645 | + | "log", | |
| 4646 | + | "once_cell", | |
| 4647 | + | "regex", | |
| 4648 | + | "serde", | |
| 4649 | + | "serde_json", | |
| 4650 | + | "tokio", | |
| 4651 | + | "url", | |
| 4652 | + | ] | |
| 4653 | + | ||
| 4654 | + | [[package]] | |
| 4573 | 4655 | name = "wit-bindgen" | |
| 4574 | 4656 | version = "0.51.0" | |
| 4575 | 4657 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -7,7 +7,7 @@ members = [ | |||
| 7 | 7 | default-members = ["."] | |
| 8 | 8 | ||
| 9 | 9 | [workspace.package] | |
| 10 | - | version = "0.3.4" | |
| 10 | + | version = "0.3.5" | |
| 11 | 11 | edition = "2024" | |
| 12 | 12 | license-file = "LICENSE" | |
| 13 | 13 | ||
| @@ -96,3 +96,4 @@ time = "0.3" | |||
| 96 | 96 | ||
| 97 | 97 | [dev-dependencies] | |
| 98 | 98 | http-body-util = "0.1" | |
| 99 | + | wiremock = "0.6" |
| @@ -71,6 +71,14 @@ pub struct PostWithAuthor { | |||
| 71 | 71 | pub edited_at: Option<DateTime<Utc>>, | |
| 72 | 72 | pub deleted_at: Option<DateTime<Utc>>, | |
| 73 | 73 | pub removed_at: Option<DateTime<Utc>>, | |
| 74 | + | /// Author's current Fan+ status (denormalised on users; refreshed at | |
| 75 | + | /// OAuth callback / `POST /auth/refresh`). Used for the + badge and | |
| 76 | + | /// for signature visibility — signatures only render for current | |
| 77 | + | /// Fan+ subscribers. | |
| 78 | + | pub author_is_fan_plus: bool, | |
| 79 | + | /// Author's saved signature HTML (rendered at save time). Render only | |
| 80 | + | /// when `author_is_fan_plus` is true. | |
| 81 | + | pub author_signature_html: Option<String>, | |
| 74 | 82 | } | |
| 75 | 83 | ||
| 76 | 84 | #[derive(sqlx::FromRow)] | |
| @@ -415,7 +423,9 @@ pub async fn list_posts_in_thread( | |||
| 415 | 423 | COALESCE(u.display_name, u.username) AS author_name, | |
| 416 | 424 | u.username AS author_username, | |
| 417 | 425 | p.body_html, p.created_at, p.edited_at, p.deleted_at, | |
| 418 | - | p.removed_at | |
| 426 | + | p.removed_at, | |
| 427 | + | u.is_fan_plus AS author_is_fan_plus, | |
| 428 | + | u.signature_html AS author_signature_html | |
| 419 | 429 | FROM posts p | |
| 420 | 430 | JOIN users u ON u.mnw_account_id = p.author_id | |
| 421 | 431 | WHERE p.thread_id = $1 | |
| @@ -438,7 +448,9 @@ pub async fn list_posts_in_thread_paginated( | |||
| 438 | 448 | COALESCE(u.display_name, u.username) AS author_name, | |
| 439 | 449 | u.username AS author_username, | |
| 440 | 450 | p.body_html, p.created_at, p.edited_at, p.deleted_at, | |
| 441 | - | p.removed_at | |
| 451 | + | p.removed_at, | |
| 452 | + | u.is_fan_plus AS author_is_fan_plus, | |
| 453 | + | u.signature_html AS author_signature_html | |
| 442 | 454 | FROM posts p | |
| 443 | 455 | JOIN users u ON u.mnw_account_id = p.author_id | |
| 444 | 456 | WHERE p.thread_id = $1 |
| @@ -13,31 +13,47 @@ v0.3.4. Audit grade A. 228 tests. | |||
| 13 | 13 | ||
| 14 | 14 | ## Platform Integration (Post-Beta) | |
| 15 | 15 | ||
| 16 | - | ### Default Categories — Remaining | |
| 17 | - | - [ ] Issues (git issue tracker replacement — see MNW G8-issues) | |
| 18 | - | - [ ] Patches (inbound email patches — see MNW G7B-patches) | |
| 16 | + | ### Default Categories | |
| 17 | + | Done: | |
| 18 | + | - [x] Issues: MT seeds an "issues" category in default communities; MNW `routes/postmark/issues.rs` spawns an MT thread per inbound issue (project-linked repos only) and routes email replies into that thread as posts. Issue row stores `mt_thread_id` for direct lookup. | |
| 19 | + | - [x] Patches: MT seeds a "patches" category in default communities; MNW `routes/postmark/patches.rs` already wired (auto-creates the category on demand for pre-step-6 communities). | |
| 20 | + | ||
| 21 | + | Still blocked on MNW Developer Services (crash reporting / feedback / dashboard, not yet built): | |
| 19 | 22 | - [ ] Crashes (crash reports from DS2) | |
| 20 | 23 | - [ ] Feedback (user feedback from DS3) | |
| 21 | 24 | ||
| 22 | 25 | ### Fan+ Feature Gating | |
| 23 | - | - [ ] Signatures (text + image, rendered on every post) — Fan+ only | |
| 24 | - | - [ ] Custom / larger profile images — Fan+ only (free accounts get generated avatar) | |
| 25 | - | - [ ] Image and video embeds in posts — Fan+ only (free accounts post text only) | |
| 26 | - | - [ ] Creator auto-grant: creators get all Fan+ forum perks in own communities (no + badge) | |
| 27 | - | - [ ] + badge rendering in post author display | |
| 28 | 26 | ||
| 29 | - | ### Private Communities (Fan+) | |
| 30 | - | - [ ] Community visibility flag (public/private) | |
| 31 | - | - [ ] Membership gating: restrict join to Fan+ subscribers or item buyers | |
| 32 | - | - [ ] Hidden from public listing, accessible only via direct link or MNW project page | |
| 27 | + | Depends on MNW shipping the `perks` object in `/oauth/userinfo` (see MNW server todo: "OAuth userinfo perks object"). Gating predicate: `user.perks.fan_plus || user.perks.is_creator`. Creator auto-grant falls out of the `is_creator` branch — no separate code path. | |
| 28 | + | ||
| 29 | + | Plumbing: | |
| 30 | + | - [x] Extend `SessionUser` with `perks: UserPerks { fan_plus, is_creator, creator_tier: Option<{ tier, features }> }` and `effective_plus()` helper | |
| 31 | + | - [x] `auth::refresh_session(state, session)` — reads cached access token, re-hits `/oauth/userinfo`, overwrites session perks. Flushes session on `401`, leaves intact on transient errors | |
| 32 | + | - [x] `POST /auth/refresh` route — JSON response with refreshed perks; `401` if not logged in, `502` on MNW transport/parse error | |
| 33 | + | - [x] Refactored userinfo fetch into reusable `fetch_userinfo`; callback handler now retries on transport only | |
| 34 | + | ||
| 35 | + | Gated features: | |
| 36 | + | - [x] Signatures (markdown + image, 1024 char cap, rendered below post body). Edit form at `/account`. Render-time visibility gated on current `users.is_fan_plus` — lapsed users keep the row but the signature hides until they renew. | |
| 37 | + | - [x] Image embeds in posts (markdown ``), gated via `render_markdown_plus` (strict + images permitted). Non-plus users get a 422 with a clear "Fan+ feature" message at submit time. Applies to thread bodies, replies, and footnotes. | |
| 38 | + | - [x] + badge in author display: shown only for users with active Fan+ subscription (creators with auto-grant do NOT get the badge — auto-grant covers editor capabilities, not the public badge). | |
| 39 | + | - [x] Denormalised `users.is_fan_plus` / `is_creator` columns (migration 026) mirror MNW perks; refreshed on login + `POST /auth/refresh`. Post-author lookup uses these via SQL JOIN — no per-post HTTP call. | |
| 40 | + | - [ ] Custom / larger profile images — **deferred**: MT pulls `avatar_url` from MNW. Forum-local avatar storage would be a separate feature; out of scope for current launch. | |
| 33 | 41 | ||
| 34 | 42 | ### Community Moderation Enforcement | |
| 35 | - | - [ ] Restricted state: disable new thread creation for non-moderators | |
| 36 | - | - [ ] Frozen state: community goes read-only, mods can still take mod actions to unfreeze | |
| 37 | - | - [ ] Clean slate mechanism: clear all threads/posts, preserve settings/categories, post system notice | |
| 38 | - | - [ ] Archived state with reactivation path | |
| 39 | - | - [ ] PoM integration: flag age monitoring, flag-to-action ratio alerts | |
| 40 | - | - [ ] Document moderation policy publicly (see `docs/internal/moderation_policy.md` for internal version) | |
| 43 | + | ||
| 44 | + | Two-layer auth model already exists: superadmin = `PLATFORM_ADMIN_ID` (single user), forum-level = `CommunityRole::{Owner, Moderator}`. State changes and clean-slate authorized by `is_mod_or_owner(role) || is_platform_admin(user)` — wrap as `require_mod_or_superadmin` helper to keep boilerplate down. No new permission concepts; more robust system deferred. | |
| 45 | + | ||
| 46 | + | - [x] Add `community.state` enum column: `Active | Restricted | Frozen | Archived` (migration 025, `CommunityState` in mt-core, `set_community_state` mutation) | |
| 47 | + | - [x] `require_mod_or_superadmin` / `is_mod_or_superadmin` / `is_platform_admin` helpers + `WriteScope` + `check_write_state` enforcement helper | |
| 48 | + | - [x] Restricted state: block new thread creation for non-mods; existing threads still accept replies | |
| 49 | + | - [x] Frozen state: read-only for everyone except mods/superadmin (blocks new threads, replies, footnotes, endorsements) | |
| 50 | + | - [x] Archived state: Frozen behavior + hidden from default `/` listing; exposed under `?filter=archived`; reactivation sets state back to Active | |
| 51 | + | - [x] State-change route: `POST /p/{slug}/settings/state` (owner/mod/superadmin); rejects unknown values with 422; logs `ModAction::ChangeCommunityState` | |
| 52 | + | - [x] Clean-slate mutation: transactional delete of all threads/posts (cascades through endorsements/flags/footnotes/etc.), preserves community/categories/memberships/bans/tags; posts a pinned+locked "Community reset by <actor> on <date>" thread in the first category | |
| 53 | + | - [x] Clean-slate UX: typed-phrase confirmation matching community slug (GitHub repo-delete style); 422 on mismatch | |
| 54 | + | - [x] Superadmin UX: dedicated `GET /_admin/communities/{slug}` view with state-change form + clean-slate danger zone; linked from the admin dashboard community table | |
| 55 | + | - [x] Moderation policy published at `MNW/server/site-docs/public/guide/moderation.md`; linked from MT footer | |
| 56 | + | - [x] `ModAction::CleanSlateCommunity` logged with deleted thread count + system thread ID for audit | |
| 41 | 57 | ||
| 42 | 58 | ### Notification Integration | |
| 43 | 59 | - [ ] Push mentions, replies, endorsements, flags to MNW notifications API | |
| @@ -47,9 +63,9 @@ v0.3.4. Audit grade A. 228 tests. | |||
| 47 | 63 | ||
| 48 | 64 | ## Deferred (Post-Beta) | |
| 49 | 65 | ||
| 66 | + | - [ ] Private communities (visibility flag, membership gating, hidden listing) — tabled; focus is project-oriented and creator-oriented public forums | |
| 50 | 67 | - [ ] E2E encrypted live chat (OpenMLS integration, WebSocket gateway) | |
| 51 | 68 | - [ ] Real-time thread updates via shared WebSocket gateway (shared with SyncKit realtime sync — single service) | |
| 52 | - | - [ ] Community creation by users (currently admin-seeded only; MNW auto-provisioning handles project communities) | |
| 53 | 69 | - [ ] Federation (ActivityPub or custom protocol) | |
| 54 | 70 | - [ ] Subcategories / nested categories | |
| 55 | 71 | - [ ] Similar thread detection on new thread creation |
| @@ -0,0 +1,17 @@ | |||
| 1 | + | -- Fan+ perks: signature columns + denormalised perk flags. | |
| 2 | + | -- | |
| 3 | + | -- `is_fan_plus` and `is_creator` mirror the latest MNW `/oauth/userinfo` | |
| 4 | + | -- `perks` snapshot for this user. They're cached here so post rendering can | |
| 5 | + | -- gate the + badge and signature display by JOINing against users instead of | |
| 6 | + | -- making per-post calls to MNW. Refreshed on login + `POST /auth/refresh` | |
| 7 | + | -- (see `src/auth.rs`). | |
| 8 | + | -- | |
| 9 | + | -- Signature: free-text markdown shown beneath each post by Fan+ subscribers. | |
| 10 | + | -- We store both the source markdown and pre-rendered HTML, matching the | |
| 11 | + | -- pattern used for posts. Render at save time only. | |
| 12 | + | ||
| 13 | + | ALTER TABLE users | |
| 14 | + | ADD COLUMN signature_markdown TEXT, | |
| 15 | + | ADD COLUMN signature_html TEXT, | |
| 16 | + | ADD COLUMN is_fan_plus BOOLEAN NOT NULL DEFAULT FALSE, | |
| 17 | + | ADD COLUMN is_creator BOOLEAN NOT NULL DEFAULT FALSE; |
| @@ -1,13 +1,19 @@ | |||
| 1 | 1 | //! OAuth client for "Log in with Makenot.work" and session user extraction. | |
| 2 | + | //! | |
| 3 | + | //! Perks (Fan+, creator tier, capabilities) come from MNW's `/oauth/userinfo` | |
| 4 | + | //! `perks` object. We cache them in the session and refresh on three triggers: | |
| 5 | + | //! (1) login, (2) session cycle, (3) on-demand via `POST /auth/refresh`. See | |
| 6 | + | //! `MNW/server/docs/oauth_integration.md` for the contract. | |
| 2 | 7 | ||
| 3 | 8 | use axum::{ | |
| 4 | 9 | extract::{FromRequestParts, Query, State}, | |
| 5 | 10 | http::{request::Parts, StatusCode}, | |
| 6 | 11 | response::{IntoResponse, Redirect}, | |
| 12 | + | Json, | |
| 7 | 13 | }; | |
| 8 | 14 | use base64::Engine; | |
| 9 | 15 | use rand::RngCore; | |
| 10 | - | use serde::Deserialize; | |
| 16 | + | use serde::{Deserialize, Serialize}; | |
| 11 | 17 | use sha2::{Digest, Sha256}; | |
| 12 | 18 | use tokio::time::sleep; | |
| 13 | 19 | use tower_sessions::Session; | |
| @@ -37,17 +43,52 @@ fn generate_state_nonce() -> String { | |||
| 37 | 43 | ||
| 38 | 44 | // ── Session user ── | |
| 39 | 45 | ||
| 40 | - | /// Minimal user info stored in the session after OAuth login. | |
| 46 | + | /// User info cached in the session after OAuth login. | |
| 47 | + | /// | |
| 48 | + | /// `perks` reflects MNW state at the last refresh (login, session cycle, or | |
| 49 | + | /// explicit `POST /auth/refresh`). Use [`UserPerks::effective_plus`] for the | |
| 50 | + | /// canonical Fan+ gate. | |
| 41 | 51 | #[derive(Clone, Debug)] | |
| 42 | 52 | pub struct SessionUser { | |
| 43 | 53 | pub user_id: uuid::Uuid, | |
| 44 | 54 | pub username: String, | |
| 45 | 55 | pub display_name: Option<String>, | |
| 56 | + | pub perks: UserPerks, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Capability snapshot from MNW's `/oauth/userinfo` `perks` object. | |
| 60 | + | /// | |
| 61 | + | /// Default = no perks; this is what unknown / not-yet-refreshed sessions see. | |
| 62 | + | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 63 | + | pub struct UserPerks { | |
| 64 | + | #[serde(default)] | |
| 65 | + | pub fan_plus: bool, | |
| 66 | + | #[serde(default)] | |
| 67 | + | pub is_creator: bool, | |
| 68 | + | #[serde(default)] | |
| 69 | + | pub creator_tier: Option<CreatorTierInfo>, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 73 | + | pub struct CreatorTierInfo { | |
| 74 | + | pub tier: String, | |
| 75 | + | pub features: Vec<String>, | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | impl UserPerks { | |
| 79 | + | /// Canonical "should this user see + features" check. True for active Fan+ | |
| 80 | + | /// subscribers and for any creator (auto-grant: creators get + perks without | |
| 81 | + | /// paying for Fan+ separately). | |
| 82 | + | pub fn effective_plus(&self) -> bool { | |
| 83 | + | self.fan_plus || self.is_creator | |
| 84 | + | } | |
| 46 | 85 | } | |
| 47 | 86 | ||
| 48 | 87 | const SESSION_USER_ID: &str = "user_id"; | |
| 49 | 88 | const SESSION_USERNAME: &str = "username"; | |
| 50 | 89 | const SESSION_DISPLAY_NAME: &str = "display_name"; | |
| 90 | + | const SESSION_PERKS: &str = "perks"; | |
| 91 | + | const SESSION_ACCESS_TOKEN: &str = "mnw_access_token"; | |
| 51 | 92 | const SESSION_OAUTH_STATE: &str = "oauth_state"; | |
| 52 | 93 | const SESSION_PKCE_VERIFIER: &str = "pkce_verifier"; | |
| 53 | 94 | ||
| @@ -74,10 +115,17 @@ impl SessionUser { | |||
| 74 | 115 | None | |
| 75 | 116 | } | |
| 76 | 117 | }; | |
| 118 | + | // Perks default to empty — sessions predating the perks change still load. | |
| 119 | + | let perks: UserPerks = session | |
| 120 | + | .get(SESSION_PERKS) | |
| 121 | + | .await | |
| 122 | + | .unwrap_or_default() | |
| 123 | + | .unwrap_or_default(); | |
| 77 | 124 | Some(Self { | |
| 78 | 125 | user_id, | |
| 79 | 126 | username, | |
| 80 | 127 | display_name, | |
| 128 | + | perks, | |
| 81 | 129 | }) | |
| 82 | 130 | } | |
| 83 | 131 | ||
| @@ -91,6 +139,9 @@ impl SessionUser { | |||
| 91 | 139 | if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &self.display_name).await { | |
| 92 | 140 | tracing::error!(error = %e, "failed to save display_name to session"); | |
| 93 | 141 | } | |
| 142 | + | if let Err(e) = session.insert(SESSION_PERKS, &self.perks).await { | |
| 143 | + | tracing::error!(error = %e, "failed to save perks to session"); | |
| 144 | + | } | |
| 94 | 145 | } | |
| 95 | 146 | } | |
| 96 | 147 | ||
| @@ -157,6 +208,111 @@ struct UserinfoResponse { | |||
| 157 | 208 | username: String, | |
| 158 | 209 | display_name: Option<String>, | |
| 159 | 210 | avatar_url: Option<String>, | |
| 211 | + | #[serde(default)] | |
| 212 | + | perks: UserPerks, | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | #[derive(Debug)] | |
| 216 | + | pub enum UserinfoError { | |
| 217 | + | Unauthorized, | |
| 218 | + | Transport, | |
| 219 | + | BadResponse, | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// Single-attempt userinfo fetch against MNW. Callers decide retry policy. | |
| 223 | + | /// | |
| 224 | + | /// `Unauthorized` means the bearer token is invalid or the user is gone. | |
| 225 | + | /// `Transport` covers network and 5xx. `BadResponse` covers other 4xx and parse | |
| 226 | + | /// errors. The login callback retries on `Transport`; `refresh_session` does | |
| 227 | + | /// not — the client can retry. | |
| 228 | + | async fn fetch_userinfo( | |
| 229 | + | http: &reqwest::Client, | |
| 230 | + | base_url: &str, | |
| 231 | + | access_token: &str, | |
| 232 | + | ) -> Result<UserinfoResponse, UserinfoError> { | |
| 233 | + | let url = format!("{}/oauth/userinfo", base_url); | |
| 234 | + | let res = http | |
| 235 | + | .get(&url) | |
| 236 | + | .bearer_auth(access_token) | |
| 237 | + | .send() | |
| 238 | + | .await | |
| 239 | + | .map_err(|e| { | |
| 240 | + | tracing::warn!(error = %e, "userinfo transport error"); | |
| 241 | + | UserinfoError::Transport | |
| 242 | + | })?; | |
| 243 | + | ||
| 244 | + | let status = res.status(); | |
| 245 | + | if status == reqwest::StatusCode::UNAUTHORIZED { | |
| 246 | + | return Err(UserinfoError::Unauthorized); | |
| 247 | + | } | |
| 248 | + | if status.is_server_error() { | |
| 249 | + | return Err(UserinfoError::Transport); | |
| 250 | + | } | |
| 251 | + | if !status.is_success() { | |
| 252 | + | let body = res.text().await.unwrap_or_default(); | |
| 253 | + | tracing::warn!(%status, %body, "userinfo non-success"); | |
| 254 | + | return Err(UserinfoError::BadResponse); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | res.json::<UserinfoResponse>().await.map_err(|e| { | |
| 258 | + | tracing::warn!(error = %e, "userinfo parse failed"); | |
| 259 | + | UserinfoError::BadResponse | |
| 260 | + | }) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | /// Refresh the cached perks for the current session by re-hitting MNW. | |
| 264 | + | /// | |
| 265 | + | /// Caller must have a logged-in session (access token stored at login). On | |
| 266 | + | /// `Unauthorized` the session is flushed — the access token is gone for good | |
| 267 | + | /// and the user needs to log in again. Other errors leave the session intact. | |
| 268 | + | pub async fn refresh_session( | |
| 269 | + | state: &AppState, | |
| 270 | + | session: &Session, | |
| 271 | + | ) -> Result<UserPerks, UserinfoError> { | |
| 272 | + | let token: String = session | |
| 273 | + | .get(SESSION_ACCESS_TOKEN) | |
| 274 | + | .await | |
| 275 | + | .unwrap_or(None) | |
| 276 | + | .ok_or(UserinfoError::Unauthorized)?; | |
| 277 | + | ||
| 278 | + | match fetch_userinfo(&state.http, &state.config.mnw_base_url, &token).await { | |
| 279 | + | Ok(info) => { | |
| 280 | + | if let Err(e) = session.insert(SESSION_PERKS, &info.perks).await { | |
| 281 | + | tracing::error!(error = %e, "failed to save refreshed perks"); | |
| 282 | + | } | |
| 283 | + | // Username/display can drift on MNW too — sync them while we're here. | |
| 284 | + | if let Err(e) = session.insert(SESSION_USERNAME, &info.username).await { | |
| 285 | + | tracing::error!(error = %e, "failed to save refreshed username"); | |
| 286 | + | } | |
| 287 | + | if let Err(e) = session.insert(SESSION_DISPLAY_NAME, &info.display_name).await { | |
| 288 | + | tracing::error!(error = %e, "failed to save refreshed display_name"); | |
| 289 | + | } | |
| 290 | + | // Mirror perks into users table so post rendering sees the change | |
| 291 | + | // without consulting MNW per-post. Best-effort: rendering tolerates | |
| 292 | + | // a stale row, so DB errors here are logged but non-fatal. | |
| 293 | + | if let Err(e) = sqlx::query( | |
| 294 | + | "UPDATE users SET is_fan_plus = $2, is_creator = $3 WHERE mnw_account_id = $1", | |
| 295 | + | ) | |
| 296 | + | .bind(info.user_id) | |
| 297 | + | .bind(info.perks.fan_plus) | |
| 298 | + | .bind(info.perks.is_creator) | |
| 299 | + | .execute(&state.db) | |
| 300 | + | .await | |
| 301 | + | { | |
| 302 | + | tracing::warn!(error = %e, "failed to mirror refreshed perks to users table"); | |
| 303 | + | } | |
| 304 | + | let _ = info.avatar_url; // not stored in session yet | |
| 305 | + | Ok(info.perks) | |
| 306 | + | } | |
| 307 | + | Err(UserinfoError::Unauthorized) => { | |
| 308 | + | // Token revoked, expired, or user deleted — drop the session. | |
| 309 | + | if let Err(e) = session.flush().await { | |
| 310 | + | tracing::warn!(error = %e, "failed to flush session after auth failure"); | |
| 311 | + | } | |
| 312 | + | Err(UserinfoError::Unauthorized) | |
| 313 | + | } | |
| 314 | + | Err(e) => Err(e), | |
| 315 | + | } | |
| 160 | 316 | } | |
| 161 | 317 | ||
| 162 | 318 | // ── Handlers ── | |
| @@ -288,77 +444,56 @@ pub async fn callback( | |||
| 288 | 444 | } | |
| 289 | 445 | }; | |
| 290 | 446 | ||
| 291 | - | // Fetch userinfo (retry up to 2 attempts on network/5xx errors) | |
| 292 | - | let userinfo_url = format!("{}/oauth/userinfo", state.config.mnw_base_url); | |
| 293 | - | tracing::info!(%userinfo_url, "fetching userinfo"); | |
| 294 | - | let mut userinfo_res = None; | |
| 447 | + | // Fetch userinfo (retry up to 2 attempts on transport / 5xx errors). | |
| 448 | + | tracing::info!(base_url = %state.config.mnw_base_url, "fetching userinfo"); | |
| 449 | + | let mut info: Option<UserinfoResponse> = None; | |
| 295 | 450 | for attempt in 0..=backoffs.len() { | |
| 296 | - | let res = state | |
| 297 | - | .http | |
| 298 | - | .get(&userinfo_url) | |
| 299 | - | .bearer_auth(&token.access_token) | |
| 300 | - | .send() | |
| 301 | - | .await; | |
| 302 | - | ||
| 303 | - | match res { | |
| 304 | - | Ok(r) if r.status().is_server_error() => { | |
| 305 | - | let status = r.status(); | |
| 306 | - | if attempt < backoffs.len() { | |
| 307 | - | tracing::warn!(%status, attempt, "userinfo got 5xx, retrying"); | |
| 308 | - | sleep(backoffs[attempt]).await; | |
| 309 | - | continue; | |
| 310 | - | } | |
| 311 | - | let body = r.text().await.unwrap_or_default(); | |
| 312 | - | tracing::error!(%status, %body, "userinfo fetch failed after retries"); | |
| 313 | - | return Redirect::to("/?error=userinfo_fetch_failed"); | |
| 451 | + | match fetch_userinfo(&state.http, &state.config.mnw_base_url, &token.access_token).await { | |
| 452 | + | Ok(i) => { | |
| 453 | + | info = Some(i); | |
| 454 | + | break; | |
| 314 | 455 | } | |
| 315 | - | Ok(r) if !r.status().is_success() => { | |
| 316 | - | let status = r.status(); | |
| 317 | - | let body = r.text().await.unwrap_or_default(); | |
| 318 | - | tracing::error!(%status, %body, "userinfo fetch failed"); | |
| 456 | + | Err(UserinfoError::Transport) if attempt < backoffs.len() => { | |
| 457 | + | tracing::warn!(attempt, "userinfo transport error, retrying"); | |
| 458 | + | sleep(backoffs[attempt]).await; | |
| 459 | + | continue; | |
| 460 | + | } | |
| 461 | + | Err(UserinfoError::Transport) => { | |
| 462 | + | tracing::error!("userinfo transport failed after retries"); | |
| 319 | 463 | return Redirect::to("/?error=userinfo_fetch_failed"); | |
| 320 | 464 | } | |
| 321 | - | Ok(r) => { | |
| 322 | - | userinfo_res = Some(r); | |
| 323 | - | break; | |
| 465 | + | Err(UserinfoError::Unauthorized) => { | |
| 466 | + | tracing::error!("userinfo unauthorized — token rejected"); | |
| 467 | + | return Redirect::to("/?error=userinfo_fetch_failed"); | |
| 324 | 468 | } | |
| 325 | - | Err(e) => { | |
| 326 | - | if attempt < backoffs.len() { | |
| 327 | - | tracing::warn!(error = %e, attempt, "userinfo request failed, retrying"); | |
| 328 | - | sleep(backoffs[attempt]).await; | |
| 329 | - | continue; | |
| 330 | - | } | |
| 331 | - | tracing::error!(error = %e, "userinfo request failed after retries"); | |
| 332 | - | return Redirect::to("/?error=userinfo_request_failed"); | |
| 469 | + | Err(UserinfoError::BadResponse) => { | |
| 470 | + | tracing::error!("userinfo bad response"); | |
| 471 | + | return Redirect::to("/?error=userinfo_parse_failed"); | |
| 333 | 472 | } | |
| 334 | 473 | } | |
| 335 | 474 | } | |
| 336 | - | // Safety: loop always either sets userinfo_res or returns early | |
| 337 | - | let userinfo_res = userinfo_res.unwrap(); | |
| 338 | - | ||
| 339 | - | let info: UserinfoResponse = match userinfo_res.json().await { | |
| 340 | - | Ok(i) => i, | |
| 341 | - | Err(e) => { | |
| 342 | - | tracing::error!(error = %e, "userinfo parse failed"); | |
| 343 | - | return Redirect::to("/?error=userinfo_parse_failed"); | |
| 344 | - | } | |
| 345 | - | }; | |
| 475 | + | let info = info.expect("userinfo loop always sets value or returns"); | |
| 346 | 476 | ||
| 347 | 477 | tracing::info!(user_id = %info.user_id, username = %info.username, "OAuth login successful"); | |
| 348 | 478 | ||
| 349 | - | // Upsert local user | |
| 479 | + | // Upsert local user. `is_fan_plus`/`is_creator` are denormalised here so | |
| 480 | + | // post rendering can look up the post author's perks via JOIN — see | |
| 481 | + | // migration 026. | |
| 350 | 482 | let upsert_result = sqlx::query( | |
| 351 | 483 | r#" | |
| 352 | - | INSERT INTO users (mnw_account_id, username, display_name, avatar_url) | |
| 353 | - | VALUES ($1, $2, $3, $4) | |
| 484 | + | INSERT INTO users (mnw_account_id, username, display_name, avatar_url, is_fan_plus, is_creator) | |
| 485 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 354 | 486 | ON CONFLICT (mnw_account_id) DO UPDATE | |
| 355 | - | SET username = $2, display_name = $3, avatar_url = $4, updated_at = now() | |
| 487 | + | SET username = $2, display_name = $3, avatar_url = $4, | |
| 488 | + | is_fan_plus = $5, is_creator = $6, updated_at = now() | |
| 356 | 489 | "#, | |
| 357 | 490 | ) | |
| 358 | 491 | .bind(info.user_id) | |
| 359 | 492 | .bind(&info.username) | |
| 360 | 493 | .bind(&info.display_name) | |
| 361 | 494 | .bind(&info.avatar_url) | |
| 495 | + | .bind(info.perks.fan_plus) | |
| 496 | + | .bind(info.perks.is_creator) | |
| 362 | 497 | .execute(&state.db) | |
| 363 | 498 | .await; | |
| 364 | 499 | ||
| @@ -386,13 +521,21 @@ pub async fn callback( | |||
| 386 | 521 | return Redirect::to("/?error=account_suspended"); | |
| 387 | 522 | } | |
| 388 | 523 | ||
| 389 | - | // Save session | |
| 524 | + | // Save session — perks come from the same userinfo response, no second roundtrip. | |
| 390 | 525 | let session_user = SessionUser { | |
| 391 | 526 | user_id: info.user_id, | |
| 392 | 527 | username: info.username, | |
| 393 | 528 | display_name: info.display_name, | |
| 529 | + | perks: info.perks, | |
| 394 | 530 | }; | |
| 395 | 531 | session_user.save_to_session(&session).await; | |
| 532 | + | // Stash the access token so `refresh_session` can re-hit userinfo without | |
| 533 | + | // forcing the user through another OAuth round trip. Token lifetime is set | |
| 534 | + | // by MNW (7d as of writing); after expiry, refresh returns Unauthorized and | |
| 535 | + | // the session is flushed. | |
| 536 | + | if let Err(e) = session.insert(SESSION_ACCESS_TOKEN, &token.access_token).await { | |
| 537 | + | tracing::error!(error = %e, "failed to save access token to session"); | |
| 538 | + | } | |
| 396 | 539 | if let Err(e) = session.cycle_id().await { | |
| 397 | 540 | tracing::warn!(error = %e, "Failed to cycle session ID"); | |
| 398 | 541 | } | |
| @@ -401,6 +544,30 @@ pub async fn callback( | |||
| 401 | 544 | Redirect::to("/") | |
| 402 | 545 | } | |
| 403 | 546 | ||
| 547 | + | /// `POST /auth/refresh` — re-fetch MNW userinfo and overwrite cached perks. | |
| 548 | + | /// | |
| 549 | + | /// Useful after the user takes an action that changed their MNW entitlements | |
| 550 | + | /// (e.g., subscribing to Fan+, upgrading a creator tier) so they don't have to | |
| 551 | + | /// log out and back in to see the new perks. Returns the refreshed perks as | |
| 552 | + | /// JSON. | |
| 553 | + | #[tracing::instrument(skip_all)] | |
| 554 | + | pub async fn refresh( | |
| 555 | + | State(state): State<AppState>, | |
| 556 | + | session: Session, | |
| 557 | + | ) -> Result<Json<RefreshResponse>, StatusCode> { | |
| 558 | + | match refresh_session(&state, &session).await { | |
| 559 | + | Ok(perks) => Ok(Json(RefreshResponse { perks })), | |
| 560 | + | Err(UserinfoError::Unauthorized) => Err(StatusCode::UNAUTHORIZED), | |
| 561 | + | Err(UserinfoError::Transport) => Err(StatusCode::BAD_GATEWAY), | |
| 562 | + | Err(UserinfoError::BadResponse) => Err(StatusCode::BAD_GATEWAY), | |
| 563 | + | } | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | #[derive(Serialize)] | |
| 567 | + | pub struct RefreshResponse { | |
| 568 | + | pub perks: UserPerks, | |
| 569 | + | } | |
| 570 | + | ||
| 404 | 571 | /// `POST /auth/logout` — flush session, redirect home. | |
| 405 | 572 | #[tracing::instrument(skip_all)] | |
| 406 | 573 | pub async fn logout(session: Session) -> impl IntoResponse { |
| @@ -0,0 +1,134 @@ | |||
| 1 | + | //! Per-user account settings (signature + Fan+ status display). | |
| 2 | + | //! | |
| 3 | + | //! All routes require a logged-in session. The signature editor is gated on | |
| 4 | + | //! [`UserPerks::effective_plus`]: non-Fan+ users see the upsell instead of an | |
| 5 | + | //! input. Lapsed Fan+ users retain their saved markdown (we don't auto-delete | |
| 6 | + | //! it) but the post-rendering layer hides their signature until they renew. | |
| 7 | + | ||
| 8 | + | use axum::{ | |
| 9 | + | extract::{Form, State}, | |
| 10 | + | http::StatusCode, | |
| 11 | + | response::{IntoResponse, Redirect, Response}, | |
| 12 | + | }; | |
| 13 | + | use tower_sessions::Session; | |
| 14 | + | ||
| 15 | + | use crate::auth::MaybeUser; | |
| 16 | + | use crate::csrf; | |
| 17 | + | use crate::templates::*; | |
| 18 | + | use crate::AppState; | |
| 19 | + | ||
| 20 | + | use super::{ | |
| 21 | + | render_markdown, render_markdown_plus, template_user, SignatureForm, | |
| 22 | + | }; | |
| 23 | + | ||
| 24 | + | const SIGNATURE_MAX: usize = 1024; | |
| 25 | + | ||
| 26 | + | #[tracing::instrument(skip_all)] | |
| 27 | + | pub(super) async fn account_settings( | |
| 28 | + | State(state): State<AppState>, | |
| 29 | + | session: Session, | |
| 30 | + | MaybeUser(session_user): MaybeUser, | |
| 31 | + | ) -> Result<AccountSettingsTemplate, Response> { | |
| 32 | + | let csrf_token = Some(csrf::get_or_create_token(&session).await); | |
| 33 | + | let user = session_user | |
| 34 | + | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 35 | + | ||
| 36 | + | let row: Option<(Option<String>, Option<String>)> = sqlx::query_as( | |
| 37 | + | "SELECT signature_markdown, signature_html FROM users WHERE mnw_account_id = $1", | |
| 38 | + | ) | |
| 39 | + | .bind(user.user_id) | |
| 40 | + | .fetch_optional(&state.db) | |
| 41 | + | .await | |
| 42 | + | .map_err(|e| { | |
| 43 | + | tracing::error!(error = ?e, "db error loading signature"); | |
| 44 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 45 | + | })?; | |
| 46 | + | let (signature_markdown, signature_html) = row.unwrap_or((None, None)); | |
| 47 | + | ||
| 48 | + | Ok(AccountSettingsTemplate { | |
| 49 | + | csrf_token, | |
| 50 | + | session_user: Some(template_user(&user, state.config.platform_admin_id)), | |
| 51 | + | mnw_base_url: state.config.mnw_base_url.clone(), | |
| 52 | + | has_plus: user.perks.effective_plus(), | |
| 53 | + | fan_plus: user.perks.fan_plus, | |
| 54 | + | signature_markdown, | |
| 55 | + | signature_html, | |
| 56 | + | error: None, | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | #[tracing::instrument(skip_all)] | |
| 61 | + | pub(super) async fn update_signature_handler( | |
| 62 | + | State(state): State<AppState>, | |
| 63 | + | MaybeUser(session_user): MaybeUser, | |
| 64 | + | Form(form): Form<SignatureForm>, | |
| 65 | + | ) -> Result<Redirect, Response> { | |
| 66 | + | let user = session_user | |
| 67 | + | .ok_or_else(|| Redirect::to("/auth/login").into_response())?; | |
| 68 | + | ||
| 69 | + | if !user.perks.effective_plus() { | |
| 70 | + | return Err(( | |
| 71 | + | StatusCode::FORBIDDEN, | |
| 72 | + | "Signatures are a Fan+ feature.", | |
| 73 | + | ) | |
| 74 | + | .into_response()); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // "Clear signature" button submits with `clear=1`; takes precedence over | |
| 78 | + | // the textarea content. | |
| 79 | + | if form.clear.as_deref() == Some("1") { | |
| 80 | + | sqlx::query( | |
| 81 | + | "UPDATE users SET signature_markdown = NULL, signature_html = NULL \ | |
| 82 | + | WHERE mnw_account_id = $1", | |
| 83 | + | ) | |
| 84 | + | .bind(user.user_id) | |
| 85 | + | .execute(&state.db) | |
| 86 | + | .await | |
| 87 | + | .map_err(|e| { | |
| 88 | + | tracing::error!(error = ?e, "db error clearing signature"); | |
| 89 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 90 | + | })?; | |
| 91 | + | return Ok(Redirect::to("/account?toast=Signature+cleared")); | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | let trimmed = form.signature.trim(); | |
| 95 | + | if trimmed.is_empty() { | |
| 96 | + | // Treat empty submit as a no-op rather than implicit clear — there's | |
| 97 | + | // an explicit Clear button for that. | |
| 98 | + | return Ok(Redirect::to("/account")); | |
| 99 | + | } | |
| 100 | + | if trimmed.chars().count() > SIGNATURE_MAX { | |
| 101 | + | return Err(( | |
| 102 | + | StatusCode::UNPROCESSABLE_ENTITY, | |
| 103 | + | format!("Signature must be at most {SIGNATURE_MAX} characters."), | |
| 104 | + | ) | |
| 105 | + | .into_response()); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Render with the same plus-aware paths as posts: creators get image | |
| 109 | + | // embeds via auto-grant, matching post-body behaviour. Render-time | |
| 110 | + | // visibility is gated by `users.is_fan_plus` so creator signatures | |
| 111 | + | // still only surface when they're also a Fan+ subscriber — auto-grant | |
| 112 | + | // covers editing capability, not the public + badge / signature display. | |
| 113 | + | let signature_html = if user.perks.effective_plus() { | |
| 114 | + | render_markdown_plus(trimmed) | |
| 115 | + | } else { | |
| 116 | + | render_markdown(trimmed) | |
| 117 | + | }; | |
| 118 | + | ||
| 119 | + | sqlx::query( | |
| 120 | + | "UPDATE users SET signature_markdown = $2, signature_html = $3 \ | |
| 121 | + | WHERE mnw_account_id = $1", | |
| 122 | + | ) | |
| 123 | + | .bind(user.user_id) | |
| 124 | + | .bind(trimmed) | |
| 125 | + | .bind(&signature_html) | |
| 126 | + | .execute(&state.db) | |
| 127 | + | .await | |
| 128 | + | .map_err(|e| { | |
| 129 | + | tracing::error!(error = ?e, "db error saving signature"); | |
| 130 | + | StatusCode::INTERNAL_SERVER_ERROR.into_response() | |
| 131 | + | })?; | |
| 132 | + | ||
| 133 | + | Ok(Redirect::to("/account?toast=Signature+saved")) | |
| 134 | + | } |
| @@ -11,7 +11,7 @@ use crate::auth::MaybeUser; | |||
| 11 | 11 | use crate::AppState; | |
| 12 | 12 | ||
| 13 | 13 | use super::super::{ | |
| 14 | - | check_community_access, check_user_post_rate, check_write_access, get_community, | |
| 14 | + | check_community_access, check_user_post_rate, check_write_access, check_write_state, get_community, WriteScope, | |
| 15 | 15 | parse_uuid, validate_body, FootnoteForm, | |
| 16 | 16 | }; | |
| 17 | 17 | use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST}; | |
| @@ -77,6 +77,7 @@ pub(in crate::routes) async fn add_footnote_handler( | |||
| 77 | 77 | let community = get_community(&state.db, &slug).await?; | |
| 78 | 78 | ||
| 79 | 79 | check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?; | |
| 80 | + | check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?; | |
| 80 | 81 | mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) | |
| 81 | 82 | .await | |
| 82 | 83 | .map_err(|e| { | |
| @@ -86,9 +87,13 @@ pub(in crate::routes) async fn add_footnote_handler( | |||
| 86 | 87 | check_user_post_rate(&state.db, user.user_id).await?; | |
| 87 | 88 | ||
| 88 | 89 | let body = validate_body(&form.body, 65536, "Footnote")?; | |
| 90 | + | let author_plus = user.perks.effective_plus(); | |
| 91 | + | if !author_plus { | |
| 92 | + | crate::routes::reject_embeds_for_free_user(body)?; | |
| 93 | + | } | |
| 89 | 94 | ||
| 90 | 95 | let (body_html, _mention_ids) = resolve_and_render_mentions( | |
| 91 | - | &state.db, body, community.id, &slug, user.user_id, | |
| 96 | + | &state.db, body, community.id, &slug, user.user_id, author_plus, | |
| 92 | 97 | ).await?; | |
| 93 | 98 | ||
| 94 | 99 | mt_db::mutations::insert_footnote(&state.db, post_id, user.user_id, body, &body_html) |
| @@ -17,9 +17,10 @@ use crate::AppState; | |||
| 17 | 17 | use mt_core::types::ModAction; | |
| 18 | 18 | ||
| 19 | 19 | use super::super::{ | |
| 20 | - | check_user_post_rate, check_write_access, get_community, get_thread, is_mod_or_owner, | |
| 21 | - | log_mod_action, parse_uuid, render_markdown, render_markdown_with_mentions, | |
| 22 | - | template_user, validate_body, validate_title, get_role, | |
| 20 | + | check_user_post_rate, check_write_access, check_write_state, get_community, get_thread, | |
| 21 | + | is_mod_or_owner, log_mod_action, parse_uuid, reject_embeds_for_free_user, render_markdown, | |
| 22 | + | render_markdown_plus, render_markdown_with_mentions, template_user, validate_body, | |
| 23 | + | validate_title, get_role, WriteScope, | |
| 23 | 24 | CreateReplyForm, CreateThreadForm, | |
| 24 | 25 | }; | |
| 25 | 26 | ||
| @@ -119,6 +120,7 @@ pub(super) async fn verify_quotes( | |||
| 119 | 120 | ||
| 120 | 121 | /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids). | |
| 121 | 122 | /// `author_id` is excluded from the mention list (self-mention not stored). | |
| 123 | + | /// `allow_images` enables image embeds (Fan+ subscribers only). | |
| 122 | 124 | #[tracing::instrument(skip_all)] | |
| 123 | 125 | pub(super) async fn resolve_and_render_mentions( | |
| 124 | 126 | db: &sqlx::PgPool, | |
| @@ -126,10 +128,16 @@ pub(super) async fn resolve_and_render_mentions( | |||
| 126 | 128 | community_id: Uuid, | |
| 127 | 129 | community_slug: &str, | |
| 128 | 130 | author_id: Uuid, | |
| 131 | + | allow_images: bool, | |
| 129 | 132 | ) -> Result<(String, Vec<Uuid>), Response> { | |
| 130 | 133 | let usernames = docengine::extract_mentions(body); | |
| 131 | 134 | if usernames.is_empty() { | |
| 132 | - | return Ok((render_markdown(body), Vec::new())); | |
| 135 | + | let rendered = if allow_images { | |
| 136 | + | render_markdown_plus(body) | |
| 137 | + | } else { | |
| 138 | + | render_markdown(body) | |
| 139 | + | }; | |
| 140 | + | return Ok((rendered, Vec::new())); | |
| 133 | 141 | } | |
| 134 | 142 | ||
| 135 | 143 | let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames) | |
| @@ -140,7 +148,7 @@ pub(super) async fn resolve_and_render_mentions( | |||
| 140 | 148 | })?; | |
| 141 | 149 | ||
| 142 | 150 | let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect(); | |
| 143 | - | let body_html = render_markdown_with_mentions(body, community_slug, &valid_set); | |
| 151 | + | let body_html = render_markdown_with_mentions(body, community_slug, &valid_set, allow_images); | |
| 144 | 152 | ||
| 145 | 153 | // Collect user IDs, excluding self | |
| 146 | 154 | let mention_ids: Vec<Uuid> = resolved | |
| @@ -208,6 +216,7 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 208 | 216 | let community = get_community(&state.db, &slug).await?; | |
| 209 | 217 | ||
| 210 | 218 | check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?; | |
| 219 | + | check_write_state(&state, &community, &user, WriteScope::NewThread).await?; | |
| 211 | 220 | mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) | |
| 212 | 221 | .await | |
| 213 | 222 | .map_err(|e| { | |
| @@ -218,6 +227,10 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 218 | 227 | ||
| 219 | 228 | let title = validate_title(&form.title)?; | |
| 220 | 229 | let body = validate_body(&form.body, 65536, "Body")?; | |
| 230 | + | let author_plus = user.perks.effective_plus(); | |
| 231 | + | if !author_plus { | |
| 232 | + | reject_embeds_for_free_user(body)?; | |
| 233 | + | } | |
| 221 | 234 | ||
| 222 | 235 | let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug) | |
| 223 | 236 | .await | |
| @@ -230,7 +243,7 @@ pub(in crate::routes) async fn create_thread_handler( | |||
| 230 | 243 | verify_quotes(&state.db, body).await?; | |
| 231 | 244 | ||
| 232 | 245 | let (body_html, mention_ids) = resolve_and_render_mentions( | |
| 233 | - | &state.db, body, community.id, &slug, user.user_id, | |
| 246 | + | &state.db, body, community.id, &slug, user.user_id, author_plus, | |
| 234 | 247 | ).await?; | |
| 235 | 248 | ||
| 236 | 249 | let thread_id = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title) | |
| @@ -295,6 +308,7 @@ pub(in crate::routes) async fn create_reply_handler( | |||
| 295 | 308 | let community = get_community(&state.db, &slug).await?; | |
| 296 | 309 | ||
| 297 | 310 | check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?; | |
| 311 | + | check_write_state(&state, &community, &user, WriteScope::ContinueExisting).await?; | |
| 298 | 312 | mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id) | |
| 299 | 313 | .await | |
| 300 | 314 | .map_err(|e| { | |
| @@ -308,11 +322,15 @@ pub(in crate::routes) async fn create_reply_handler( | |||
| 308 | 322 | } | |
| 309 | 323 | ||
| 310 | 324 | let body = validate_body(&form.body, 65536, "Body")?; | |
| 325 | + | let author_plus = user.perks.effective_plus(); | |
| 326 | + | if !author_plus { | |
| 327 | + | reject_embeds_for_free_user(body)?; | |
| 328 | + | } | |
| 311 | 329 | ||
| 312 | 330 | verify_quotes(&state.db, body).await?; | |
| 313 | 331 | ||
| 314 | 332 | let (body_html, mention_ids) = resolve_and_render_mentions( | |
| 315 | - | &state.db, body, community.id, &slug, user.user_id, | |
| 333 | + | &state.db, body, community.id, &slug, user.user_id, author_plus, | |
| 316 | 334 | ).await?; | |
| 317 | 335 | ||
| 318 | 336 | let thread_id = parse_uuid(&thread_id_str)?; |
| @@ -170,6 +170,14 @@ pub(in crate::routes) async fn thread( | |||
| 170 | 170 | let can_flag = !is_removed | |
| 171 | 171 | && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id); | |
| 172 | 172 | ||
| 173 | + | // Signatures are gated on current Fan+ status: a lapsed Fan+ user's | |
| 174 | + | // saved signature is hidden until they renew. Same for the + badge. | |
| 175 | + | let author_signature_html = if p.author_is_fan_plus { | |
| 176 | + | p.author_signature_html | |
| 177 | + | } else { | |
| 178 | + | None | |
| 179 | + | }; | |
| 180 | + | ||
| 173 | 181 | PostRow { | |
| 174 | 182 | id: post_id_str, | |
| 175 | 183 | author_name: p.author_name, | |
| @@ -186,6 +194,8 @@ pub(in crate::routes) async fn thread( | |||
| 186 | 194 | endorsement_count, | |
| 187 | 195 | is_endorsed, | |
| 188 | 196 | can_endorse, | |
| 197 | + | author_has_plus_badge: p.author_is_fan_plus, | |
| 198 | + | author_signature_html, | |
| 189 | 199 | } | |
| 190 | 200 | }) | |
| 191 | 201 | .collect(); |
| @@ -26,19 +26,60 @@ pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60; | |||
| 26 | 26 | // ============================================================================ | |
| 27 | 27 | ||
| 28 | 28 | /// Render markdown to HTML, stripping raw HTML events to prevent XSS. | |
| 29 | + | /// | |
| 30 | + | /// Strict preset: no images, no raw HTML, dangerous-scheme filtering. Use this | |
| 31 | + | /// for content from non-Fan+ users. Fan+ subscribers get image embeds via | |
| 32 | + | /// [`render_markdown_plus`]. | |
| 29 | 33 | pub(crate) fn render_markdown(input: &str) -> String { | |
| 30 | 34 | docengine::render_strict(input) | |
| 31 | 35 | } | |
| 32 | 36 | ||
| 37 | + | /// Render markdown to HTML with image embeds permitted. Otherwise identical to | |
| 38 | + | /// the strict preset — raw HTML is still stripped, dangerous schemes filtered, | |
| 39 | + | /// links get `nofollow`. Use for Fan+ subscriber content. | |
| 40 | + | pub(crate) fn render_markdown_plus(input: &str) -> String { | |
| 41 | + | docengine::Renderer::strict() | |
| 42 | + | .with_strip_images(false) | |
| 43 | + | .render(input) | |
| 44 | + | } | |
| 45 | + | ||
| 33 | 46 | /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members. | |
| 34 | 47 | pub(crate) fn render_markdown_with_mentions( | |
| 35 | 48 | input: &str, | |
| 36 | 49 | community_slug: &str, | |
| 37 | 50 | valid_usernames: &std::collections::HashSet<String>, | |
| 51 | + | allow_images: bool, | |
| 38 | 52 | ) -> String { | |
| 39 | 53 | let template = format!("/p/{community_slug}/u/{{username}}"); | |
| 40 | 54 | let resolved = docengine::resolve_mentions(input, valid_usernames, &template); | |
| 41 | - | docengine::render_strict(&resolved) | |
| 55 | + | if allow_images { | |
| 56 | + | render_markdown_plus(&resolved) | |
| 57 | + | } else { | |
| 58 | + | docengine::render_strict(&resolved) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Reject submissions that contain image embeds. Used to give non-Fan+ users | |
| 63 | + | /// a clear error rather than silently stripping their image at render time. | |
| 64 | + | /// | |
| 65 | + | /// Only matches the markdown image syntax ``. Raw HTML `<img>` / | |
| 66 | + | /// `<video>` / `<iframe>` are stripped by all renderers (`strip_raw_html` is | |
| 67 | + | /// true in both `render_strict` and `render_markdown_plus`), so we don't need | |
| 68 | + | /// to reject them here — and rejecting them would surprise users pasting code | |
| 69 | + | /// blocks containing HTML. | |
| 70 | + | #[allow(clippy::result_large_err)] | |
| 71 | + | pub(crate) fn reject_embeds_for_free_user(body: &str) -> Result<(), Response> { | |
| 72 | + | static EMBED_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| { | |
| 73 | + | regex_lite::Regex::new(r"!\[[^\]]*\]\([^\)]+\)").unwrap() | |
| 74 | + | }); | |
| 75 | + | if EMBED_RE.is_match(body) { | |
| 76 | + | return Err(( | |
| 77 | + | StatusCode::UNPROCESSABLE_ENTITY, | |
| 78 | + | "Image embeds are a Fan+ feature.", | |
| 79 | + | ) | |
| 80 | + | .into_response()); | |
| 81 | + | } | |
| 82 | + | Ok(()) | |
| 42 | 83 | } | |
| 43 | 84 | ||
| 44 | 85 | // ============================================================================ |
| @@ -114,12 +114,17 @@ async fn create_community( | |||
| 114 | 114 | .await | |
| 115 | 115 | .map_err(db_error)?; | |
| 116 | 116 | ||
| 117 | - | // Create default categories | |
| 117 | + | // Create default categories. Issues + Patches are seeded empty so they | |
| 118 | + | // surface in the directory before the first email lands — the internal | |
| 119 | + | // API also auto-creates any missing category on demand (see thread | |
| 120 | + | // handler below), so this is for discoverability, not correctness. | |
| 118 | 121 | let categories = [ | |
| 119 | 122 | ("Items", "items", 0), | |
| 120 | 123 | ("Blog", "blog", 1), | |
| 121 | 124 | ("Devlog", "devlog", 2), | |
| 122 | 125 | ("Discussion", "discussion", 3), | |
| 126 | + | ("Issues", "issues", 4), | |
| 127 | + | ("Patches", "patches", 5), | |
| 123 | 128 | ]; | |
| 124 | 129 | for (name, slug, order) in categories { | |
| 125 | 130 | mt_db::mutations::create_category(&state.db, community_id, name, slug, None, order) |
| @@ -1,5 +1,6 @@ | |||
| 1 | 1 | //! Route handlers — MNW-integrated forum. | |
| 2 | 2 | ||
| 3 | + | mod account; | |
| 3 | 4 | mod admin; | |
| 4 | 5 | mod flagging; | |
| 5 | 6 | mod forum; | |
| @@ -60,6 +61,8 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 60 | 61 | .route("/p/{slug}/settings/categories/{cat_id}/move", post(settings::move_category_handler)) | |
| 61 | 62 | .route("/p/{slug}/settings/tags/new", post(settings::create_tag_handler)) | |
| 62 | 63 | .route("/p/{slug}/settings/tags/delete", post(settings::delete_tag_handler)) | |
| 64 | + | .route("/p/{slug}/settings/state", post(settings::set_community_state_handler)) | |
| 65 | + | .route("/account/signature", post(account::update_signature_handler)) | |
| 63 | 66 | .route("/p/{slug}/moderation/ban", post(moderation::ban_user_handler)) | |
| 64 | 67 | .route("/p/{slug}/moderation/unban", post(moderation::unban_user_handler)) | |
| 65 | 68 | .route("/p/{slug}/moderation/mute", post(moderation::mute_user_handler)) | |
| @@ -81,6 +84,7 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 81 | 84 | .route("/tracked/stop-all", post(tracking::untrack_all_handler)) | |
| 82 | 85 | .route("/_admin/communities/{id}/suspend", post(admin::suspend_community_handler)) | |
| 83 | 86 | .route("/_admin/communities/{id}/unsuspend", post(admin::unsuspend_community_handler)) | |
| 87 | + | .route("/_admin/communities/{slug}/clean-slate", post(admin::admin_community_clean_slate_handler)) | |
| 84 | 88 | .route("/_admin/users/{id}/suspend", post(admin::suspend_user_handler)) | |
| 85 | 89 | .route("/_admin/users/{id}/unsuspend", post(admin::unsuspend_user_handler)) | |
| 86 | 90 | .route("/p/{slug}/upload", post(uploads::upload_image_handler)) | |
| @@ -111,6 +115,7 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 111 | 115 | .route("/p/{slug}", get(forum::project_forum)) | |
| 112 | 116 | .route("/p/{slug}/members", get(forum::community_members)) | |
| 113 | 117 | .route("/p/{slug}/u/{username}", get(forum::user_profile)) | |
| 118 | + | .route("/account", get(account::account_settings)) | |
| 114 | 119 | .route("/p/{slug}/settings", get(settings::community_settings)) | |
| 115 | 120 | .route("/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form)) | |
| 116 | 121 | .route("/p/{slug}/moderation", get(moderation::moderation_page)) | |
| @@ -122,9 +127,11 @@ pub fn forum_routes(state: AppState) -> Router { | |||
| 122 | 127 | .route("/tracked", get(tracking::tracked_threads_page)) | |
| 123 | 128 | .route("/about/tracking", get(tracking::tracking_info_page)) | |
| 124 | 129 | .route("/_admin", get(admin::admin_dashboard)) | |
| 130 | + | .route("/_admin/communities/{slug}", get(admin::admin_community_detail)) | |
| 125 | 131 | .route("/auth/login", get(auth::login)) | |
| 126 | 132 | .route("/auth/callback", get(auth::callback)) | |
| 127 | 133 | .route("/auth/logout", post(auth::logout)) | |
| 134 | + | .route("/auth/refresh", post(auth::refresh)) | |
| 128 | 135 | .route("/api/user/{user_id}/summary", get(forum::user_summary_api)) | |
| 129 | 136 | .route("/uploads/{id}", get(uploads::serve_image_handler)) | |
| 130 | 137 | .route("/api/health", get(health)); | |
| @@ -201,6 +208,26 @@ pub(super) struct UpdateCommunityForm { | |||
| 201 | 208 | pub(super) auto_hide_threshold: Option<String>, | |
| 202 | 209 | } | |
| 203 | 210 | ||
| 211 | + | /// `POST /_admin/communities/{slug}/clean-slate` confirmation form. | |
| 212 | + | /// `confirm` must exactly match the community slug (typed-phrase pattern). | |
| 213 | + | #[derive(Deserialize)] | |
| 214 | + | pub(super) struct CleanSlateForm { | |
| 215 | + | pub(super) confirm: String, | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | #[derive(Deserialize)] | |
| 219 | + | pub(super) struct SignatureForm { | |
| 220 | + | pub(super) signature: String, | |
| 221 | + | /// `Some("1")` when the Clear button is pressed. | |
| 222 | + | pub(super) clear: Option<String>, | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | #[derive(Deserialize)] | |
| 226 | + | pub(super) struct SetCommunityStateForm { | |
| 227 | + | /// Target state: `"active" | "restricted" | "frozen" | "archived"`. | |
| 228 | + | pub(super) state: String, | |
| 229 | + | } | |
| 230 | + | ||
| 204 | 231 | #[derive(Deserialize)] | |
| 205 | 232 | pub(super) struct CreateCategoryForm { | |
| 206 | 233 | pub(super) name: String, | |
| @@ -224,6 +251,14 @@ pub(super) struct PageQuery { | |||
| 224 | 251 | pub(super) page: Option<u32>, | |
| 225 | 252 | } | |
| 226 | 253 | ||
| 254 | + | /// Query for `/` forum directory. `filter=archived` shows only archived | |
| 255 | + | /// communities; otherwise default listing (archived hidden). | |
| 256 | + | #[derive(Deserialize)] | |
| 257 | + | pub(super) struct ForumDirectoryQuery { | |
| 258 | + | pub(super) page: Option<u32>, | |
| 259 | + | pub(super) filter: Option<String>, | |
| 260 | + | } | |
| 261 | + | ||
| 227 | 262 | #[derive(Deserialize)] | |
| 228 | 263 | pub(super) struct CategoryQuery { | |
| 229 | 264 | pub(super) page: Option<u32>, |
| @@ -43,6 +43,8 @@ macro_rules! impl_into_response { | |||
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | impl_into_response!( | |
| 46 | + | AccountSettingsTemplate, | |
| 47 | + | AdminCommunityTemplate, | |
| 46 | 48 | ForumDirectoryTemplate, | |
| 47 | 49 | CommunityTemplate, | |
| 48 | 50 | CategoryTemplate, |
| @@ -83,6 +83,12 @@ pub struct PostRow { | |||
| 83 | 83 | pub endorsement_count: u32, | |
| 84 | 84 | pub is_endorsed: bool, | |
| 85 | 85 | pub can_endorse: bool, | |
| 86 | + | /// Whether to show the `+` badge next to the author's name. True only for | |
| 87 | + | /// Fan+ subscribers — creators don't get the badge per platform spec. | |
| 88 | + | pub author_has_plus_badge: bool, | |
| 89 | + | /// Signature HTML to render below the post body. `None` if the author | |
| 90 | + | /// hasn't set one or has lost Fan+. | |
| 91 | + | pub author_signature_html: Option<String>, | |
| 86 | 92 | } | |
| 87 | 93 | ||
| 88 | 94 | // ============================================================================ | |
| @@ -115,6 +121,43 @@ impl Pagination { | |||
| 115 | 121 | // Page templates | |
| 116 | 122 | // ============================================================================ | |
| 117 | 123 | ||
| 124 | + | /// Admin community detail page — state controls + clean-slate. | |
| 125 | + | #[derive(Template)] | |
| 126 | + | #[template(path = "pages/admin_community.html")] | |
| 127 | + | pub struct AdminCommunityTemplate { | |
| 128 | + | pub csrf_token: CsrfTokenOption, | |
| 129 | + | pub session_user: Option<TemplateSessionUser>, | |
| 130 | + | pub mnw_base_url: std::sync::Arc<str>, | |
| 131 | + | pub community_name: String, | |
| 132 | + | pub community_slug: String, | |
| 133 | + | /// Current state as a snake_case string (`active`/`restricted`/`frozen`/`archived`). | |
| 134 | + | pub current_state: &'static str, | |
| 135 | + | pub thread_count: i64, | |
| 136 | + | pub member_count: i64, | |
| 137 | + | pub is_suspended: bool, | |
| 138 | + | pub suspension_reason: Option<String>, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// Account settings — Fan+ signature editor and perk status. | |
| 142 | + | #[derive(Template)] | |
| 143 | + | #[template(path = "pages/account.html")] | |
| 144 | + | pub struct AccountSettingsTemplate { | |
| 145 | + | pub csrf_token: CsrfTokenOption, | |
| 146 | + | pub session_user: Option<TemplateSessionUser>, | |
| 147 | + | pub mnw_base_url: std::sync::Arc<str>, | |
| 148 | + | /// Whether the viewer has Fan+ perks (incl. creator auto-grant). Drives | |
| 149 | + | /// whether the signature form is editable or shows the upsell. | |
| 150 | + | pub has_plus: bool, | |
| 151 | + | /// Direct Fan+ subscription (distinct from creator auto-grant). | |
| 152 | + | pub fan_plus: bool, | |
| 153 | + | /// Currently saved signature markdown (None if unset). | |
| 154 | + | pub signature_markdown: Option<String>, | |
| 155 | + | /// Rendered preview of the saved signature. | |
| 156 | + | pub signature_html: Option<String>, | |
| 157 | + | /// Server-side validation error to surface above the form. | |
| 158 | + | pub error: Option<String>, | |
| 159 | + | } | |
| 160 | + | ||
| 118 | 161 | /// Forum directory — lists local communities. | |
| 119 | 162 | #[derive(Template)] | |
| 120 | 163 | #[template(path = "pages/forum_directory.html")] | |
| @@ -124,6 +167,8 @@ pub struct ForumDirectoryTemplate { | |||
| 124 | 167 | pub mnw_base_url: std::sync::Arc<str>, | |
| 125 | 168 | pub communities: Vec<CommunityDirectoryRow>, | |
| 126 | 169 | pub pagination: Pagination, | |
| 170 | + | /// True when viewing the archived-only listing (`?filter=archived`). | |
| 171 | + | pub viewing_archived: bool, | |
| 127 | 172 | } | |
| 128 | 173 | ||
| 129 | 174 | /// Project forum — category table within a single project. |
| @@ -0,0 +1,65 @@ | |||
| 1 | + | {% extends "base.html" %} | |
| 2 | + | ||
| 3 | + | {% block title %}Account — Multithreaded{% endblock %} | |
| 4 | + | ||
| 5 | + | {% block head %}<meta name="robots" content="noindex">{% endblock %} | |
| 6 | + | ||
| 7 | + | {% block header %}{% include "partials/site_header.html" %}{% endblock %} | |
| 8 | + | ||
| 9 | + | {% block content %} | |
| 10 | + | <div class="container"> | |
| 11 | + | <h2 class="section-heading">Account</h2> | |
| 12 | + | ||
| 13 | + | <div class="settings-section"> | |
| 14 | + | <h3>Signature</h3> | |
| 15 | + | <p class="form-help"> | |
| 16 | + | A short markdown note rendered below every post you make. Fan+ subscribers may use | |
| 17 | + | image embeds; otherwise text and links only. | |
| 18 | + | </p> | |
| 19 | + | ||
| 20 | + | {% if let Some(err) = error %} | |
| 21 | + | <div class="form-error">{{ err }}</div> | |
| 22 | + | {% endif %} | |
| 23 | + | ||
| 24 | + | {% if has_plus %} | |
| 25 | + | <form method="post" action="/account/signature" class="form-container"> | |
| 26 | + | <div class="form-group"> | |
| 27 | + | <label for="signature">Signature (markdown, up to 1024 characters)</label> | |
| 28 | + | <textarea id="signature" name="signature" class="textarea-medium" maxlength="1024">{{ signature_markdown.as_deref().unwrap_or("") }}</textarea> | |
| 29 | + | </div> | |
| 30 | + | <div class="form-actions"> | |
| 31 | + | <button type="submit" class="primary">Save signature</button> | |
| 32 | + | {% if signature_markdown.is_some() %} | |
| 33 | + | <button type="submit" name="clear" value="1" class="secondary">Clear signature</button> | |
| 34 | + | {% endif %} | |
| 35 | + | </div> | |
| 36 | + | </form> | |
| 37 | + | ||
| 38 | + | {% if let Some(html) = signature_html %} | |
| 39 | + | <h4 class="subsection-heading">Preview</h4> | |
| 40 | + | <div class="post-signature"> | |
| 41 | + | {{ html|safe }} | |
| 42 | + | </div> | |
| 43 | + | {% endif %} | |
| 44 | + | {% else %} | |
| 45 | + | <div class="form-help"> | |
| 46 | + | Signatures are a Fan+ feature. <a href="{{ mnw_base_url }}/fan-plus">Learn more</a>. | |
| 47 | + | </div> | |
| 48 | + | {% endif %} | |
| 49 | + | </div> | |
| 50 | + | ||
| 51 | + | <div class="settings-section"> | |
| 52 | + | <h3>Status</h3> | |
| 53 | + | <ul class="account-status"> | |
| 54 | + | <li>Fan+ subscription: <strong>{% if fan_plus %}active{% else %}inactive{% endif %}</strong></li> | |
| 55 | + | <li>+ perks available: <strong>{% if has_plus %}yes{% else %}no{% endif %}</strong></li> | |
| 56 | + | </ul> | |
| 57 | + | <form method="post" action="/auth/refresh" class="form-inline"> | |
| 58 | + | <button type="submit" class="secondary">Refresh from MNW</button> | |
| 59 | + | </form> | |
| 60 | + | <span class="form-help"> | |
| 61 | + | Hit refresh after subscribing or upgrading on makenot.work to see changes here. | |
| 62 | + | </span> | |
| 63 | + | </div> | |
| 64 | + | </div> | |
| 65 | + | {% endblock %} |
| @@ -58,7 +58,7 @@ | |||
| 58 | 58 | {% for post in posts %} | |
| 59 | 59 | <div class="post-item{% if post.is_op %} op{% endif %}{% if post.is_removed %} post-removed{% endif %}" id="post-{{ post.id }}" data-post-id="{{ post.id }}"> | |
| 60 | 60 | <div class="post-header"> | |
| 61 | - | <a href="/p/{{ community_slug }}/u/{{ post.author_username }}" class="post-author">{{ post.author_name }}</a> | |
| 61 | + | <a href="/p/{{ community_slug }}/u/{{ post.author_username }}" class="post-author">{{ post.author_name }}</a>{% if post.author_has_plus_badge %}<span class="badge badge-plus" title="Fan+ subscriber">+</span>{% endif %} | |
| 62 | 62 | <span> | |
| 63 | 63 | <span class="post-timestamp">{{ post.timestamp }}</span> | |
| 64 | 64 | {% if !post.is_removed %} | |
| @@ -97,6 +97,11 @@ | |||
| 97 | 97 | <div class="post-body"> | |
| 98 | 98 | {{ post.body_html|safe }} | |
| 99 | 99 | </div> | |
| 100 | + | {% if let Some(sig_html) = post.author_signature_html %} | |
| 101 | + | <div class="post-signature"> | |
| 102 | + | {{ sig_html|safe }} | |
| 103 | + | </div> | |
| 104 | + | {% endif %} | |
| 100 | 105 | {% if !post.link_previews.is_empty() %} | |
| 101 | 106 | <div class="post-link-previews"> | |
| 102 | 107 | {% for lp in post.link_previews %} |
| @@ -14,6 +14,7 @@ | |||
| 14 | 14 | {% if let Some(user) = session_user %} | |
| 15 | 15 | <a href="{{ mnw_base_url }}/feed">Feed</a> | |
| 16 | 16 | <a href="{{ mnw_base_url }}/u/{{ user.username }}">{{ user.username }}</a> | |
| 17 | + | <a href="/account">Account</a> | |
| 17 | 18 | <a href="{{ mnw_base_url }}/dashboard">Dashboard</a> | |
| 18 | 19 | {% if user.is_platform_admin %}<a href="/_admin">Admin</a>{% endif %} | |
| 19 | 20 | <form method="post" action="/auth/logout" style="display:inline"><button type="submit" class="link-button" aria-label="Log out">Log Out</button></form> |
| @@ -19,8 +19,21 @@ pub struct TestHarness { | |||
| 19 | 19 | _test_db: TestDb, | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | + | /// Options for customizing a [`TestHarness`]. | |
| 23 | + | #[derive(Default)] | |
| 24 | + | pub struct HarnessOptions { | |
| 25 | + | pub platform_admin_id: Option<Uuid>, | |
| 26 | + | /// Override MNW base URL — point at a wiremock server for tests that exercise | |
| 27 | + | /// OAuth/userinfo flows. Defaults to a black-hole URL that fails fast. | |
| 28 | + | pub mnw_base_url: Option<String>, | |
| 29 | + | } | |
| 30 | + | ||
| 22 | 31 | impl TestHarness { | |
| 23 | 32 | pub async fn new() -> Self { | |
| 33 | + | Self::with_options(HarnessOptions::default()).await | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | pub async fn with_options(opts: HarnessOptions) -> Self { | |
| 24 | 37 | let test_db = TestDb::new().await; | |
| 25 | 38 | let pool = test_db.pool.clone(); | |
| 26 | 39 | ||
| @@ -38,10 +51,14 @@ impl TestHarness { | |||
| 38 | 51 | )); | |
| 39 | 52 | ||
| 40 | 53 | let config = Config { | |
| 41 | - | mnw_base_url: "http://127.0.0.1:9999".into(), | |
| 54 | + | mnw_base_url: opts | |
| 55 | + | .mnw_base_url | |
| 56 | + | .as_deref() | |
| 57 | + | .unwrap_or("http://127.0.0.1:9999") | |
| 58 | + | .into(), | |
| 42 | 59 | oauth_client_id: "test-client-id".to_string(), | |
| 43 | 60 | oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(), | |
| 44 | - | platform_admin_id: None, | |
| 61 | + | platform_admin_id: opts.platform_admin_id, | |
| 45 | 62 | cookie_secure: false, | |
| 46 | 63 | s3: None, | |
| 47 | 64 | internal_shared_secret: None, | |
| @@ -159,61 +176,11 @@ impl TestHarness { | |||
| 159 | 176 | ||
| 160 | 177 | /// Create a harness with a specific platform admin user ID. | |
| 161 | 178 | pub async fn new_with_admin(admin_id: Uuid) -> Self { | |
| 162 | - | let harness = Self::new().await; | |
| 163 | - | // Rebuild with admin config — we need to reconstruct the app | |
| 164 | - | // because Config is cloned into AppState. | |
| 165 | - | drop(harness); | |
| 166 | - | ||
| 167 | - | let test_db = TestDb::new().await; | |
| 168 | - | let pool = test_db.pool.clone(); | |
| 169 | - | ||
| 170 | - | let session_store = PostgresStore::new(pool.clone()); | |
| 171 | - | session_store | |
| 172 | - | .migrate() | |
| 173 | - | .await | |
| 174 | - | .expect("Failed to migrate session store"); | |
| 175 | - | ||
| 176 | - | let session_layer = SessionManagerLayer::new(session_store) | |
| 177 | - | .with_secure(false) | |
| 178 | - | .with_same_site(SameSite::Lax) | |
| 179 | - | .with_expiry(Expiry::OnInactivity( | |
| 180 | - | tower_sessions::cookie::time::Duration::days(1), | |
| 181 | - | )); | |
| 182 | - | ||
| 183 | - | let config = Config { | |
| 184 | - | mnw_base_url: "http://127.0.0.1:9999".into(), | |
| 185 | - | oauth_client_id: "test-client-id".to_string(), | |
| 186 | - | oauth_redirect_uri: "http://127.0.0.1:3400/auth/callback".to_string(), | |
| 179 | + | Self::with_options(HarnessOptions { | |
| 187 | 180 | platform_admin_id: Some(admin_id), | |
| 188 | - | cookie_secure: false, | |
| 189 | - | s3: None, | |
| 190 | - | internal_shared_secret: None, | |
| 191 | - | }; | |
| 192 | - | ||
| 193 | - | let state = AppState { | |
| 194 | - | db: pool.clone(), | |
| 195 | - | config, | |
| 196 | - | http: reqwest::Client::new(), | |
| 197 | - | preview_http: multithreaded::link_preview::build_preview_client(), | |
| 198 | - | s3: None, | |
| 199 | - | }; | |
| 200 | - | ||
| 201 | - | let test_login = axum::Router::new() | |
| 202 | - | .route("/_test/login", axum::routing::post(test_login_handler)) | |
| 203 | - | .with_state(state.clone()); | |
| 204 | - | ||
| 205 | - | let app = routes::forum_routes(state) | |
| 206 | - | .merge(test_login) | |
| 207 | - | .layer(axum::middleware::from_fn(csrf::csrf_middleware)) | |
| 208 | - | .layer(session_layer); | |
| 209 | - | ||
| 210 | - | let client = TestClient::new(app); | |
| 211 | - | ||
| 212 | - | TestHarness { | |
| 213 | - | client, | |
| 214 | - | db: pool, | |
| 215 | - | _test_db: test_db, | |
| 216 | - | } | |
| 181 | + | ..Default::default() | |
| 182 | + | }) | |
| 183 | + | .await | |
| 217 | 184 | } | |
| 218 | 185 | ||
| 219 | 186 | /// Ban a user in a community via direct SQL. | |
| @@ -260,6 +227,9 @@ impl TestHarness { | |||
| 260 | 227 | } | |
| 261 | 228 | ||
| 262 | 229 | /// Handler for `POST /_test/login` — sets session keys without OAuth. | |
| 230 | + | /// | |
| 231 | + | /// Accepts optional `access_token` and `perks` (JSON object) fields to seed the | |
| 232 | + | /// fields normally populated by the OAuth callback. | |
| 263 | 233 | async fn test_login_handler( | |
| 264 | 234 | session: tower_sessions::Session, | |
| 265 | 235 | axum::Json(payload): axum::Json<serde_json::Value>, | |
| @@ -275,5 +245,12 @@ async fn test_login_handler( | |||
| 275 | 245 | let _ = session.insert("user_id", user_id).await; | |
| 276 | 246 | let _ = session.insert("username", username).await; | |
| 277 | 247 | ||
| 248 | + | if let Some(token) = payload.get("access_token").and_then(|v| v.as_str()) { | |
| 249 | + | let _ = session.insert("mnw_access_token", token).await; | |
| 250 | + | } | |
| 251 | + | if let Some(perks) = payload.get("perks") { | |
| 252 | + | let _ = session.insert("perks", perks).await; | |
| 253 | + | } | |
| 254 | + | ||
| 278 | 255 | axum::http::StatusCode::OK | |
| 279 | 256 | } |
| @@ -1,5 +1,7 @@ | |||
| 1 | - | use crate::harness::TestHarness; | |
| 1 | + | use crate::harness::{HarnessOptions, TestHarness}; | |
| 2 | 2 | use axum::http::StatusCode; | |
| 3 | + | use wiremock::matchers::{header, method, path}; | |
| 4 | + | use wiremock::{Mock, MockServer, ResponseTemplate}; | |
| 3 | 5 | ||
| 4 | 6 | #[tokio::test] | |
| 5 | 7 | async fn unauthenticated_sees_login_link() { | |
| @@ -176,6 +178,172 @@ async fn callback_with_correct_state_fails_at_token_exchange() { | |||
| 176 | 178 | ); | |
| 177 | 179 | } | |
| 178 | 180 | ||
| 181 | + | // ── Perks refresh (`POST /auth/refresh`) ── | |
| 182 | + | // | |
| 183 | + | // Refresh re-hits MNW's `/oauth/userinfo` using the cached access token and | |
| 184 | + | // overwrites the session's `perks`. These tests use wiremock to stand in for | |
| 185 | + | // MNW. | |
| 186 | + | ||
| 187 | + | /// Spin up a TestHarness pointed at a wiremock MNW. Returns both so individual | |
| 188 | + | /// tests can register response expectations on the mock. | |
| 189 | + | async fn harness_with_mock_mnw() -> (TestHarness, MockServer) { | |
| 190 | + | let mock = MockServer::start().await; | |
| 191 | + | let h = TestHarness::with_options(HarnessOptions { | |
| 192 | + | mnw_base_url: Some(mock.uri()), | |
| 193 | + | ..Default::default() | |
| 194 | + | }) | |
| 195 | + | .await; | |
| 196 | + | (h, mock) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// Log in via the test harness and seed access token + perks into the session. | |
| 200 | + | async fn login_with_token(h: &mut TestHarness, username: &str, token: &str, perks: serde_json::Value) -> uuid::Uuid { | |
| 201 | + | let user_id = uuid::Uuid::new_v4(); | |
| 202 | + | sqlx::query( | |
| 203 | + | "INSERT INTO users (mnw_account_id, username, display_name) \ | |
| 204 | + | VALUES ($1, $2, $2) ON CONFLICT (mnw_account_id) DO NOTHING", | |
| 205 | + | ) | |
| 206 | + | .bind(user_id) | |
| 207 | + | .bind(username) | |
| 208 | + | .execute(&h.db) | |
| 209 | + | .await | |
| 210 | + | .expect("insert test user"); | |
| 211 | + | h.client.get("/").await; | |
| 212 | + | let body = serde_json::json!({ | |
| 213 | + | "user_id": user_id.to_string(), | |
| 214 | + | "username": username, | |
| 215 | + | "access_token": token, | |
| 216 | + | "perks": perks, | |
| 217 | + | }); | |
| 218 | + | h.client.post_json("/_test/login", &body.to_string()).await; | |
| 219 | + | user_id | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[tokio::test] | |
| 223 | + | async fn refresh_updates_perks_from_mnw() { | |
| 224 | + | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 225 | + | let user_id = login_with_token( | |
| 226 | + | &mut h, | |
| 227 | + | "refreshuser", | |
| 228 | + | "fake-token", | |
| 229 | + | serde_json::json!({ "fan_plus": false, "is_creator": false }), | |
| 230 | + | ) | |
| 231 | + | .await; | |
| 232 | + | ||
| 233 | + | Mock::given(method("GET")) | |
| 234 | + | .and(path("/oauth/userinfo")) | |
| 235 | + | .and(header("authorization", "Bearer fake-token")) | |
| 236 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 237 | + | "user_id": user_id, | |
| 238 | + | "username": "refreshuser", | |
| 239 | + | "display_name": "Refresh User", | |
| 240 | + | "avatar_url": null, | |
| 241 | + | "perks": { | |
| 242 | + | "fan_plus": true, | |
| 243 | + | "is_creator": false, | |
| 244 | + | "creator_tier": null, | |
| 245 | + | }, | |
| 246 | + | }))) | |
| 247 | + | .expect(1) | |
| 248 | + | .mount(&mock) | |
| 249 | + | .await; | |
| 250 | + | ||
| 251 | + | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 252 | + | assert_eq!(resp.status, StatusCode::OK, "body: {}", resp.text); | |
| 253 | + | let body: serde_json::Value = serde_json::from_str(&resp.text).expect("json body"); | |
| 254 | + | assert_eq!(body["perks"]["fan_plus"], true); | |
| 255 | + | assert_eq!(body["perks"]["is_creator"], false); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[tokio::test] | |
| 259 | + | async fn refresh_returns_creator_tier_features() { | |
| 260 | + | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 261 | + | let user_id = login_with_token( | |
| 262 | + | &mut h, | |
| 263 | + | "creatoruser", | |
| 264 | + | "creator-token", | |
| 265 | + | serde_json::json!({}), | |
| 266 | + | ) | |
| 267 | + | .await; | |
| 268 | + | ||
| 269 | + | Mock::given(method("GET")) | |
| 270 | + | .and(path("/oauth/userinfo")) | |
| 271 | + | .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ | |
| 272 | + | "user_id": user_id, | |
| 273 | + | "username": "creatoruser", | |
| 274 | + | "display_name": null, | |
| 275 | + | "avatar_url": null, | |
| 276 | + | "perks": { | |
| 277 | + | "fan_plus": false, | |
| 278 | + | "is_creator": true, | |
| 279 | + | "creator_tier": { "tier": "big_files", "features": ["file_uploads", "large_files"] }, | |
| 280 | + | }, | |
| 281 | + | }))) | |
| 282 | + | .mount(&mock) | |
| 283 | + | .await; | |
| 284 | + | ||
| 285 | + | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 286 | + | assert_eq!(resp.status, StatusCode::OK); | |
| 287 | + | let body: serde_json::Value = serde_json::from_str(&resp.text).unwrap(); | |
| 288 | + | assert_eq!(body["perks"]["is_creator"], true); | |
| 289 | + | assert_eq!(body["perks"]["creator_tier"]["tier"], "big_files"); | |
| 290 | + | let features = body["perks"]["creator_tier"]["features"].as_array().expect("features array"); | |
| 291 | + | assert!(features.iter().any(|f| f == "file_uploads")); | |
| 292 | + | assert!(features.iter().any(|f| f == "large_files")); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | #[tokio::test] | |
| 296 | + | async fn refresh_unauthorized_flushes_session() { | |
| 297 | + | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 298 | + | login_with_token(&mut h, "expireduser", "stale-token", serde_json::json!({})).await; | |
| 299 | + | ||
| 300 | + | Mock::given(method("GET")) | |
| 301 | + | .and(path("/oauth/userinfo")) | |
| 302 | + | .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({"error": "invalid_token"}))) | |
| 303 | + | .mount(&mock) | |
| 304 | + | .await; | |
| 305 | + | ||
| 306 | + | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 307 | + | assert_eq!(resp.status, StatusCode::UNAUTHORIZED); | |
| 308 | + | ||
| 309 | + | // Session should be flushed — the home page now shows the login link, not the username. | |
| 310 | + | let resp = h.client.get("/").await; | |
| 311 | + | assert!(resp.text.contains("Login"), "expected login link after session flush"); | |
| 312 | + | assert!( | |
| 313 | + | !resp.text.contains("expireduser"), | |
| 314 | + | "username should not appear after flush" | |
| 315 | + | ); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[tokio::test] | |
| 319 | + | async fn refresh_without_session_returns_401() { | |
| 320 | + | let mut h = TestHarness::new().await; | |
| 321 | + | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 322 | + | assert_eq!(resp.status, StatusCode::UNAUTHORIZED); | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[tokio::test] | |
| 326 | + | async fn refresh_on_mnw_5xx_returns_bad_gateway() { | |
| 327 | + | let (mut h, mock) = harness_with_mock_mnw().await; | |
| 328 | + | login_with_token(&mut h, "transientuser", "any-token", serde_json::json!({})).await; | |
| 329 | + | ||
| 330 | + | Mock::given(method("GET")) | |
| 331 | + | .and(path("/oauth/userinfo")) | |
| 332 | + | .respond_with(ResponseTemplate::new(503)) | |
| 333 | + | .mount(&mock) | |
| 334 | + | .await; | |
| 335 | + | ||
| 336 | + | let resp = h.client.post_form("/auth/refresh", "").await; | |
| 337 | + | assert_eq!(resp.status, StatusCode::BAD_GATEWAY); | |
| 338 | + | ||
| 339 | + | // Session should still be valid — 5xx is transient. | |
| 340 | + | let resp = h.client.get("/").await; | |
| 341 | + | assert!( | |
| 342 | + | resp.text.contains("transientuser"), | |
| 343 | + | "session should survive transient MNW error" | |
| 344 | + | ); | |
| 345 | + | } | |
| 346 | + | ||
| 179 | 347 | #[tokio::test] | |
| 180 | 348 | async fn suspended_user_sees_error_page() { | |
| 181 | 349 | let mut h = TestHarness::new().await; |