Skip to main content

max / multithreaded

Code review remediation: split forum routes, add helpers, fix clippy Split forum/mod.rs (1,247 lines) into views.rs, actions.rs, posts.rs, thread.rs. Extract route helpers. Fix all clippy warnings. Grade A. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-12 23:39 UTC
Commit: ab97981429ecf116fc4931cb5ba4e98f3dab3282
Parent: 5907e76
21 files changed, +1977 insertions, -1104 deletions
M Cargo.lock +17 -9
@@ -163,9 +163,9 @@ dependencies = [
163 163
164 164 [[package]]
165 165 name = "aws-lc-rs"
166 - version = "1.16.1"
166 + version = "1.16.2"
167 167 source = "registry+https://github.com/rust-lang/crates.io-index"
168 - checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
168 + checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
169 169 dependencies = [
170 170 "aws-lc-sys",
171 171 "zeroize",
@@ -173,9 +173,9 @@ dependencies = [
173 173
174 174 [[package]]
175 175 name = "aws-lc-sys"
176 - version = "0.38.0"
176 + version = "0.39.1"
177 177 source = "registry+https://github.com/rust-lang/crates.io-index"
178 - checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
178 + checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
179 179 dependencies = [
180 180 "cc",
181 181 "cmake",
@@ -2110,8 +2110,6 @@ name = "multithreaded"
2110 2110 version = "0.3.2"
2111 2111 dependencies = [
2112 2112 "askama",
2113 - "aws-config",
2114 - "aws-sdk-s3",
2115 2113 "axum",
2116 2114 "base64",
2117 2115 "chrono",
@@ -2127,6 +2125,7 @@ dependencies = [
2127 2125 "rand 0.8.5",
2128 2126 "regex-lite",
2129 2127 "reqwest",
2128 + "s3-storage",
2130 2129 "serde",
2131 2130 "serde_json",
2132 2131 "sha2",
@@ -2855,7 +2854,7 @@ dependencies = [
2855 2854 "once_cell",
2856 2855 "ring",
2857 2856 "rustls-pki-types",
2858 - "rustls-webpki 0.103.9",
2857 + "rustls-webpki 0.103.11",
2859 2858 "subtle",
2860 2859 "zeroize",
2861 2860 ]
@@ -2894,9 +2893,9 @@ dependencies = [
2894 2893
2895 2894 [[package]]
2896 2895 name = "rustls-webpki"
2897 - version = "0.103.9"
2896 + version = "0.103.11"
2898 2897 source = "registry+https://github.com/rust-lang/crates.io-index"
2899 - checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
2898 + checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4"
2900 2899 dependencies = [
2901 2900 "aws-lc-rs",
2902 2901 "ring",
@@ -2917,6 +2916,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2917 2916 checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
2918 2917
2919 2918 [[package]]
2919 + name = "s3-storage"
2920 + version = "0.1.0"
2921 + dependencies = [
2922 + "aws-config",
2923 + "aws-sdk-s3",
2924 + "tracing",
2925 + ]
2926 +
2927 + [[package]]
2920 2928 name = "schannel"
2921 2929 version = "0.1.29"
2922 2930 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +2 -4
@@ -35,8 +35,7 @@ base64 = "0.22"
35 35 rand = "0.8"
36 36
37 37 # S3 storage
38 - aws-sdk-s3 = "1.119"
39 - aws-config = { version = "1.8", features = ["behavior-version-latest"] }
38 + s3-storage = { path = "../../Shared/s3-storage" }
40 39
41 40 # Database
42 41 sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
@@ -87,8 +86,7 @@ docengine = { path = "../../Shared/docengine", features = ["mentions", "quotes"]
87 86 tagtree = { workspace = true }
88 87 tower_governor = { workspace = true }
89 88 governor = { workspace = true }
90 - aws-sdk-s3 = { workspace = true }
91 - aws-config = { workspace = true }
89 + s3-storage = { workspace = true }
92 90 dotenvy = "0.15"
93 91 hex = "0.4"
94 92 hmac = { workspace = true }
@@ -163,10 +163,62 @@ multithreaded (src/)
163 163
164 164 ## Deployment
165 165
166 - - **Server**: Astra (Tailscale IP, port 3400)
167 - - **Process**: systemd unit (`deploy/multithreaded.service`)
168 - - **Config**: Environment variables (`deploy/env.production`)
169 - - **Monitoring**: PoM health check on `/api/health`
166 + Deployed to two targets. Both run on port 3400, use systemd, and share the same service unit (`deploy/multithreaded.service`). Public domain: `forums.makenot.work` (Cloudflare-proxied, points to hetzner).
167 +
168 + ### Hetzner (production)
169 +
170 + - **Host**: `alpha-west-1` (Tailscale `100.120.174.96`, public `5.78.144.244`)
171 + - **SSH**: `root@100.120.174.96` (via Tailscale)
172 + - **Install path**: `/opt/multithreaded/`
173 + - **Build**: cross-compiled on macOS via `cargo zigbuild --release --target x86_64-unknown-linux-gnu`
174 + - **Deploy script**: `deploy/deploy-hetzner.sh` (build, upload binary + static + migrations, restart)
175 + - `--setup` -- first-time: create system user, dirs, database, build, install, seed
176 + - `--config` -- upload systemd unit, static assets, migrations only
177 + - **Env file**: `deploy/env.hetzner` -> `/opt/multithreaded/.env`
178 + - **Reverse proxy**: Caddy (TLS termination via Cloudflare Origin CA, `forums.makenot.work`)
179 + - **Bind address**: `127.0.0.1:3400` (Caddy fronts it; no direct external access)
180 + - **OAuth**: `client_id=mt-forums-6378957b452bbbc906c3db8edd072d64`, redirect to `https://forums.makenot.work/auth/callback`, `MNW_BASE_URL=https://makenot.work`
181 +
182 + ### Astra (staging/dev)
183 +
184 + - **Host**: `astra` (Tailscale `100.106.221.39`)
185 + - **SSH**: `max@100.106.221.39` (via Tailscale)
186 + - **Install path**: `/opt/multithreaded/`
187 + - **Build**: native build on astra (aarch64). Source rsynced to `~/src/multithreaded/`, built with `cargo build --release`, binary copied to `/opt/multithreaded/`.
188 + - **Deploy script**: `deploy/deploy.sh` (rsync source, build remote, deploy files, restart)
189 + - `--setup` -- first-time: create system user, dirs, database, build, install, seed
190 + - **Env file**: `deploy/env.production` -> `/opt/multithreaded/.env`
191 + - **No reverse proxy**: direct access on port 3400 via Tailscale IP
192 + - **Bind address**: `0.0.0.0:3400`
193 + - **OAuth**: `MNW_BASE_URL=http://127.0.0.1:3000` (local MNW instance), redirect to `http://100.106.221.39:3400/auth/callback`
194 +
195 + ### Shared Details
196 +
197 + - **systemd unit**: `deploy/multithreaded.service`
198 + - Runs as `multithreaded` system user
199 + - `EnvironmentFile=/opt/multithreaded/.env`
200 + - Security hardening: `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `MemoryMax=512M`
201 + - Depends on `postgresql.service`
202 + - **Migrations**: auto-applied on boot (`sqlx::migrate!()`)
203 + - **Seeding**: `./multithreaded --seed` (idempotent, run once after first deploy)
204 + - **reqwest TLS**: uses `rustls-tls` feature (not `native-tls`). Required for cross-compilation to x86_64 Linux from macOS -- native-tls depends on OpenSSL which complicates cross builds.
205 + - **Prerequisites for hetzner cross-compile**: `brew install zig`, `cargo install cargo-zigbuild`, `rustup target add x86_64-unknown-linux-gnu`
206 +
207 + ### Monitoring
208 +
209 + - PoM monitors `forums.makenot.work` -- health check on `/api/health`, TLS validation, route probes, DNS verification
210 + - PoM targets: MNW, MT (`forums.makenot.work`), htpy.app
211 +
212 + ### Deploy Files
213 +
214 + ```
215 + deploy/
216 + ├── deploy.sh # Astra deploy (rsync + native build)
217 + ├── deploy-hetzner.sh # Hetzner deploy (cross-compile + upload)
218 + ├── multithreaded.service # systemd unit (shared)
219 + ├── env.production # Env vars for astra
220 + └── env.hetzner # Env vars for hetzner
221 + ```
170 222
171 223 ## Testing
172 224
@@ -0,0 +1,392 @@
1 + # Multithreaded — Completed Work
2 +
3 + Archived completed phases from todo.md. All items here are done.
4 +
5 + ---
6 +
7 + ## Phase 0 — Skeleton
8 +
9 + - [x] Workspace setup (multithreaded binary + mt-core + mt-db crates)
10 + - [x] Domain models (User, Community, Category, Thread, Post, Membership, Role)
11 + - [x] Error types (CoreError)
12 + - [x] Database pool (PgPool creation)
13 + - [x] Axum server stub (main.rs, binds to port)
14 + - [x] Template layer — base.html, site_header, 6 page templates
15 + - [x] CSS — curated MNW design language (warm beige, three-tier typography, dense tables)
16 + - [x] Static file serving (fonts, htmx.min.js, style.css via ServeDir)
17 + - [x] Route handlers with hardcoded dummy data (all 6 pages render)
18 + - [x] View-model structs (ProjectRow, CategoryRow, ThreadRow, PostRow, TemplateSessionUser)
19 + - [x] impl_into_response! macro (MNW pattern)
20 +
21 + ## Phase 1 — Database
22 +
23 + - [x] Migration 001: users table (mnw_account_id UUID PK, username, display_name, avatar_url, created_at, updated_at)
24 + - [x] Migration 002: communities table (id, name, slug UNIQUE, description, created_at)
25 + - [x] Migration 003: categories table (id, community_id FK, name, slug, description, sort_order, created_at; UNIQUE(community_id, slug))
26 + - [x] Migration 004: threads table (id, category_id FK, author_id FK, title, pinned, locked, created_at, last_activity_at)
27 + - [x] Migration 005: posts table (id, thread_id FK, author_id FK, body_markdown, body_html, parent_post_id FK nullable, created_at, edited_at)
28 + - [x] Migration 006: memberships table (id, user_id FK, community_id FK, role CHECK, joined_at; UNIQUE(user_id, community_id))
29 + - [x] Auto-run migrations on boot (sqlx::migrate! in main.rs)
30 + - [x] Add DATABASE_URL / .env support (dotenvy)
31 + - [x] Seed script (--seed flag: 2 users, 3 communities, 9 categories, 3 threads, 4 posts, idempotent guard)
32 + - [x] PgPool passed to router as Axum state
33 + - [x] Indexes on all FK columns and common query patterns
34 +
35 + ## Phase 2 — Auth (MNW OAuth Integration)
36 +
37 + - [x] OAuth2 PKCE client flow: redirect to MNW /oauth/authorize, receive auth code, exchange for token via /oauth/token
38 + - [x] MNW-side: /oauth/userinfo endpoint (Bearer JWT, returns user_id, username, display_name, avatar_url)
39 + - [x] MNW-side: relaxed redirect_uri validation (localhost always allowed + registered URIs in sync_apps.redirect_uris)
40 + - [x] MNW-side: migration 026 adding redirect_uris column to sync_apps
41 + - [x] MNW-side: /api/public/projects endpoint (no auth, returns project list for forum directory)
42 + - [x] Session middleware (tower-sessions + PostgresStore, 7-day expiry, SameSite::Lax)
43 + - [x] Login route: GET /auth/login generates PKCE pair + state nonce, redirects to MNW OAuth
44 + - [x] Callback route: GET /auth/callback verifies state, exchanges code, fetches userinfo, upserts local user, sets session
45 + - [x] Logout route: GET /auth/logout flushes session, redirects home
46 + - [x] MaybeUser extractor (Option<SessionUser> from session, injected into handlers)
47 + - [x] AppState struct (PgPool + Config + reqwest::Client), replaces bare PgPool
48 + - [x] Config (MNW_BASE_URL, OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI from env)
49 + - [x] Updated site_header.html: shows real username + logout link when logged in, single "Login" link when logged out
50 + - [x] Route refactor: /c/ → /p/ (project-scoped forums), removed /communities
51 + - [x] Forum directory at / (fetches projects from MNW API, dense table)
52 + - [x] Forum-dense CSS overhaul (tighter padding, smaller fonts, directory-table, removed hero/card styles)
53 + - [x] Deleted landing.html, community_list.html (replaced by forum_directory.html)
54 + - [x] deploy/env.production updated with MNW_BASE_URL, OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI
55 + - [x] Register Multithreaded as sync_app on astra (SQL insert, api_key set in env)
56 + - [x] MNW-side: INSECURE_COOKIES env override for staging HTTP deployments
57 + - [x] MNW-side: SameSite::Lax for session cookies (OAuth redirect compatibility)
58 + - [x] MNW-side: SYNCKIT_JWT_SECRET required for OAuth token exchange
59 + - [x] Session cookie renamed to `mt_session` (avoid collision with MNW `id` cookie on same host)
60 + - [x] Tracing instrumentation on OAuth callback (all error paths log details)
61 + - [x] Deployed to astra: MNW (0.0.0.0:3000) + Multithreaded (0.0.0.0:3400), OAuth flow verified end-to-end
62 + - [x] CSRF middleware (generate token per session, validate on POST/PUT/DELETE)
63 +
64 + ## Phase 3 — Read Path (DB-Backed Pages)
65 +
66 + - [x] DB queries: list communities with member counts (mt-db)
67 + - [x] DB queries: get community by slug, list categories with thread counts
68 + - [x] DB queries: list threads in category (pinned first, then by last_activity_at DESC) with author username + reply count
69 + - [x] DB queries: get thread by id, list posts with author usernames
70 + - [x] Replace dummy data in route handlers with real DB queries
71 + - [x] Relative timestamps (e.g. "2h ago", "3d ago") — helper in mt-core
72 + - [x] 404 pages when project/category/thread not found (StatusCode::NOT_FOUND)
73 +
74 + ## Phase 4 — Write Path (Create + Reply)
75 +
76 + - [x] POST /p/{slug}/{category}/new — create thread (title + body, render markdown to HTML)
77 + - [x] POST /p/{slug}/{category}/{thread_id}/reply — create post
78 + - [x] Markdown rendering (pulldown-cmark, HTML events stripped for XSS prevention)
79 + - [x] Input validation (title length, body not empty, slug format)
80 + - [x] Redirect after successful create (POST-redirect-GET)
81 + - [x] Update thread.last_activity_at on new post
82 + - [x] Flash messages / toast on success ("Thread created", "Reply posted")
83 + - [x] Require login for write operations (redirect to /auth/login if not authenticated)
84 +
85 + ## Phase 5 — Edit + Delete
86 +
87 + - [x] Edit post (only by author within 15min window, or always for mods/owners)
88 + - [x] Delete post (soft delete — set body to "[deleted]", preserve thread structure)
89 + - [x] Edit thread title (author, moderator, or owner)
90 + - [x] Delete thread (soft delete — mark as deleted, hide from listings)
91 + - [x] Confirmation prompts for destructive actions (JS confirm)
92 +
93 + ## Phase 6 — Moderation
94 +
95 + - [x] Pin/unpin thread toggle (Owner/Moderator only, POST-redirect-GET)
96 + - [x] Lock/unlock thread toggle (Owner/Moderator only, prevents new replies)
97 + - [x] Role checks: is_mod_or_owner(), is_owner() helpers; get_user_role query
98 + - [x] Mod actions UI — pin/lock buttons on thread page header
99 + - [x] [pinned] and [locked] badges on thread page
100 + - [x] Community settings page (/p/{slug}/settings — name + description, Owner only)
101 + - [x] Category management — create, rename, reorder (up/down swap), Owner only
102 + - [x] Edit category page (/p/{slug}/settings/categories/{id}/edit)
103 + - [x] Settings link on community page (Owner only)
104 + - [x] require_owner() helper for settings handlers
105 +
106 + ## Phase 7 — Pagination
107 +
108 + - [x] Paginate thread list in category (25 per page, LIMIT/OFFSET queries)
109 + - [x] Paginate posts in long threads (50 per page)
110 + - [x] Pagination partial template (prev/next navigation)
111 + - [x] Pagination struct (current_page, total_pages, has_prev, has_next)
112 + - [x] Pagination CSS (centered flex, mono font, matching aesthetic)
113 + - [x] PageQuery deserialize struct for ?page= query param
114 +
115 + ## Phase 8 — Testing
116 +
117 + - [x] Integration test harness (per-test database create/drop, like MNW)
118 + - [x] TestClient (cookie-aware, CSRF auto-injection, in-process via tower::oneshot)
119 + - [x] TestHarness (login_as, create_community, create_category, add_membership, create_thread_with_post)
120 + - [x] /_test/login route for session setup without OAuth
121 + - [x] CSRF tests: missing token 403, valid token succeeds, wrong token 403, token stable across requests (4 tests)
122 + - [x] Auth tests: login link visible, login redirects to MNW, logout clears session (3 tests)
123 + - [x] CRUD tests: create thread, require login, reply, locked reply rejected, edit post, delete post, edit title, delete thread (8 tests)
124 + - [x] Permission tests: non-mod can't pin, mod can pin, settings access, category creation forbidden, edit window (6 tests)
125 + - [x] Moderation tests: pin toggle, lock prevents replies, pinned first in listing, update settings, create category (5 tests)
126 + - [x] CSRF unit tests: token length/hex, uniqueness, constant_time_compare (3 tests)
127 +
128 + ## Phase 9 — Deploy (Done items)
129 +
130 + - [x] Deploy script (cross-compile + upload + restart, like MNW pattern)
131 + - [x] systemd unit file (deploy/multithreaded.service)
132 + - [x] .env template for production secrets (deploy/env.production)
133 + - [x] Health check endpoint (GET /api/health)
134 + - [x] Error pages (404, 500 — styled, extend base.html)
135 + - [x] 404 fallback handler (.fallback on router)
136 + - [x] CSRF middleware layer (between routes and session)
137 + - [x] lib.rs module extraction (testable binary)
138 + - [x] Form submit interceptor (JS fetch with X-CSRF-Token for native POSTs)
139 +
140 + ## Phase 10 — Polish + UX (Done items)
141 +
142 + - [x] Sort controls on thread table (clickable headers: replies, last activity; toggle asc/desc; preserved in pagination)
143 + - [x] User profile links (author names link to makenot.work/u/{username} on forum directory, category, and thread pages)
144 + - [x] Page titles and meta descriptions (descriptions on public pages, noindex on forms/settings/errors)
145 + - [x] Markdown rendering tests (15 unit tests: XSS prevention, HTML stripping, standard markdown elements)
146 + - [x] Timestamp formatting tests (10 boundary tests added to existing 6: all transition points covered)
147 + - [x] Pagination edge case tests (9 integration tests: empty, page beyond max, page=0, sort controls, meta tags, noindex)
148 + - [x] Pagination page clamping (page > max clamped to last page before SQL query, both category and thread handlers)
149 + - [x] Community member list page (/p/{slug}/members — sorted by role, links to MNW profiles, 3 integration tests)
150 +
151 + ## Phase 11 — Moderation System
152 +
153 + - [x] Migration 008: community_bans table (ban/mute with expiry, unique per community+user+type)
154 + - [x] Migration 009: mod_log table (actor, action, target, reason, timestamp)
155 + - [x] Migration 010: suspension columns on users and communities (suspended_at, suspension_reason)
156 + - [x] Config: platform_admin_id from PLATFORM_ADMIN_ID env var
157 + - [x] PlatformAdmin extractor (returns 404 for non-admins, hides admin routes)
158 + - [x] Suspension check in OAuth callback (suspended users cannot log in)
159 + - [x] DB queries: is_user_banned, is_user_muted, list_community_bans, list_mod_log, count_mod_log, get_user_by_username, list_all_communities, search_users
160 + - [x] DB mutations: create_community_ban (upsert), remove_community_ban, insert_mod_log, suspend/unsuspend_community, suspend/unsuspend_user
161 + - [x] Enforcement helpers: check_community_access (suspension+ban for reads), check_write_access (suspension+ban+mute for writes)
162 + - [x] Enforcement added to all read handlers (project_forum, category, thread, community_members)
163 + - [x] Enforcement added to all write handlers (create_thread, create_reply, edit_post, edit_thread, new_thread form)
164 + - [x] Delete handlers: suspension+ban check (allow own deletes even if muted)
165 + - [x] Mod log retrofit on existing handlers (pin, lock, delete_thread, delete_post, edit_settings, create_category, edit_category)
166 + - [x] Community moderation page (/p/{slug}/moderation — ban/mute forms, active bans table, mod/owner only)
167 + - [x] Ban/unban/mute/unmute handlers with role hierarchy (can't ban owners, mods can't ban mods)
168 + - [x] Mod log page (/p/{slug}/moderation/log — paginated action history)
169 + - [x] Moderation link on community page (visible to mod/owner)
170 + - [x] Platform admin dashboard (/_admin — community list, user search, suspend/unsuspend actions)
171 + - [x] Admin suspend/unsuspend handlers for communities and users
172 + - [x] Footer with moderation@makenot.work contact on all pages
173 + - [x] CSS: badge-ban, badge-mute-type, site-footer styles
174 + - [x] Ban tests: 12 integration tests (banned can't read/write, muted can read but not write, role hierarchy, unban/unmute restores access)
175 + - [x] Admin tests: 6 integration tests (non-admin 404, dashboard visible, suspend/unsuspend community+user)
176 + - [x] CommunityRow updated with suspended_at field
177 + - [x] CommunityTemplate updated with is_mod_or_owner field
178 + - [x] deploy/env.production updated with PLATFORM_ADMIN_ID
179 +
180 + ## Phase 14 — Immutable Posts + Footnotes
181 +
182 + - [x] Migration 011: `post_footnotes` table + `removed_by`/`removed_at` on posts
183 + - [x] Removed `update_post_body()`, `soft_delete_post()` from mutations.rs
184 + - [x] Added `insert_footnote()`, `mod_remove_post()` to mutations.rs
185 + - [x] Added `FootnoteWithAuthor`, `list_footnotes_for_posts()`, `get_post_body_markdown()` to queries.rs
186 + - [x] `PostWithAuthor` updated with `removed_at`
187 + - [x] Removed post edit/delete routes and handlers (`edit_post_form`, `edit_post_handler`, `delete_post_handler`)
188 + - [x] Removed `EditPostForm`, `EDIT_WINDOW_MINUTES`, `can_edit_post()`, `can_delete()`
189 + - [x] Restricted thread edit/delete to mod/owner only (was author OR mod)
190 + - [x] Added `add_footnote_handler()` — author-only, validates body, renders markdown, inserts footnote
191 + - [x] Added `mod_remove_post_handler()` in moderation.rs — mod/owner only, sets removed_by/removed_at, mod log entry
192 + - [x] Thread handler: batch-fetches footnotes, builds FootnoteViewRow per post, removed_at display
193 + - [x] Template changes: `PostRow` (removed is_edited/can_edit/can_delete, added is_removed/can_add_footnote/can_remove/footnotes), `FootnoteViewRow`, `ThreadTemplate.can_mod_thread`
194 + - [x] Deleted `EditPostTemplate`, `edit_post.html`
195 + - [x] Rewritten `thread.html`: post-item with data-post-id, mod remove button, footnotes section, footnote form (details/summary)
196 + - [x] CSS: .post-footnotes, .footnote, .footnote-prefix, .footnote-form-toggle, .post-removed
197 +
198 + ## Phase 21 — Verified Quoting
199 +
200 + - [x] `[quote:POST_ID:HASH]` format — HASH = first 8 hex chars of SHA-256 of quoted text
201 + - [x] `verify_quotes()` in forum.rs: regex extraction, substring check, hash verification, 422 on mismatch
202 + - [x] Verification wired into `create_reply_handler` and `create_thread_handler`
203 + - [x] `post_process_quotes()` in markdown.rs: replaces markers with `<cite class="quote-attribution">` linking to `#post-POST_ID`
204 + - [x] Thread handler: builds quote_authors map from post IDs, passes to post_process_quotes
205 + - [x] Inline JS: mouseup text selection → floating "Quote" button → SHA-256 via crypto.subtle.digest → blockquote + marker → insert into reply textarea → scroll to form
206 + - [x] CSS: .quote-attribution, .quote-btn
207 + - [x] Added regex-lite dependency
208 + - [x] 15 new integration tests: user_cannot_edit/delete_post, mod_can_remove_post, mod_can_edit/delete_thread, user_cannot_edit/delete_thread, add_footnote_by_author, add_footnote_non_author_rejected, multiple_footnotes_ordered, footnote_on_removed_post_rejected, valid_quote_accepted, fabricated_quote_rejected, altered_quote_rejected, quote_renders_with_attribution
209 +
210 + ## Phase 15 — Post Endorsements
211 +
212 + - [x] Migration 012: `post_endorsements` table — composite PK, CASCADE on post delete, index on endorser_id
213 + - [x] `list_endorsements_for_posts` batch query, `toggle_endorsement` mutation (INSERT ON CONFLICT + DELETE toggle)
214 + - [x] "Endorse" button on posts (not own, not removed, logged-in). Toggle, `.endorsed` CSS class.
215 + - [x] No public count. Count visible to: author, endorsers, mods only.
216 + - [x] Muted users CAN endorse. Banned/suspended cannot.
217 + - [x] 8 integration tests
218 +
219 + ## Phase 13 — Draft Auto-Save
220 +
221 + - [x] JS IIFE: debounced 1s save to localStorage keyed by page URL, for `#body` and `#reply-body` textareas
222 + - [x] Draft restore on page load with "Draft restored" indicator + "Discard" link
223 + - [x] Clear draft on form submission
224 + - [x] 7-day draft expiry
225 + - [x] Respects `mt_tracking_enabled` localStorage toggle
226 +
227 + ## Phase 17 — Post Flagging
228 +
229 + - [x] Migration 013: `post_flags` table — reason CHECK (spam/rule_breaking/off_topic), UNIQUE(post_id, flagger_id), indexes
230 + - [x] "Flag" `<details>` toggle on posts with radio buttons + optional detail textarea
231 + - [x] `POST /p/{slug}/{cat}/{thread_id}/posts/{post_id}/flag` — duplicate silently ignored
232 + - [x] "Pending Flags" section on moderation page with dismiss/remove actions
233 + - [x] `POST /p/{slug}/moderation/flags/{flag_id}/dismiss` + `.../remove`
234 + - [x] Remove via flag: mod-removes post + resolves all flags + mod log entry
235 + - [x] New route file: `src/routes/flagging.rs`
236 + - [x] 6 integration tests
237 +
238 + ## Phase 18 — Tags
239 +
240 + - [x] Migration 014: `tags` table (community-scoped, UNIQUE slug) + `thread_tags` join table
241 + - [x] Community settings: create/delete tags (owner only)
242 + - [x] Thread creation: tag checkboxes, custom `deserialize_string_or_seq` for serde_urlencoded compatibility
243 + - [x] Tag badges on thread listing rows
244 + - [x] Category filter by tag (`?tag=slug`) with tag chip UI
245 + - [x] Batch-fetch tags per thread (HashMap pattern)
246 + - [x] 5 integration tests
247 +
248 + ## Phase 16 — Unread/New Tracking
249 +
250 + - [x] Tier 1 (JS localStorage): `mt_thread_state` map, `data-thread-id`/`data-reply-count` attrs, `.unread` class, LRU cap 1000
251 + - [x] Migration 015: `tracked_threads` table (user_id, thread_id PK, last_read_post_id, tracked_at)
252 + - [x] Track/untrack buttons on thread page (logged-in only)
253 + - [x] `POST /p/{slug}/{cat}/{thread_id}/track` + `.../untrack` + `POST /tracked/stop-all`
254 + - [x] Read position upsert on tracked thread view (last post on current page)
255 + - [x] `/tracked` page: tracked threads with unread counts, "Stop tracking all"
256 + - [x] New route file: `src/routes/tracking.rs`
257 + - [x] 6 integration tests
258 +
259 + ## Phase 19 — @Mentions
260 +
261 + - [x] Parse `@username` in post body during markdown rendering — render as link to community-scoped profile (`/p/{slug}/u/{username}`)
262 + - [x] Migration 016: `post_mentions` table — `(post_id UUID, mentioned_user_id UUID, created_at TIMESTAMPTZ)`
263 + - [x] On post creation: extract `@username` tokens, resolve to user IDs, insert into `post_mentions` (self-mentions excluded)
264 + - [x] Unknown usernames left as plain text (not linked)
265 + - [x] Mentions inside code spans/blocks skipped
266 + - [x] Unit tests: extraction, dedup, code-span skip, fenced code, resolve valid/invalid/mixed (9 tests in markdown.rs)
267 + - [x] Integration tests: mention renders as profile link, mention stored in DB, self-mention not stored, unknown username left as text (4 tests)
268 + - [x] Category listing: threads where logged-in user was mentioned get violet left border + `@` badge (batch query `get_threads_with_mentions_for_user`)
269 + - [x] `/tracked` page: tracked threads with mentions show `@` badge + `mentioned` class (via `has_mention` EXISTS subselect)
270 + - [x] CSS: `.badge-mention` (violet `@` text), `tr.mentioned` (3px violet left border). Fixed `var(--accent)` → `var(--highlight)` bug.
271 + - [x] Integration tests: mention indicator on category listing, mention indicator on tracked page (2 tests)
272 +
273 + ## Phase 20 — Link Previews
274 +
275 + - [x] Migration 017: `link_previews` table — `(id UUID, post_id UUID, url TEXT, title TEXT, description TEXT, fetched_at TIMESTAMPTZ)`
276 + - [x] On post creation: extract URLs from body, fetch OpenGraph `og:title` + `og:description` (5s timeout, 1MB body cap)
277 + - [x] Fetch happens once at post creation time, not on every render. Failures logged, don't block post creation.
278 + - [x] Render below post body: card with title + description + URL, linked with `rel="noopener noreferrer nofollow"`
279 + - [x] Batch-fetch previews in thread view (same pattern as footnotes/endorsements)
280 + - [x] Unit tests: URL extraction from markdown, OG meta parsing, cap at 3 URLs (10 tests in link_preview.rs)
281 + - [x] Integration tests: preview renders in thread, no previews for plain text, multiple previews render (3 tests)
282 +
283 + ## Phase 23 — Search
284 +
285 + - [x] Migration 018: `CREATE EXTENSION pg_trgm`, GIN trigram indexes on threads.title and posts.body_markdown, generated tsvector columns + GIN indexes
286 + - [x] Search query: CTE combining tsvector/ts_rank + pg_trgm similarity, title matches ranked 2x above body, recency tiebreak
287 + - [x] `GET /search?q=...&scope=...` endpoint returning HTMX fragment, optional community scope
288 + - [x] Search modal UI: overlay triggered by `/` key or header "Search" button, HTMX `hx-get` with `keyup changed delay:150ms`
289 + - [x] Keyboard navigation: arrow keys to move selection, Enter to navigate, Esc to close
290 + - [x] No inline event handlers (XSS test compatible)
291 + - [x] New files: src/routes/search.rs, templates/fragments/search_results.html, migrations/018_search_indexes.sql
292 + - [x] Integration tests: search by title, body content match, scoped vs global, empty query returns nothing, deleted thread excluded (5 tests)
293 +
294 + ## Remaining Items from Completed Phases
295 +
296 + - [x] Endorsement count as profile stat — added endorsement_count subquery to get_user_profile_in_community, displayed on user_profile.html (Phase 15 → Phase 22)
297 + - [x] Opt-out toggle UI for Tier 1 unread tracking — checkbox on /tracked page toggles `mt_tracking_enabled` localStorage key (Phase 16)
298 + - [x] Privacy transparency static page — GET /about/tracking, explains Tier 1/Tier 2 tracking, no third-party analytics, linked from footer + tracked page (Phase 16)
299 + - [x] Integration tests: profile shows endorsement count, tracking info page loads (2 tests)
300 +
301 + ## Phase 24 — Image Uploads
302 +
303 + - [x] S3 config (S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY), graceful degradation when unconfigured
304 + - [x] `src/storage.rs`: S3Storage client (aws-sdk-s3), validate_image(), strip_exif_jpeg(), generate_image_key()
305 + - [x] `src/routes/uploads.rs`: POST /p/{slug}/upload (multipart), GET /uploads/{id} (proxy from S3), POST /p/{slug}/uploads/{id}/remove (mod)
306 + - [x] Migration 019: images table (id, uploader_id, community_id, s3_key, filename, content_type, size_bytes, created_at, removed_at, removed_by)
307 + - [x] DB: insert_image(), remove_image() mutations; get_image(), count_recent_uploads_by_user() queries
308 + - [x] File validation: png/jpg/gif/webp only, max 5MB, extension-content_type cross-validation
309 + - [x] EXIF stripping: JPEG APP1 (EXIF) and APP13 (IPTC) segments removed without re-encoding
310 + - [x] JS: drag-and-drop + paste handler on textareas, placeholder text during upload, CSRF token injection
311 + - [x] CSS: .post-body img max-width + clickable, textarea.drag-over dashed border
312 + - [x] Image click opens full size in new tab
313 + - [x] Rate limit: 20 uploads per user per hour
314 + - [x] Unit tests: 10 (validation + EXIF strip + key generation)
315 + - [x] Integration tests: 4 (auth required, 503 without S3, nonexistent 404, invalid UUID 404)
316 +
317 + ---
318 +
319 + ## Remaining Items — Resolved (2026-03-16)
320 +
321 + ### Moderation integration tests
322 + - [x] mod_remove_post_directly — mod removes post via direct handler, verifies DB state
323 + - [x] member_cannot_remove_post — non-mod gets 403
324 + - [x] removed_post_shows_removed_in_thread — CSS class appears after removal
325 + - [x] mod_log_shows_actions — mod log page renders actions + actor username
326 + - [x] mod_log_forbidden_for_members — non-mod gets 403
327 + - [x] moderation_page_shows_bans_and_flags — page lists banned users + pending flags
328 +
329 + ### Forum directory pagination
330 + - [x] list_communities paginated (LIMIT/OFFSET), count_communities query
331 + - [x] Pagination nav on forum directory template
332 + - [x] Integration test: 30 communities → 2 pages with Next/Previous links
333 +
334 + ### Auto-moderation: auto_hide_threshold
335 + - [x] Migration 020: `auto_hide_threshold` column on communities (INTEGER, nullable)
336 + - [x] CommunityRow includes auto_hide_threshold
337 + - [x] Settings form + handler saves threshold (0 = disabled/NULL)
338 + - [x] flag_post_handler checks threshold after inserting flag, auto-removes post if met
339 + - [x] count_pending_flags_for_post query
340 + - [x] Integration tests: threshold triggers removal, NULL disables auto-hide, settings saves threshold (3 tests)
341 + - [x] CSS: .form-help + .input-narrow for settings form
342 +
343 + ### MNW Forums tab
344 + - [x] Already implemented in MNW — dashboard tab, HTMX partial, MT_BASE_URL config
345 +
346 + ## Run 8 Audit Items (Mar 2026)
347 + - [x] Remove unnecessary `data.clone()` in uploads.rs (saves up to 5MB allocation per image upload)
348 +
349 + ---
350 +
351 + ## Rust Patterns Audit (2026-03-21)
352 +
353 + - [x] Create `CommunityRole` enum (Owner/Moderator/Member) replacing string checks
354 + - [x] Create `BanType` enum (Ban/Mute) replacing `&str` parameter
355 + - [x] Create `ModAction` enum replacing raw string mod log actions
356 + - [x] Create `SortColumn`/`SortOrder` enums replacing raw strings
357 + - [x] Wrap `create_post` + `last_activity_at` update in a transaction
358 + - [x] Optimize config cloning — clone once at startup, reuse reference
359 +
360 + ---
361 +
362 + ## Bug Hunt Fixes (2026-03-22)
363 +
364 + - [x] Link preview body could exceed MAX_BODY_SIZE — added chunk slicing and stream exhaustion handling (`link_preview.rs`)
365 +
366 + ---
367 +
368 + ## TagTree Integration (2026-03-21)
369 +
370 + - [x] Add `tagtree` workspace dependency
371 + - [x] Replace inline tag slug validation with `tagtree::validate_with()` (TagConfig: max_depth 3, max_length 64)
372 + - [x] Tag slugs now support dot-separated hierarchy
373 + - [x] All 218 tests pass
374 +
375 + ---
376 +
377 + ## Platform Integration — Internal API
378 +
379 + - [x] `POST /internal/communities` — create community for an MNW project (HMAC-SHA256 auth)
380 + - [x] `POST /internal/threads` — create thread linked to MNW item/blog post
381 + - [x] `POST /internal/posts` — create system post
382 + - [x] `GET /internal/threads/{id}/stats` — comment count for embedding in MNW UI
383 + - [x] Auth middleware: `X-Internal-Signature` header with HMAC-SHA256
384 + - [x] `communities.project_id` nullable FK — migration 021
385 + - [x] `threads.external_ref` nullable — migration 021
386 +
387 + ## Default Categories (auto-provisioned)
388 +
389 + - [x] Items (comments on items)
390 + - [x] Blog (comments on blog posts)
391 + - [x] Devlog (developer updates)
392 + - [x] Discussion (general)
@@ -0,0 +1,71 @@
1 + # Multithreaded -- Audit History
2 +
3 + Full chronological audit log. See [audit_review.md](./audit_review.md) for current state.
4 +
5 + ## Changes Since Last Audit
6 +
7 + ### Seventh formal audit (2026-03-28, Run 12 cross-project)
8 + - **Test count:** 225 (35 unit lib + 190 integration). 0 clippy warnings. 0 failures.
9 + - **Grade:** A (maintained). v0.3.2.
10 + - **Internal API improvements:** MNW category auto-provisioning (Items, Blog, Devlog, Discussion) via internal API with shared secret auth.
11 + - **Link preview fix:** Corrected URL extraction edge case.
12 + - **New dependency advisories (action items):**
13 + - aws-lc-sys 0.38.0 (RUSTSEC-2026-0044 + -0048, severity 7.4 HIGH) — upgrade to 0.39.0 via `cargo update -p aws-lc-sys`
14 + - rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`
15 + - **Mandatory surprise:** None new. Previous surprises (CoreError dead code, link_preview IPv6 blocking) both resolved.
16 + - **No new code findings.** All previous items remain resolved.
17 + - **Note:** Test count 225 is lower than previous 249 — mt-core (16) and mt-db (11) unit tests may not have been captured in this run. Integration tests grew from 187 to 190.
18 +
19 + ### Test coverage expansion (2026-03-22)
20 + - **Test count:** 222 -> 249 (+27 tests). 0 clippy warnings.
21 + - **Grade:** A (maintained). Testing A- -> A. Three cold spots resolved.
22 + - **auth.rs:** 3 -> 8 integration tests (+5). PKCE params, state nonce validation (3 paths), suspended user behavior.
23 + - **admin.rs:** 6 -> 10 integration tests (+4). Search, invalid UUID handling, mod_log entry creation, non-admin access denial.
24 + - **mutations.rs:** New test file with 18 integration tests. Covers: cleanup_expired_bans, ban upserts, swap_category_order, get_category_id_by_slugs, update_category, ensure_membership idempotency, soft_delete, create_post activity bump, toggle_endorsement, insert_flag idempotency, remove_image, link_preview dedup, mentions dedup, upsert_user.
25 + - **seed.rs:** Type safety improved — raw `&str` role params replaced with `CommunityRole` enum (B -> A-).
26 + - **Module heatmap updates:** auth.rs Test B- -> A-, admin.rs Test B -> A-, mutations.rs Test B -> A-, seed.rs Code B+ -> A- / Type Safety B -> A-.
27 +
28 + ### Fifth formal audit (2026-03-18, Run 9 cross-project)
29 + - **Test count:** 222 (unchanged). 0 clippy warnings.
30 + - **Grade:** A (maintained). v0.3.1 (deployed 2026-03-18).
31 + - **No new findings requiring action.**
32 + - **Observations (pre-existing, not regressions):**
33 + - ~~`deletion_task.abort()` in main.rs without awaiting completion~~ — Fixed: now awaits task completion after abort.
34 + - Inline `onsubmit` confirmation dialogs in thread.html — not screen-reader friendly. Impact: LOW, functional but not best-practice.
35 + - ~~No client-side maxlength on textarea inputs~~ — Fixed: maxlength added to all inputs/textareas. Server-side limits added for flag detail and ban/mute reason (1024 bytes).
36 + - **Mandatory surprise:** URL validation in link_preview.rs blocks IPv4-mapped IPv6 addresses via host_part parsing, but IPv6 full range check uses string prefix match for unique local addresses. Intentionally restrictive (good for SSRF) — not a vulnerability.
37 +
38 + ### Phases 19 + 20 implementation (2026-03-16)
39 + - **Test count:** 146 -> 173 (+27 tests: 19 unit + 7 integration + 1 workflow mod)
40 + - **Grade:** A (maintained). Phases 19 (@Mentions) and 20 (Link Previews) implemented.
41 + - **Source LOC:** ~7,000 (up from 6,232)
42 + - **Migrations:** 12 -> 17 (013 flagging, 014 tags, 015 tracking, 016 post_mentions, 017 link_previews)
43 + - **New files:** `src/link_preview.rs` (URL extraction + OG fetch), `tests/workflows/mentions.rs` (4 tests), `tests/workflows/link_previews.rs` (3 tests)
44 + - **New DB functions:** `resolve_usernames_in_community`, `insert_mentions`, `list_link_previews_for_posts`, `insert_link_preview`
45 + - **Markdown:** `extract_mention_usernames`, `resolve_mentions` with code-span awareness
46 + - **Zero clippy warnings, all 173 tests passing.**
47 +
48 + ### Second formal audit (2026-03-16, Run 6 cross-project)
49 + - **Test count:** 106 -> 146 (+40 tests)
50 + - **Grade:** A (maintained). Phases 14, 15, and 21 implemented since last audit.
51 + - **Source LOC:** 6,232 (up from ~4,800)
52 + - **Migrations:** 10 -> 12 (post_footnotes, post_endorsements)
53 + - **Instrument coverage:** 109/110 (99%) — near-perfect
54 + - **New finding (LOW):** Regex compiled per-request in verify_quotes/post_process_quotes for SHA-256 hash pattern matching. Should use LazyLock.
55 + - **Performance note:** forum.rs at 969 LOC split into forum/ directory module: views.rs (510) + actions.rs (480).
56 + - **Mandatory surprise:** Per-request regex in quote verification — LOW (functional but inefficient).
57 + - **Previous items verified:** All previous remediated items confirmed intact.
58 +
59 + ### First formal audit (2026-03-14)
60 + - **Grade:** B+ (unchanged from baseline, but now backed by per-module code review)
61 + - **Baseline was optimistic on:** Security (A- -> B+: javascript: XSS found, fail-open patterns found), Type Safety (A- -> B+: domain types confirmed unused), Observability (B -> C: zero #[instrument] is worse than "no annotations yet"), Performance (B -> A-: indexes are actually solid)
62 + - **Baseline was pessimistic on:** Performance (B -> A-: proper composite indexes, partial indexes, no N+1)
63 + - **Test count confirmed:** 90 (documented 72 was wrong: 56 integration + 18 unit markdown/csrf + 16 unit mt-core)
64 + - **New findings:** 1 HIGH (javascript: XSS), 4 MEDIUM (secure cookie, transaction, fail-open, observability), 5 SMALL
65 +
66 + ### Full remediation (2026-03-14)
67 + - **Grade:** B+ -> A- (all 10 findings resolved, grade capped by git hygiene)
68 + - **Tests:** 90 -> 97 (+7 markdown security tests)
69 + - **Files:** 36 -> 33 (deleted error.rs, models.rs, pool.rs)
70 + - **Cold spots:** 7 -> 3 (resolved: markdown XSS, observability, dead code, dead docs x2)
71 + - **Key changes:** URL scheme allowlist sanitization, 86 `#[instrument(skip_all)]`, fail-closed access checks, transaction wrapping, configurable Secure cookie, dead code + deps removed, mod log error logging, `.env.example` expanded
@@ -104,70 +104,6 @@ Filed in `docs/mnw/mt/todo.md`.
104 104 | 2026-03-22 (coverage) | ~7,000 | ~39| 249 | ~36 | 0 | 0 | A |
105 105 | 2026-03-28 (Run 12) | ~7,200 | ~39| 225+ | ~32 | 0 | 0 | A |
106 106
107 - ## Changes Since Last Audit
108 -
109 - ### Seventh formal audit (2026-03-28, Run 12 cross-project)
110 - - **Test count:** 225 (35 unit lib + 190 integration). 0 clippy warnings. 0 failures.
111 - - **Grade:** A (maintained). v0.3.2.
112 - - **Internal API improvements:** MNW category auto-provisioning (Items, Blog, Devlog, Discussion) via internal API with shared secret auth.
113 - - **Link preview fix:** Corrected URL extraction edge case.
114 - - **New dependency advisories (action items):**
115 - - aws-lc-sys 0.38.0 (RUSTSEC-2026-0044 + -0048, severity 7.4 HIGH) — upgrade to 0.39.0 via `cargo update -p aws-lc-sys`
116 - - rustls-webpki 0.103.9 (RUSTSEC-2026-0049) — upgrade to 0.103.10 via `cargo update -p rustls-webpki`
117 - - **Mandatory surprise:** None new. Previous surprises (CoreError dead code, link_preview IPv6 blocking) both resolved.
118 - - **No new code findings.** All previous items remain resolved.
119 - - **Note:** Test count 225 is lower than previous 249 — mt-core (16) and mt-db (11) unit tests may not have been captured in this run. Integration tests grew from 187 to 190.
120 -
121 - ### Test coverage expansion (2026-03-22)
122 - - **Test count:** 222 -> 249 (+27 tests). 0 clippy warnings.
123 - - **Grade:** A (maintained). Testing A- -> A. Three cold spots resolved.
124 - - **auth.rs:** 3 -> 8 integration tests (+5). PKCE params, state nonce validation (3 paths), suspended user behavior.
125 - - **admin.rs:** 6 -> 10 integration tests (+4). Search, invalid UUID handling, mod_log entry creation, non-admin access denial.
126 - - **mutations.rs:** New test file with 18 integration tests. Covers: cleanup_expired_bans, ban upserts, swap_category_order, get_category_id_by_slugs, update_category, ensure_membership idempotency, soft_delete, create_post activity bump, toggle_endorsement, insert_flag idempotency, remove_image, link_preview dedup, mentions dedup, upsert_user.
127 - - **seed.rs:** Type safety improved — raw `&str` role params replaced with `CommunityRole` enum (B -> A-).
128 - - **Module heatmap updates:** auth.rs Test B- -> A-, admin.rs Test B -> A-, mutations.rs Test B -> A-, seed.rs Code B+ -> A- / Type Safety B -> A-.
129 -
130 - ### Fifth formal audit (2026-03-18, Run 9 cross-project)
131 - - **Test count:** 222 (unchanged). 0 clippy warnings.
132 - - **Grade:** A (maintained). v0.3.1 (deployed 2026-03-18).
133 - - **No new findings requiring action.**
134 - - **Observations (pre-existing, not regressions):**
135 - - ~~`deletion_task.abort()` in main.rs without awaiting completion~~ — Fixed: now awaits task completion after abort.
136 - - Inline `onsubmit` confirmation dialogs in thread.html — not screen-reader friendly. Impact: LOW, functional but not best-practice.
137 - - ~~No client-side maxlength on textarea inputs~~ — Fixed: maxlength added to all inputs/textareas. Server-side limits added for flag detail and ban/mute reason (1024 bytes).
138 - - **Mandatory surprise:** URL validation in link_preview.rs blocks IPv4-mapped IPv6 addresses via host_part parsing, but IPv6 full range check uses string prefix match for unique local addresses. Intentionally restrictive (good for SSRF) — not a vulnerability.
139 -
140 - ### Phases 19 + 20 implementation (2026-03-16)
141 - - **Test count:** 146 -> 173 (+27 tests: 19 unit + 7 integration + 1 workflow mod)
142 - - **Grade:** A (maintained). Phases 19 (@Mentions) and 20 (Link Previews) implemented.
143 - - **Source LOC:** ~7,000 (up from 6,232)
144 - - **Migrations:** 12 -> 17 (013 flagging, 014 tags, 015 tracking, 016 post_mentions, 017 link_previews)
145 - - **New files:** `src/link_preview.rs` (URL extraction + OG fetch), `tests/workflows/mentions.rs` (4 tests), `tests/workflows/link_previews.rs` (3 tests)
146 - - **New DB functions:** `resolve_usernames_in_community`, `insert_mentions`, `list_link_previews_for_posts`, `insert_link_preview`
147 - - **Markdown:** `extract_mention_usernames`, `resolve_mentions` with code-span awareness
148 - - **Zero clippy warnings, all 173 tests passing.**
149 -
150 - ### Second formal audit (2026-03-16, Run 6 cross-project)
151 - - **Test count:** 106 -> 146 (+40 tests)
152 - - **Grade:** A (maintained). Phases 14, 15, and 21 implemented since last audit.
153 - - **Source LOC:** 6,232 (up from ~4,800)
154 - - **Migrations:** 10 -> 12 (post_footnotes, post_endorsements)
155 - - **Instrument coverage:** 109/110 (99%) — near-perfect
156 - - **New finding (LOW):** Regex compiled per-request in verify_quotes/post_process_quotes for SHA-256 hash pattern matching. Should use LazyLock.
157 - - **Performance note:** forum.rs at 969 LOC split into forum/ directory module: views.rs (510) + actions.rs (480).
158 - - **Mandatory surprise:** Per-request regex in quote verification — LOW (functional but inefficient).
159 - - **Previous items verified:** All previous remediated items confirmed intact.
160 -
161 - ### First formal audit (2026-03-14)
162 - - **Grade:** B+ (unchanged from baseline, but now backed by per-module code review)
163 - - **Baseline was optimistic on:** Security (A- -> B+: javascript: XSS found, fail-open patterns found), Type Safety (A- -> B+: domain types confirmed unused), Observability (B -> C: zero #[instrument] is worse than "no annotations yet"), Performance (B -> A-: indexes are actually solid)
164 - - **Baseline was pessimistic on:** Performance (B -> A-: proper composite indexes, partial indexes, no N+1)
165 - - **Test count confirmed:** 90 (documented 72 was wrong: 56 integration + 18 unit markdown/csrf + 16 unit mt-core)
166 - - **New findings:** 1 HIGH (javascript: XSS), 4 MEDIUM (secure cookie, transaction, fail-open, observability), 5 SMALL
167 -
168 - ### Full remediation (2026-03-14)
169 - - **Grade:** B+ -> A- (all 10 findings resolved, grade capped by git hygiene)
170 - - **Tests:** 90 -> 97 (+7 markdown security tests)
171 - - **Files:** 36 -> 33 (deleted error.rs, models.rs, pool.rs)
172 - - **Cold spots:** 7 -> 3 (resolved: markdown XSS, observability, dead code, dead docs x2)
173 - - **Key changes:** URL scheme allowlist sanitization, 86 `#[instrument(skip_all)]`, fail-closed access checks, transaction wrapping, configurable Secure cookie, dead code + deps removed, mod log error logging, `.env.example` expanded
107 + ---
108 +
109 + See [audit_history.md](./audit_history.md) for full chronological audit log.
@@ -0,0 +1,129 @@
1 + # Multithreaded — Code Review
2 +
3 + **Date:** 2026-04-12
4 + **Version:** 0.3.2
5 + **Reviewer:** Claude (Opus 4.6)
6 + **Scope:** Full codebase review — all Rust source, SQL migrations, templates, CSS, JS, deploy config, tests
7 +
8 + ## Summary
9 +
10 + Multithreaded is a forum application (~8,800 LOC Rust, ~20,800 total) built on Axum/PostgreSQL with MNW OAuth integration. 3-crate workspace (mt-core, mt-db, main app). 21 migrations, 21 HTML templates, 510 lines JS, 1,761 lines CSS. 225+ tests (35 unit + 190 integration). 0 clippy warnings. Security posture is strong. Code is clean and well-organized.
11 +
12 + **Overall: A** — consistent with the standing audit grade. No new vulnerabilities found. Several minor structural items noted below.
13 +
14 + ---
15 +
16 + ## Findings
17 +
18 + ### [MEDIUM] Dependency advisories — 4 active
19 +
20 + `cargo audit` reports 4 vulnerabilities:
21 +
22 + | Crate | Advisory | Severity | Fix |
23 + |-------|----------|----------|-----|
24 + | aws-lc-sys 0.38.0 | RUSTSEC-2026-0044 | HIGH | `cargo update -p aws-lc-sys` (>=0.39.0) |
25 + | aws-lc-sys 0.38.0 | RUSTSEC-2026-0048 | HIGH 7.4 | Same |
26 + | rustls-webpki 0.103.9 | RUSTSEC-2026-0049 | — | `cargo update -p rustls-webpki` (>=0.103.10) |
27 + | rand 0.8.5 + 0.9.2 | RUSTSEC-2026-0097 | — | Unsoundness with custom loggers (not applicable here, but upgrade when deps allow) |
28 +
29 + Additionally 3 allowed warnings (rsa, lru — transitive, no direct fix available).
30 +
31 + aws-lc-sys and rustls-webpki are straightforward `cargo update` fixes. The rand advisory (RUSTSEC-2026-0097) affects `rand::rng()` with custom loggers — MT doesn't use custom loggers, so no practical impact, but both 0.8.5 (direct dep) and 0.9.2 (transitive via axum/governor) are flagged. Consider bumping the direct `rand` dep from 0.8 to 0.9 when convenient.
32 +
33 + ### [MEDIUM] Three route files exceed 500-line guideline
34 +
35 + Per project conventions, files with >500 lines of branching logic should be split.
36 +
37 + | File | Lines | Content |
38 + |------|-------|---------|
39 + | `src/routes/mod.rs` | 578 | Route tree, form types, 15+ helper functions |
40 + | `src/routes/forum/views.rs` | 628 | 11 view handlers |
41 + | `src/routes/forum/actions.rs` | 600 | 10 action handlers |
42 +
43 + `mod.rs` could split helpers into `routes/helpers.rs` (validation, permission checks, markdown rendering) and keep forms + route tree in `mod.rs`. The forum files could split by page area (thread views vs. listing views, thread actions vs. post actions).
44 +
45 + `queries.rs` (1,503) and `mutations.rs` (879) are exempt — flat lists of SQL query functions with no branching logic.
46 +
47 + ### [LOW] Pagination partial not used consistently
48 +
49 + `templates/pages/forum_directory.html` (lines 40-49) inlines pagination markup instead of using `{% include "partials/pagination.html" %}`. Only `thread.html` uses the partial. If the pagination design changes, it needs updating in two places.
50 +
51 + ### [LOW] CSS: `.form-inline-row` has contradictory display properties
52 +
53 + ```css
54 + .form-inline-row { display: inline; gap: 0.25rem; flex-direction: row; align-items: center; }
55 + ```
56 +
57 + `gap`, `flex-direction`, and `align-items` are flex/grid properties — they have no effect with `display: inline`. Used on 2 admin forms. Should be `display: inline-flex`.
58 +
59 + ### [LOW] No `Cache-Control` on HTML responses
60 +
61 + Static files get caching from tower-http `ServeDir`. Image proxy sets `max-age=31536000`. But regular HTML pages have no explicit cache control headers. Adding `private, no-cache` for authenticated pages prevents stale content after logout/session change.
62 +
63 + ### [INFO] Test harness duplication
64 +
65 + `tests/harness/mod.rs`: `new()` and `new_with_admin()` share ~40 lines of identical setup code. `tests/harness/client.rs`: `send_raw` and `send_raw_with_token` duplicate ~60 lines. Not a correctness issue, but a maintenance burden.
66 +
67 + ### [INFO] `MaybeUser` silently swallows session errors
68 +
69 + In `auth.rs`, the `MaybeUser` extractor returns `MaybeUser(None)` on any session read error. This is intentionally infallible, but session store failures silently deauthenticate users without logging at the extraction point. A `tracing::warn!` on the error path would help debugging.
70 +
71 + ### [INFO] No index on `posts.removed_at`
72 +
73 + The moderation page queries removed posts and thread views filter by removal status. At scale, a partial index `WHERE removed_at IS NOT NULL` on `posts` would help. Current data volumes don't require it.
74 +
75 + ---
76 +
77 + ## Strengths
78 +
79 + - **Security is defense-in-depth.** CSRF (constant-time comparison, auto-injected), SSRF (comprehensive private IP blocking on link previews), XSS (docengine sanitization + Askama autoescaping), SQL injection (all parameterized), rate limiting (per-IP + per-user), HMAC internal API with replay protection, EXIF stripping on uploads, CSP headers. 11 dedicated XSS tests.
80 + - **Immutable post model.** Posts cannot be edited or deleted by users — only corrected via footnotes or mod-removed (content preserved). Philosophically consistent and well-executed.
81 + - **Test infrastructure is excellent.** Per-test database isolation. Cookie-aware HTTP client with auto CSRF extraction. Full Axum app in-process. 22 workflow test files covering every feature area.
82 + - **Clean 3-crate separation.** mt-core has zero deps beyond chrono. mt-db has no web framework deps. Main crate handles all HTTP concerns. No circular dependencies.
83 + - **Production hardening.** Systemd service with 18 security directives (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, RestrictAddressFamilies, etc.). Memory cap 512M. Graceful shutdown.
84 + - **Quote verification.** SHA-256 hash of quoted text verified server-side before rendering attribution. Prevents fabricated quotes.
85 + - **Observability.** 86+ `#[instrument(skip_all)]` annotations. Structured logging via tracing with env filter.
86 +
87 + ## Security Checklist
88 +
89 + | Check | Status |
90 + |-------|--------|
91 + | SQL injection | Pass — all queries parameterized via SQLx |
92 + | XSS | Pass — docengine sanitization + Askama autoescaping + CSP |
93 + | CSRF | Pass — synchronizer token, constant-time comparison, all mutating routes |
94 + | SSRF | Pass — link preview validates IPs (IPv4+IPv6 private ranges blocked) |
95 + | Auth bypass | Pass — fail-closed access checks, session cycling on login |
96 + | IDOR | Pass — all resource access checks use authenticated user context |
97 + | Rate limiting | Pass — per-IP (tower-governor) + per-user (15 posts/60s) + upload rate (20/hr) |
98 + | Secrets in source | Pass — env-based config, no secrets committed (env templates have placeholders) |
99 + | Dependency advisories | Fail — 4 active (2 HIGH via aws-lc-sys, fixable with cargo update) |
100 +
101 + ## Metrics
102 +
103 + | Metric | Value |
104 + |--------|-------|
105 + | Rust source LOC | ~8,800 |
106 + | Total LOC (all files) | ~20,800 |
107 + | Rust source files | 39 |
108 + | Test files | 24 (harness + workflows) |
109 + | Test count | 225+ (35 unit + 190 integration) |
110 + | Tests/KLOC | ~26 |
111 + | Clippy warnings | 0 |
112 + | Migrations | 21 |
113 + | HTML templates | 21 |
114 + | Query functions | 55+ |
115 + | Mutation functions | 30+ |
116 + | Dependencies (direct) | 28 |
117 + | Audit advisories | 4 (2 HIGH, 2 low/info) |
118 +
119 + ## Action Items
120 +
121 + 1. ~~**[MEDIUM]** Run `cargo update -p aws-lc-sys -p rustls-webpki` to fix 3 of 4 advisories~~ — Done. aws-lc-sys 0.39.1, rustls-webpki 0.103.11.
122 + 2. ~~**[MEDIUM]** Split `routes/mod.rs` helpers into `routes/helpers.rs`~~ — Done. mod.rs 281, helpers.rs 315.
123 + 3. ~~**[LOW]** Fix `.form-inline-row` CSS to `display: inline-flex`~~ — Done.
124 + 4. ~~**[LOW]** Use pagination partial in `forum_directory.html`~~ — Done.
125 + 5. ~~**[LOW]** Add `Cache-Control: private, no-cache` to HTML responses~~ — Done.
126 + 6. ~~**[MEDIUM]** Split `forum/views.rs` and `forum/actions.rs`~~ — Done. views.rs 424, thread.rs 224, posts.rs 443, actions.rs 172.
127 + 7. **[INFO]** Transitive dep advisories (rand, rsa, lru) — no fix available, monitor upstream.
128 + 8. **[INFO]** Add partial index on `posts.removed_at` when data volume warrants it.
129 + 9. **[INFO]** Add `tracing::warn!` to `MaybeUser` extractor on session read errors.
@@ -0,0 +1,81 @@
1 + # Multithreaded -- Competitive Analysis
2 +
3 + Last updated: 2026-04-02
4 +
5 + ## Positioning
6 +
7 + Multithreaded is forum software designed to be embedded within a creator platform. Each MNW project gets a community forum with zero configuration -- users authenticate via MNW OAuth (PKCE), and moderation integrates with the platform's existing trust system. This is not a standalone forum; it's a community layer for creators who already use Makenotwork.
8 +
9 + The key differentiator is integration depth: no separate auth, no separate user management, no separate moderation tools. A creator enables a forum for their project and it works immediately with their existing audience.
10 +
11 + ## Pricing Comparison
12 +
13 + | App | Price | Model |
14 + |-----|-------|-------|
15 + | **Multithreaded** | Included with MNW | Part of creator platform |
16 + | Discourse | $50-$300/mo (hosted) or self-host | Open source (GPL) |
17 + | Circle | $49-$399/mo | SaaS |
18 + | Mighty Networks | $41-$360/mo | SaaS |
19 + | Lemmy | Free (self-host) | Open source (AGPL) |
20 + | Flarum | Free (self-host) | Open source (MIT) |
21 + | XenForo | $160 license + $55/yr | Proprietary |
22 +
23 + ## Feature Matrix
24 +
25 + | Feature | MT | Discourse | Circle | Lemmy | Flarum |
26 + |---------|:--:|:---------:|:------:|:-----:|:------:|
27 + | Platform-integrated auth | Y | N | N | N | N |
28 + | Creator project communities | Y | N | N | N | N |
29 + | Categories | Y | Y | Y | Y | Y |
30 + | Threads + replies | Y | Y | Y | Y | Y |
31 + | Role-based moderation | Y | Y | Y | Y | Y |
32 + | Soft deletes + audit trail | Y | Y | N | N | N |
33 + | SSO/OAuth | Y (native) | Y (plugin) | Y | N | Y (ext) |
34 + | Federation | N | N | N | Y | N |
35 + | Real-time | N | Y | Y | N | N |
36 + | Email notifications | N | Y | Y | Y | Y |
37 + | Mobile app | N | Y | Y | Y | N |
38 + | Plugins/extensions | N | Y | N | N | Y |
39 + | Self-hostable | Y | Y | N | Y | Y |
40 + | Markdown rendering | Y | Y | N | Y | Y |
41 + | @mentions | Y | Y | Y | Y | N |
42 +
43 + ## Competitor Deep Dives
44 +
45 + ### 1. Discourse
46 +
47 + Industry-standard open-source forum software. Feature-rich (badges, trust levels, SSO, plugins, real-time). Hosted plans start at $50/mo. Self-hosting requires significant infrastructure. Overkill for small creator communities.
48 +
49 + **What MT lacks:** real-time updates, email notifications, trust levels, badges, plugins, mobile app, search, private messaging.
50 +
51 + ### 2. Circle
52 +
53 + Community platform with membership gating, Stripe integration, courses, events. Hosted-only SaaS ($49-$399/mo). Targets creators and course builders.
54 +
55 + **What MT lacks:** events, courses, membership tiers in the forum itself, mobile app. **What Circle lacks:** self-hosting, source availability, per-project granularity, zero-config creator integration.
56 +
57 + ### 3. Lemmy
58 +
59 + Federated link aggregator (Reddit alternative) built in Rust. ActivityPub protocol for cross-instance communication. Community-run, no monetization.
60 +
61 + **What MT lacks:** federation, voting/karma, link aggregation. **What Lemmy lacks:** creator platform integration, OAuth from parent platform, per-project communities.
62 +
63 + ### 4. Flarum
64 +
65 + Lightweight, modern forum software (PHP). Extensible via community packages. Free and open source. Clean UI but limited moderation tools in core.
66 +
67 + **What MT lacks:** extension system, search, rich text editor. **What Flarum lacks:** integrated auth, creator platform awareness, Rust performance.
68 +
69 + ## What We Offer That Competitors Don't
70 +
71 + - **Zero-config community per project** -- creator enables a forum and it works with their existing MNW audience
72 + - **Native platform authentication** -- MNW OAuth PKCE flow, no separate accounts or passwords
73 + - **Integrated moderation** -- platform-level suspensions cascade to forums; no separate admin panel
74 + - **Lightweight deployment** -- single Rust binary, systemd unit, no Docker or external services
75 + - **Source-available** -- PolyForm Noncommercial 1.0.0
76 +
77 + ## Target Users
78 +
79 + - MNW creators who want discussion forums for their projects
80 + - Open-source maintainers using MNW for distribution who want community feedback
81 + - Small communities that don't need the complexity of Discourse or the cost of Circle
@@ -0,0 +1,182 @@
1 + # OAuth Flow
2 +
3 + Multithreaded uses MNW as its sole identity provider. All authentication goes through an OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange). No passwords are stored locally.
4 +
5 + ## Environment Variables
6 +
7 + | Variable | Required | Default | Example |
8 + |----------|----------|---------|---------|
9 + | `MNW_BASE_URL` | Yes | `http://127.0.0.1:3000` | `https://makenot.work` |
10 + | `OAUTH_CLIENT_ID` | Yes | -- | `mt-forums-6378957b452bbbc906c3db8edd072d64` |
11 + | `OAUTH_REDIRECT_URI` | Yes | `http://127.0.0.1:3400/auth/callback` | `https://forums.makenot.work/auth/callback` |
12 + | `COOKIE_SECURE` | No | `true` | `false` (local dev over HTTP) |
13 + | `PLATFORM_ADMIN_ID` | No | -- | UUID of the platform admin user |
14 +
15 + ## PKCE OAuth Flow (Step by Step)
16 +
17 + ```
18 + Browser Multithreaded MNW (OAuth Provider)
19 + │ │ │
20 + │ 1. GET /auth/login │ │
21 + │──────────────────────>│ │
22 + │ │ 2. Generate verifier, │
23 + │ │ challenge, state │
24 + │ │ Store in session │
25 + │ │ │
26 + │ 3. 302 Redirect │ │
27 + │<──────────────────────│ │
28 + │ │
29 + │ 4. GET /oauth/authorize?response_type=code │
30 + │ &client_id=...&redirect_uri=... │
31 + │ &state=...&code_challenge=... │
32 + │ &code_challenge_method=S256 │
33 + │──────────────────────────────────────────────────>│
34 + │ │
35 + │ 5. User authenticates on MNW │
36 + │<─────────────────────────────────────────────────>│
37 + │ │
38 + │ 6. 302 Redirect to redirect_uri │
39 + │ ?code=...&state=... │
40 + │<──────────────────────────────────────────────────│
41 + │ │
42 + │ 7. GET /auth/callback │
43 + │ ?code=...&state=... │
44 + │──────────────────────>│ │
45 + │ │ 8. Verify state nonce │
46 + │ │ Retrieve verifier │
47 + │ │ Clean up OAuth data │
48 + │ │ │
49 + │ │ 9. POST /oauth/token │
50 + │ │ {grant_type, code, │
51 + │ │ redirect_uri, │
52 + │ │ code_verifier, │
53 + │ │ client_id} │
54 + │ │──────────────────────────>│
55 + │ │ │
56 + │ │ 10. {access_token: ...} │
57 + │ │<──────────────────────────│
58 + │ │ │
59 + │ │ 11. GET /oauth/userinfo │
60 + │ │ Authorization: │
61 + │ │ Bearer <token> │
62 + │ │──────────────────────────>│
63 + │ │ │
64 + │ │ 12. {user_id, username, │
65 + │ │ display_name, │
66 + │ │ avatar_url} │
67 + │ │<──────────────────────────│
68 + │ │ │
69 + │ │ 13. Upsert local user │
70 + │ │ Check suspension │
71 + │ │ Save session │
72 + │ │ Cycle session ID │
73 + │ │ │
74 + │ 14. 302 Redirect / │ │
75 + │<──────────────────────│ │
76 + ```
77 +
78 + ### Detailed Steps
79 +
80 + 1. **User clicks "Log in"** -- browser sends `GET /auth/login`.
81 + 2. **Generate PKCE material** -- 32-byte random verifier (base64url), SHA-256 challenge (base64url), 16-byte state nonce (hex). Verifier and state stored in session.
82 + 3. **Redirect to MNW** -- 302 to `{MNW_BASE_URL}/oauth/authorize` with query params: `response_type=code`, `client_id`, `redirect_uri`, `state`, `code_challenge`, `code_challenge_method=S256`.
83 + 4. **MNW authorize endpoint** -- MNW shows its login/consent UI.
84 + 5. **User authenticates** -- enters credentials on MNW (or is already logged in).
85 + 6. **MNW redirects back** -- 302 to `redirect_uri` with `code` and `state` query params.
86 + 7. **Browser follows redirect** -- `GET /auth/callback?code=...&state=...`.
87 + 8. **Validate state and retrieve verifier** -- compare `state` param against session value (reject on mismatch). Retrieve PKCE verifier from session. Remove both from session.
88 + 9. **Token exchange** -- `POST {MNW_BASE_URL}/oauth/token` with JSON body: `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`, `client_id`. No client_secret (PKCE replaces it).
89 + 10. **Receive access token** -- MNW responds with `{ access_token: "..." }`.
90 + 11. **Fetch user info** -- `GET {MNW_BASE_URL}/oauth/userinfo` with `Authorization: Bearer {access_token}`.
91 + 12. **Receive user profile** -- `{ user_id, username, display_name, avatar_url }`.
92 + 13. **Local processing** -- upsert user into `users` table (keyed on `mnw_account_id`), check `suspended_at` (fail-closed: DB errors block login), save `SessionUser` (user_id, username, display_name) to session, cycle session ID to prevent fixation.
93 + 14. **Redirect home** -- 302 to `/`.
94 +
95 + ## Session Management
96 +
97 + - **Store**: `tower-sessions` with `PostgresStore` (auto-migrated table).
98 + - **Cookie name**: `mt_session`.
99 + - **Expiry**: 7 days of inactivity (`OnInactivity`). Reset on each request.
100 + - **SameSite**: `Lax` (allows top-level navigations but blocks cross-origin POST).
101 + - **Secure flag**: controlled by `COOKIE_SECURE` env var. `true` in production (HTTPS), `false` for local HTTP dev.
102 + - **Expired session cleanup**: background tokio task runs every 3600 seconds, deletes expired rows from the session table.
103 + - **Session ID cycling**: on successful login, `session.cycle_id()` generates a new session ID to prevent session fixation attacks.
104 +
105 + ### Session Keys
106 +
107 + | Key | Type | Set When |
108 + |-----|------|----------|
109 + | `user_id` | `Uuid` | Login success |
110 + | `username` | `String` | Login success |
111 + | `display_name` | `Option<String>` | Login success |
112 + | `oauth_state` | `String` | Login initiated (cleared after callback) |
113 + | `pkce_verifier` | `String` | Login initiated (cleared after callback) |
114 + | `csrf_token` | `String` | First state-changing request or template render |
115 +
116 + ## Auth Extractors
117 +
118 + ### `MaybeUser(Option<SessionUser>)`
119 +
120 + - Infallible extractor. Always succeeds.
121 + - Returns `Some(SessionUser)` if the session contains valid user data, `None` otherwise.
122 + - Used by public routes that show different UI for logged-in vs anonymous users.
123 +
124 + ### `PlatformAdmin(SessionUser)`
125 +
126 + - Fallible extractor. Returns `404 Not Found` if the user is not logged in or is not the platform admin.
127 + - Compares `session.user_id` against `config.platform_admin_id`.
128 + - Returns 404 (not 403) to hide admin routes from non-admins.
129 + - Used by `/_admin/*` routes.
130 +
131 + ### `SessionUser`
132 +
133 + - Not an extractor itself. Data struct stored in sessions.
134 + - Fields: `user_id: Uuid`, `username: String`, `display_name: Option<String>`.
135 +
136 + ## Token Handling
137 +
138 + - Access tokens are **not stored** in the session or database. They are used once during the callback to fetch userinfo, then discarded.
139 + - No refresh token flow. When the session expires, the user must re-authenticate through MNW.
140 + - The PKCE verifier is ephemeral -- generated at login initiation, consumed at callback, never persisted beyond the session.
141 +
142 + ## CSRF Protection
143 +
144 + Separate from OAuth but relevant to authenticated requests:
145 +
146 + - SHA-256 random token (32 bytes, 64 hex chars) generated per session.
147 + - Stored in session under `csrf_token` key.
148 + - Included in HTML via meta tag, sent on POST/PUT/PATCH/DELETE via `X-CSRF-Token` header.
149 + - Validated by `csrf_middleware` with constant-time comparison.
150 + - Exempt paths: `/auth/*`, `/api/health`, `/_test/*`.
151 +
152 + ## Error Codes
153 +
154 + All OAuth errors redirect to `/?error={code}`. The error code is a query parameter on the home page.
155 +
156 + | Error Code | Cause |
157 + |------------|-------|
158 + | `state_mismatch` | `state` param does not match session value. Possible CSRF or expired session. |
159 + | `missing_verifier` | PKCE verifier not found in session. Session expired between login click and callback. |
160 + | `token_request_failed` | Network error contacting MNW `/oauth/token`. |
161 + | `token_exchange_failed` | MNW returned non-2xx from `/oauth/token`. Invalid code, expired code, or bad verifier. |
162 + | `token_parse_failed` | MNW token response was not valid JSON or missing `access_token`. |
163 + | `userinfo_request_failed` | Network error contacting MNW `/oauth/userinfo`. |
164 + | `userinfo_fetch_failed` | MNW returned non-2xx from `/oauth/userinfo`. Token invalid or expired. |
165 + | `userinfo_parse_failed` | Userinfo response was not valid JSON or missing expected fields. |
166 + | `user_upsert_failed` | Database error inserting/updating local user record. |
167 + | `internal_error` | Database error checking suspension status (fail-closed). |
168 + | `account_suspended` | User's local account has `suspended_at` set. Login blocked. |
169 +
170 + ## Logout
171 +
172 + `POST /auth/logout` -- flushes the entire session (removes all keys, deletes session row) and redirects to `/`.
173 +
174 + ## Key Paths
175 +
176 + - `src/auth.rs` -- PKCE helpers, session user, login/callback/logout handlers
177 + - `src/config.rs` -- `Config::from_env()`, all OAuth-related env vars
178 + - `src/csrf.rs` -- CSRF token generation, middleware, constant-time comparison
179 + - `src/internal_auth.rs` -- HMAC-SHA256 auth for MNW-to-MT internal API (separate from OAuth)
180 + - `src/main.rs` -- session store setup, session layer config, middleware stack
181 + - `deploy/env.hetzner` -- production env vars (hetzner)
182 + - `deploy/env.production` -- production env vars (astra)
M docs/todo.md +23 -4
@@ -1,10 +1,28 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: All pre-beta phases (0-11, 13-24). 249 tests (187 integration + 35 unit lib + 16 unit mt-core + 11 unit mt-db). v0.3.1. Audit grade: A (Run 10). Deployed to hetzner+astra (forums.makenot.work). All 21 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). Internal API live (INTERNAL_SHARED_SECRET configured on both MNW+MT, 2026-03-22). Default categories auto-provisioned (Items, Blog, Devlog, Discussion). Zero audit cold spots.
3 + Done: All pre-beta phases + S3 storage extraction (shared crate) + code review remediation. Active: None. Next: Post-beta platform integration below.
4 +
5 + v0.3.2. Audit grade A. Deployed to hetzner+astra (forums.makenot.work). MNW internal API live.
4 6
5 7 Completed work archived in `docs/archive/mt_todo_done.md`.
6 8
7 - No remaining pre-beta items. Only deferred post-beta items below.
9 + ---
10 +
11 + ## Code Review Remediation (2026-04-12)
12 +
13 + ### Done
14 + - [x] Fix dependency advisories: aws-lc-sys 0.39.1, rustls-webpki 0.103.11
15 + - [x] Fix `.form-inline-row` CSS: `display: inline` -> `display: inline-flex`
16 + - [x] Use pagination partial in `forum_directory.html`
17 + - [x] Add `Cache-Control: private, no-cache` header for HTML responses
18 + - [x] Split `routes/mod.rs` (578 lines) -> `mod.rs` (281) + `helpers.rs` (315)
19 + - [x] Split `forum/views.rs` (628 lines) -> `views.rs` (424) + `thread.rs` (224)
20 + - [x] Split `forum/actions.rs` (600 lines) -> `actions.rs` (172) + `posts.rs` (443)
21 +
22 + ### Remaining
23 + - [ ] Transitive dep advisories: rand 0.8/0.9 (RUSTSEC-2026-0097), rsa (RUSTSEC-2023-0071), lru (RUSTSEC-2026-0002) — no direct fix available, monitor upstream
24 + - [ ] Add partial index on `posts.removed_at` (`WHERE removed_at IS NOT NULL`) when data volume warrants it
25 + - [ ] Add `tracing::warn!` to `MaybeUser` extractor on session read errors (currently silently returns None)
8 26
9 27 ---
10 28
@@ -53,7 +71,8 @@ MT becomes the social backbone for MNW. Design doc: `docs/internal/strategy/plat
53 71 | Templates (HTML) | `templates/` |
54 72 | Link previews | `src/link_preview.rs` |
55 73 | S3 storage | `src/storage.rs` |
56 - | Routes | `src/routes/` (mod.rs, forum/{mod,views,actions}.rs, moderation.rs, settings.rs, admin.rs, flagging.rs, tracking.rs, search.rs, uploads.rs) |
74 + | Route helpers | `src/routes/helpers.rs` |
75 + | Routes | `src/routes/` (mod.rs, helpers.rs, forum/{mod,views,thread,posts,actions}.rs, moderation.rs, settings.rs, admin.rs, flagging.rs, tracking.rs, search.rs, uploads.rs) |
57 76 | Auth (OAuth) | `src/auth.rs` |
58 77 | CSRF | `src/csrf.rs` |
59 78 | Markdown | `docengine` crate (`Shared/docengine/`) — features: mentions, quotes |
@@ -61,7 +80,7 @@ MT becomes the social backbone for MNW. Design doc: `docs/internal/strategy/plat
61 80 | Seed data | `src/seed.rs` |
62 81 | Entry point | `src/main.rs` |
63 82 | Library root | `src/lib.rs` |
64 - | Migrations | `migrations/` (001-020) |
83 + | Migrations | `migrations/` (001-021) |
65 84 | CSS | `static/style.css` |
66 85 | Deploy config | `deploy/` |
67 86 | Integration tests | `tests/` |
@@ -103,6 +103,10 @@ async fn main() {
103 103 axum::http::header::X_FRAME_OPTIONS,
104 104 axum::http::HeaderValue::from_static("DENY"),
105 105 ))
106 + .layer(tower_http::set_header::SetResponseHeaderLayer::if_not_present(
107 + axum::http::header::CACHE_CONTROL,
108 + axum::http::HeaderValue::from_static("private, no-cache"),
109 + ))
106 110 // Internal API routes — HMAC auth only, no CSRF/session middleware
107 111 .merge(multithreaded::routes::internal::internal_routes(state))
108 112 .nest_service("/static", ServeDir::new("static"));
@@ -1,4 +1,4 @@
1 - //! Write handlers — thread creation, replies, footnotes, endorsements, thread management.
1 + //! Write handlers — footnotes and endorsements.
2 2
3 3 use axum::{
4 4 extract::Path,
@@ -6,333 +6,15 @@ use axum::{
6 6 response::{IntoResponse, Redirect, Response},
7 7 Form,
8 8 };
9 - use tower_sessions::Session;
10 - use uuid::Uuid;
11 -
12 - use sha2::{Sha256, Digest};
13 9
14 10 use crate::auth::MaybeUser;
15 - use crate::csrf;
16 - use crate::templates::*;
17 11 use crate::AppState;
18 12
19 - use mt_core::types::ModAction;
20 -
21 13 use super::super::{
22 - check_community_access, check_user_post_rate, check_write_access, get_community, get_role,
23 - get_thread, is_mod_or_owner, log_mod_action, parse_uuid, render_markdown,
24 - render_markdown_with_mentions, template_user, validate_body, validate_title,
25 - CreateReplyForm, CreateThreadForm, EditThreadForm, FootnoteForm,
14 + check_community_access, check_user_post_rate, check_write_access, get_community,
15 + parse_uuid, validate_body, FootnoteForm,
26 16 };
27 -
28 - // ============================================================================
29 - // Quote verification
30 - // ============================================================================
31 -
32 - const MAX_QUOTES_PER_POST: usize = 10;
33 - const MAX_FOOTNOTES_PER_POST: usize = 10;
34 -
35 - /// Extract `[quote:POST_ID:HASH]` markers from markdown body and verify each.
36 - /// Returns the IDs of quoted posts for attribution rendering.
37 - #[tracing::instrument(skip_all)]
38 - async fn verify_quotes(
39 - db: &sqlx::PgPool,
40 - body: &str,
41 - ) -> Result<Vec<Uuid>, Response> {
42 - static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
43 - regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
44 - });
45 -
46 - let match_count = QUOTE_RE.find_iter(body).count();
47 - if match_count > MAX_QUOTES_PER_POST {
48 - return Err((
49 - StatusCode::UNPROCESSABLE_ENTITY,
50 - "Too many quotes. Maximum is 10 per post.",
51 - )
52 - .into_response());
53 - }
54 -
55 - let mut quoted_post_ids = Vec::new();
56 -
57 - for caps in QUOTE_RE.captures_iter(body) {
58 - let post_id_str = &caps[1];
59 - let claimed_hash = &caps[2];
60 -
61 - let post_id = Uuid::parse_str(post_id_str)
62 - .map_err(|_| (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response())?;
63 -
64 - // Extract the quoted text: lines starting with `> ` immediately before the marker
65 - let marker = caps.get(0).unwrap();
66 - let before_marker = &body[..marker.start()];
67 - let quoted_lines: Vec<&str> = before_marker
68 - .lines()
69 - .rev()
70 - .take_while(|line| line.starts_with("> ") || line.starts_with('>'))
71 - .collect::<Vec<_>>()
72 - .into_iter()
73 - .rev()
74 - .collect();
75 -
76 - let quoted_text: String = quoted_lines
77 - .iter()
78 - .map(|line| line.strip_prefix("> ").unwrap_or(line.strip_prefix('>').unwrap_or(line)))
79 - .collect::<Vec<_>>()
80 - .join("\n")
81 - .trim()
82 - .to_string();
83 -
84 - if quoted_text.is_empty() {
85 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response());
86 - }
87 -
88 - // Fetch original post body
89 - let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id)
90 - .await
91 - .map_err(|e| {
92 - tracing::error!(error = ?e, "db error fetching post for quote verification");
93 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
94 - })?
95 - .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?;
96 -
97 - // Verify quoted text is a substring of the original
98 - if !original_markdown.contains(&quoted_text) {
99 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response());
100 - }
101 -
102 - // Verify hash
103 - let mut hasher = Sha256::new();
104 - hasher.update(quoted_text.as_bytes());
105 - let hash = hasher.finalize();
106 - let expected_hash = hex::encode(&hash[..4]);
107 -
108 - if claimed_hash != expected_hash {
109 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response());
110 - }
111 -
112 - quoted_post_ids.push(post_id);
113 - }
114 -
115 - Ok(quoted_post_ids)
116 - }
117 -
118 - // ============================================================================
119 - // Mention pipeline
120 - // ============================================================================
121 -
122 - /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids).
123 - /// `author_id` is excluded from the mention list (self-mention not stored).
124 - #[tracing::instrument(skip_all)]
125 - async fn resolve_and_render_mentions(
126 - db: &sqlx::PgPool,
127 - body: &str,
128 - community_id: Uuid,
129 - community_slug: &str,
130 - author_id: Uuid,
131 - ) -> Result<(String, Vec<Uuid>), Response> {
132 - let usernames = docengine::extract_mentions(body);
133 - if usernames.is_empty() {
134 - return Ok((render_markdown(body), Vec::new()));
135 - }
136 -
137 - let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames)
138 - .await
139 - .map_err(|e| {
140 - tracing::error!(error = ?e, "db error resolving mention usernames");
141 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
142 - })?;
143 -
144 - let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect();
145 - let body_html = render_markdown_with_mentions(body, community_slug, &valid_set);
146 -
147 - // Collect user IDs, excluding self
148 - let mention_ids: Vec<Uuid> = resolved
149 - .values()
150 - .copied()
151 - .filter(|uid| *uid != author_id)
152 - .collect();
153 -
154 - Ok((body_html, mention_ids))
155 - }
156 -
157 - // ============================================================================
158 - // Link preview pipeline
159 - // ============================================================================
160 -
161 - /// Extract URLs from post body, fetch OG metadata, and store previews.
162 - /// Best-effort: fetch failures are logged but never block post creation.
163 - #[tracing::instrument(skip_all)]
164 - async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) {
165 - let urls = crate::link_preview::extract_urls(body);
166 - for url in urls {
167 - match crate::link_preview::fetch_og_metadata(&state.preview_http, &url).await {
168 - Some((title, description)) => {
169 - if let Err(e) = mt_db::mutations::insert_link_preview(
170 - &state.db,
171 - post_id,
172 - &url,
173 - title.as_deref(),
174 - description.as_deref(),
175 - )
176 - .await
177 - {
178 - tracing::warn!(error = ?e, url = %url, "failed to insert link preview");
179 - }
180 - }
181 - None => {
182 - tracing::debug!(url = %url, "no OG metadata found");
183 - }
184 - }
185 - }
186 - }
187 -
188 - // ============================================================================
189 - // Thread + reply creation
190 - // ============================================================================
191 -
192 - #[tracing::instrument(skip_all)]
193 - pub(in crate::routes) async fn create_thread_handler(
194 - axum::extract::State(state): axum::extract::State<AppState>,
195 - Path((slug, category_slug)): Path<(String, String)>,
196 - MaybeUser(session_user): MaybeUser,
197 - Form(form): Form<CreateThreadForm>,
198 - ) -> Result<Redirect, Response> {
199 - let user = session_user
200 - .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
201 -
202 - let community = get_community(&state.db, &slug).await?;
203 -
204 - check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
205 - mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
206 - .await
207 - .map_err(|e| {
208 - tracing::error!(error = ?e, "db error ensuring membership");
209 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
210 - })?;
211 - check_user_post_rate(&state.db, user.user_id).await?;
212 -
213 - let title = validate_title(&form.title)?;
214 - let body = validate_body(&form.body, 65536, "Body")?;
215 -
216 - let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug)
217 - .await
218 - .map_err(|e| {
219 - tracing::error!(error = ?e, "db error looking up category");
220 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
221 - })?
222 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
223 -
224 - verify_quotes(&state.db, body).await?;
225 -
226 - let (body_html, mention_ids) = resolve_and_render_mentions(
227 - &state.db, body, community.id, &slug, user.user_id,
228 - ).await?;
229 -
230 - let thread_id = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title)
231 - .await
232 - .map_err(|e| {
233 - tracing::error!(error = ?e, "db error creating thread");
234 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
235 - })?;
236 -
237 - let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
238 - .await
239 - .map_err(|e| {
240 - tracing::error!(error = ?e, "db error creating post");
241 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
242 - })?;
243 -
244 - if !mention_ids.is_empty() {
245 - mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
246 - .await
247 - .map_err(|e| {
248 - tracing::error!(error = ?e, "db error inserting mentions");
249 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
250 - })?;
251 - }
252 -
253 - // Fetch link previews (best-effort, failures don't block post creation)
254 - fetch_and_store_link_previews(&state, body, post_id).await;
255 -
256 - // Save tags if any were selected
257 - if !form.tags.is_empty() {
258 - let tag_ids: Vec<uuid::Uuid> = form
259 - .tags
260 - .iter()
261 - .filter_map(|t| uuid::Uuid::parse_str(t).ok())
262 - .collect();
263 - if !tag_ids.is_empty() {
264 - mt_db::mutations::set_thread_tags(&state.db, thread_id, &tag_ids)
265 - .await
266 - .map_err(|e| {
267 - tracing::error!(error = ?e, "db error setting thread tags");
268 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
269 - })?;
270 - }
271 - }
272 -
273 - Ok(Redirect::to(&format!(
274 - "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created"
275 - )))
276 - }
277 -
278 - #[tracing::instrument(skip_all)]
279 - pub(in crate::routes) async fn create_reply_handler(
280 - axum::extract::State(state): axum::extract::State<AppState>,
281 - Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
282 - MaybeUser(session_user): MaybeUser,
283 - Form(form): Form<CreateReplyForm>,
284 - ) -> Result<Redirect, Response> {
285 - let user = session_user
286 - .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
287 -
288 - let thread_data = get_thread(&state.db, &thread_id_str).await?;
289 - let community = get_community(&state.db, &slug).await?;
290 -
291 - check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
292 - mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
293 - .await
294 - .map_err(|e| {
295 - tracing::error!(error = ?e, "db error ensuring membership");
296 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
297 - })?;
298 - check_user_post_rate(&state.db, user.user_id).await?;
299 -
300 - if thread_data.locked {
301 - return Err((StatusCode::FORBIDDEN, "This thread is locked.").into_response());
302 - }
303 -
304 - let body = validate_body(&form.body, 65536, "Body")?;
305 -
306 - verify_quotes(&state.db, body).await?;
307 -
308 - let (body_html, mention_ids) = resolve_and_render_mentions(
309 - &state.db, body, community.id, &slug, user.user_id,
310 - ).await?;
311 -
312 - let thread_id = parse_uuid(&thread_id_str)?;
313 - let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
314 - .await
315 - .map_err(|e| {
316 - tracing::error!(error = ?e, "db error creating reply");
317 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
318 - })?;
319 -
320 - if !mention_ids.is_empty() {
321 - mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
322 - .await
323 - .map_err(|e| {
324 - tracing::error!(error = ?e, "db error inserting mentions");
325 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
326 - })?;
327 - }
328 -
329 - // Fetch link previews (best-effort)
330 - fetch_and_store_link_previews(&state, body, post_id).await;
331 -
332 - Ok(Redirect::to(&format!(
333 - "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted"
334 - )))
335 - }
17 + use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST};
336 18
337 19 // ============================================================================
338 20 // Footnote handler
@@ -488,113 +170,3 @@ pub(in crate::routes) async fn toggle_endorsement_handler(
488 170 "/p/{slug}/{category_slug}/{thread_id_str}#post-{post_id_str}"
489 171 )))
490 172 }
491 -
492 - // ============================================================================
493 - // Thread edit/delete handlers (mod/owner only)
494 - // ============================================================================
495 -
496 - #[tracing::instrument(skip_all)]
497 - pub(in crate::routes) async fn edit_thread_form(
498 - axum::extract::State(state): axum::extract::State<AppState>,
499 - Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
500 - session: Session,
501 - MaybeUser(session_user): MaybeUser,
502 - ) -> Result<impl IntoResponse, Response> {
503 - let csrf_token = Some(csrf::get_or_create_token(&session).await);
504 - let user = session_user
505 - .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
506 -
507 - let thread_data = get_thread(&state.db, &thread_id_str).await?;
508 - let community = get_community(&state.db, &slug).await?;
509 -
510 - check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
511 -
512 - let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
513 - if !is_mod_or_owner(&role) {
514 - return Err(StatusCode::FORBIDDEN.into_response());
515 - }
516 -
517 - Ok(EditThreadTemplate {
518 - csrf_token,
519 - session_user: Some(template_user(&user, state.config.platform_admin_id)),
520 - mnw_base_url: state.config.mnw_base_url.clone(),
521 - community_name: thread_data.community_name,
522 - community_slug: slug,
523 - category_name: thread_data.category_name,
524 - category_slug,
525 - thread_id: thread_id_str,
526 - current_title: thread_data.title,
527 - })
528 - }
529 -
530 - #[tracing::instrument(skip_all)]
531 - pub(in crate::routes) async fn edit_thread_handler(
532 - axum::extract::State(state): axum::extract::State<AppState>,
533 - Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
534 - MaybeUser(session_user): MaybeUser,
535 - Form(form): Form<EditThreadForm>,
536 - ) -> Result<Redirect, Response> {
537 - let user = session_user
538 - .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
539 -
540 - let thread_data = get_thread(&state.db, &thread_id_str).await?;
541 - let community = get_community(&state.db, &slug).await?;
542 -
543 - check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
544 -
545 - let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
546 - if !is_mod_or_owner(&role) {
547 - return Err(StatusCode::FORBIDDEN.into_response());
548 - }
549 -
550 - let title = validate_title(&form.title)?;
551 -
552 - let thread_id = parse_uuid(&thread_id_str)?;
553 - mt_db::mutations::update_thread_title(&state.db, thread_id, title)
554 - .await
555 - .map_err(|e| {
556 - tracing::error!(error = ?e, "db error updating thread title");
557 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
558 - })?;
559 -
560 - Ok(Redirect::to(&format!(
561 - "/p/{slug}/{category_slug}/{thread_id_str}?toast=Title+updated"
562 - )))
563 - }
564 -
565 - #[tracing::instrument(skip_all)]
566 - pub(in crate::routes) async fn delete_thread_handler(
567 - axum::extract::State(state): axum::extract::State<AppState>,
568 - Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
569 - MaybeUser(session_user): MaybeUser,
570 - ) -> Result<Redirect, Response> {
571 - let user = session_user
572 - .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
573 -
574 - let thread_data = get_thread(&state.db, &thread_id_str).await?;
575 - let community = get_community(&state.db, &slug).await?;
576 -
577 - check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
578 -
579 - let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
580 - if !is_mod_or_owner(&role) {
581 - return Err(StatusCode::FORBIDDEN.into_response());
582 - }
583 -
584 - let thread_id = parse_uuid(&thread_id_str)?;
585 - mt_db::mutations::soft_delete_thread(&state.db, thread_id)
586 - .await
587 - .map_err(|e| {
588 - tracing::error!(error = ?e, "db error deleting thread");
589 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
590 - })?;
591 -
592 - log_mod_action(
593 - &state.db, Some(thread_data.community_id), user.user_id,
594 - ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None,
595 - ).await;
596 -
597 - Ok(Redirect::to(&format!(
598 - "/p/{slug}/{category_slug}?toast=Thread+deleted"
599 - )))
600 - }
@@ -1,7 +1,14 @@
1 1 //! Forum content handlers — directory, communities, categories, threads, posts.
2 2
3 3 mod actions;
4 + mod posts;
5 + mod thread;
4 6 mod views;
5 7
6 8 pub(in crate::routes) use actions::*;
9 + pub(in crate::routes) use posts::{
10 + create_thread_handler, create_reply_handler,
11 + edit_thread_form, edit_thread_handler, delete_thread_handler,
12 + };
13 + pub(in crate::routes) use thread::thread;
7 14 pub(in crate::routes) use views::*;
@@ -0,0 +1,443 @@
1 + //! Post creation handlers — thread creation, replies, and supporting pipelines
2 + //! (quote verification, mention resolution, link preview fetching).
3 +
4 + use axum::{
5 + extract::Path,
6 + http::StatusCode,
7 + response::{IntoResponse, Redirect, Response},
8 + Form,
9 + };
10 + use uuid::Uuid;
11 +
12 + use sha2::{Sha256, Digest};
13 +
14 + use crate::auth::MaybeUser;
15 + use crate::AppState;
16 +
17 + use mt_core::types::ModAction;
18 +
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,
23 + CreateReplyForm, CreateThreadForm,
24 + };
25 +
26 + // ============================================================================
27 + // Quote verification
28 + // ============================================================================
29 +
30 + pub(super) const MAX_QUOTES_PER_POST: usize = 10;
31 + pub(super) const MAX_FOOTNOTES_PER_POST: usize = 10;
32 +
33 + /// Extract `[quote:POST_ID:HASH]` markers from markdown body and verify each.
34 + /// Returns the IDs of quoted posts for attribution rendering.
35 + #[tracing::instrument(skip_all)]
36 + pub(super) async fn verify_quotes(
37 + db: &sqlx::PgPool,
38 + body: &str,
39 + ) -> Result<Vec<Uuid>, Response> {
40 + static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
41 + regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
42 + });
43 +
44 + let match_count = QUOTE_RE.find_iter(body).count();
45 + if match_count > MAX_QUOTES_PER_POST {
46 + return Err((
47 + StatusCode::UNPROCESSABLE_ENTITY,
48 + "Too many quotes. Maximum is 10 per post.",
49 + )
50 + .into_response());
51 + }
52 +
53 + let mut quoted_post_ids = Vec::new();
54 +
55 + for caps in QUOTE_RE.captures_iter(body) {
56 + let post_id_str = &caps[1];
57 + let claimed_hash = &caps[2];
58 +
59 + let post_id = Uuid::parse_str(post_id_str)
60 + .map_err(|_| (StatusCode::UNPROCESSABLE_ENTITY, "Invalid quote reference.").into_response())?;
61 +
62 + // Extract the quoted text: lines starting with `> ` immediately before the marker
63 + let marker = caps.get(0).unwrap();
64 + let before_marker = &body[..marker.start()];
65 + let quoted_lines: Vec<&str> = before_marker
66 + .lines()
67 + .rev()
68 + .take_while(|line| line.starts_with("> ") || line.starts_with('>'))
69 + .collect::<Vec<_>>()
70 + .into_iter()
71 + .rev()
72 + .collect();
73 +
74 + let quoted_text: String = quoted_lines
75 + .iter()
76 + .map(|line| line.strip_prefix("> ").unwrap_or(line.strip_prefix('>').unwrap_or(line)))
77 + .collect::<Vec<_>>()
78 + .join("\n")
79 + .trim()
80 + .to_string();
81 +
82 + if quoted_text.is_empty() {
83 + return Err((StatusCode::UNPROCESSABLE_ENTITY, "Empty quote text.").into_response());
84 + }
85 +
86 + // Fetch original post body
87 + let (_, original_markdown) = mt_db::queries::get_post_body_markdown(db, post_id)
88 + .await
89 + .map_err(|e| {
90 + tracing::error!(error = ?e, "db error fetching post for quote verification");
91 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
92 + })?
93 + .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "Quoted post not found.").into_response())?;
94 +
95 + // Verify quoted text is a substring of the original
96 + if !original_markdown.contains(&quoted_text) {
97 + return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response());
98 + }
99 +
100 + // Verify hash
101 + let mut hasher = Sha256::new();
102 + hasher.update(quoted_text.as_bytes());
103 + let hash = hasher.finalize();
104 + let expected_hash = hex::encode(&hash[..4]);
105 +
106 + if claimed_hash != expected_hash {
107 + return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response());
108 + }
109 +
110 + quoted_post_ids.push(post_id);
111 + }
112 +
113 + Ok(quoted_post_ids)
114 + }
115 +
116 + // ============================================================================
117 + // Mention pipeline
118 + // ============================================================================
119 +
120 + /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids).
121 + /// `author_id` is excluded from the mention list (self-mention not stored).
122 + #[tracing::instrument(skip_all)]
123 + pub(super) async fn resolve_and_render_mentions(
124 + db: &sqlx::PgPool,
125 + body: &str,
126 + community_id: Uuid,
127 + community_slug: &str,
128 + author_id: Uuid,
129 + ) -> Result<(String, Vec<Uuid>), Response> {
130 + let usernames = docengine::extract_mentions(body);
131 + if usernames.is_empty() {
132 + return Ok((render_markdown(body), Vec::new()));
133 + }
134 +
135 + let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames)
136 + .await
137 + .map_err(|e| {
138 + tracing::error!(error = ?e, "db error resolving mention usernames");
139 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
140 + })?;
141 +
142 + let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect();
143 + let body_html = render_markdown_with_mentions(body, community_slug, &valid_set);
144 +
145 + // Collect user IDs, excluding self
146 + let mention_ids: Vec<Uuid> = resolved
147 + .values()
148 + .copied()
149 + .filter(|uid| *uid != author_id)
150 + .collect();
151 +
152 + Ok((body_html, mention_ids))
153 + }
154 +
155 + // ============================================================================
156 + // Link preview pipeline
157 + // ============================================================================
158 +
159 + /// Extract URLs from post body, fetch OG metadata, and store previews.
160 + /// Best-effort: fetch failures are logged but never block post creation.
161 + #[tracing::instrument(skip_all)]
162 + pub(super) async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) {
163 + let urls = crate::link_preview::extract_urls(body);
164 + for url in urls {
165 + match crate::link_preview::fetch_og_metadata(&state.preview_http, &url).await {
166 + Some((title, description)) => {
167 + if let Err(e) = mt_db::mutations::insert_link_preview(
168 + &state.db,
169 + post_id,
170 + &url,
171 + title.as_deref(),
172 + description.as_deref(),
173 + )
174 + .await
175 + {
176 + tracing::warn!(error = ?e, url = %url, "failed to insert link preview");
177 + }
178 + }
179 + None => {
180 + tracing::debug!(url = %url, "no OG metadata found");
181 + }
182 + }
183 + }
184 + }
185 +
186 + // ============================================================================
187 + // Thread + reply creation
188 + // ============================================================================
189 +
190 + #[tracing::instrument(skip_all)]
191 + pub(in crate::routes) async fn create_thread_handler(
192 + axum::extract::State(state): axum::extract::State<AppState>,
193 + Path((slug, category_slug)): Path<(String, String)>,
194 + MaybeUser(session_user): MaybeUser,
195 + Form(form): Form<CreateThreadForm>,
196 + ) -> Result<Redirect, Response> {
197 + let user = session_user
198 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
199 +
200 + let community = get_community(&state.db, &slug).await?;
201 +
202 + check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
203 + mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
204 + .await
205 + .map_err(|e| {
206 + tracing::error!(error = ?e, "db error ensuring membership");
207 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
208 + })?;
209 + check_user_post_rate(&state.db, user.user_id).await?;
210 +
211 + let title = validate_title(&form.title)?;
212 + let body = validate_body(&form.body, 65536, "Body")?;
213 +
214 + let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug)
215 + .await
216 + .map_err(|e| {
217 + tracing::error!(error = ?e, "db error looking up category");
218 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
219 + })?
220 + .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
221 +
222 + verify_quotes(&state.db, body).await?;
223 +
224 + let (body_html, mention_ids) = resolve_and_render_mentions(
225 + &state.db, body, community.id, &slug, user.user_id,
226 + ).await?;
227 +
228 + let thread_id = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title)
229 + .await
230 + .map_err(|e| {
231 + tracing::error!(error = ?e, "db error creating thread");
232 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
233 + })?;
234 +
235 + let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
236 + .await
237 + .map_err(|e| {
238 + tracing::error!(error = ?e, "db error creating post");
239 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
240 + })?;
241 +
242 + if !mention_ids.is_empty() {
243 + mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
244 + .await
245 + .map_err(|e| {
246 + tracing::error!(error = ?e, "db error inserting mentions");
247 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
248 + })?;
249 + }
250 +
251 + // Fetch link previews (best-effort, failures don't block post creation)
252 + fetch_and_store_link_previews(&state, body, post_id).await;
253 +
254 + // Save tags if any were selected
255 + if !form.tags.is_empty() {
256 + let tag_ids: Vec<uuid::Uuid> = form
257 + .tags
258 + .iter()
259 + .filter_map(|t| uuid::Uuid::parse_str(t).ok())
260 + .collect();
261 + if !tag_ids.is_empty() {
262 + mt_db::mutations::set_thread_tags(&state.db, thread_id, &tag_ids)
263 + .await
264 + .map_err(|e| {
265 + tracing::error!(error = ?e, "db error setting thread tags");
266 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
267 + })?;
268 + }
269 + }
270 +
271 + Ok(Redirect::to(&format!(
272 + "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created"
273 + )))
274 + }
275 +
276 + #[tracing::instrument(skip_all)]
277 + pub(in crate::routes) async fn create_reply_handler(
278 + axum::extract::State(state): axum::extract::State<AppState>,
279 + Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
280 + MaybeUser(session_user): MaybeUser,
281 + Form(form): Form<CreateReplyForm>,
282 + ) -> Result<Redirect, Response> {
283 + let user = session_user
284 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
285 +
286 + let thread_data = get_thread(&state.db, &thread_id_str).await?;
287 + let community = get_community(&state.db, &slug).await?;
288 +
289 + check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
290 + mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
291 + .await
292 + .map_err(|e| {
293 + tracing::error!(error = ?e, "db error ensuring membership");
294 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
295 + })?;
296 + check_user_post_rate(&state.db, user.user_id).await?;
297 +
298 + if thread_data.locked {
299 + return Err((StatusCode::FORBIDDEN, "This thread is locked.").into_response());
300 + }
301 +
302 + let body = validate_body(&form.body, 65536, "Body")?;
303 +
304 + verify_quotes(&state.db, body).await?;
305 +
306 + let (body_html, mention_ids) = resolve_and_render_mentions(
307 + &state.db, body, community.id, &slug, user.user_id,
308 + ).await?;
309 +
310 + let thread_id = parse_uuid(&thread_id_str)?;
311 + let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
312 + .await
313 + .map_err(|e| {
314 + tracing::error!(error = ?e, "db error creating reply");
315 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
316 + })?;
317 +
318 + if !mention_ids.is_empty() {
319 + mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
320 + .await
321 + .map_err(|e| {
322 + tracing::error!(error = ?e, "db error inserting mentions");
323 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
324 + })?;
325 + }
326 +
327 + // Fetch link previews (best-effort)
328 + fetch_and_store_link_previews(&state, body, post_id).await;
329 +
330 + Ok(Redirect::to(&format!(
331 + "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted"
332 + )))
333 + }
334 +
335 + // ============================================================================
336 + // Thread edit/delete handlers (mod/owner only)
337 + // ============================================================================
338 +
339 + #[tracing::instrument(skip_all)]
340 + pub(in crate::routes) async fn edit_thread_form(
341 + axum::extract::State(state): axum::extract::State<AppState>,
342 + Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
343 + session: tower_sessions::Session,
344 + MaybeUser(session_user): MaybeUser,
345 + ) -> Result<impl IntoResponse, Response> {
346 + let csrf_token = Some(crate::csrf::get_or_create_token(&session).await);
347 + let user = session_user
348 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
349 +
350 + let thread_data = get_thread(&state.db, &thread_id_str).await?;
351 + let community = get_community(&state.db, &slug).await?;
352 +
353 + check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
354 +
355 + let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
356 + if !is_mod_or_owner(&role) {
357 + return Err(StatusCode::FORBIDDEN.into_response());
358 + }
359 +
360 + Ok(crate::templates::EditThreadTemplate {
361 + csrf_token,
362 + session_user: Some(template_user(&user, state.config.platform_admin_id)),
363 + mnw_base_url: state.config.mnw_base_url.clone(),
364 + community_name: thread_data.community_name,
365 + community_slug: slug,
366 + category_name: thread_data.category_name,
367 + category_slug,
368 + thread_id: thread_id_str,
369 + current_title: thread_data.title,
370 + })
371 + }
372 +
373 + #[tracing::instrument(skip_all)]
374 + pub(in crate::routes) async fn edit_thread_handler(
375 + axum::extract::State(state): axum::extract::State<AppState>,
376 + Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
377 + MaybeUser(session_user): MaybeUser,
378 + Form(form): Form<super::super::EditThreadForm>,
379 + ) -> Result<Redirect, Response> {
380 + let user = session_user
381 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
382 +
383 + let thread_data = get_thread(&state.db, &thread_id_str).await?;
384 + let community = get_community(&state.db, &slug).await?;
385 +
386 + check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
387 +
388 + let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
389 + if !is_mod_or_owner(&role) {
390 + return Err(StatusCode::FORBIDDEN.into_response());
391 + }
392 +
393 + let title = validate_title(&form.title)?;
394 +
395 + let thread_id = parse_uuid(&thread_id_str)?;
396 + mt_db::mutations::update_thread_title(&state.db, thread_id, title)
397 + .await
398 + .map_err(|e| {
399 + tracing::error!(error = ?e, "db error updating thread title");
400 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
401 + })?;
402 +
403 + Ok(Redirect::to(&format!(
404 + "/p/{slug}/{category_slug}/{thread_id_str}?toast=Title+updated"
405 + )))
406 + }
407 +
408 + #[tracing::instrument(skip_all)]
409 + pub(in crate::routes) async fn delete_thread_handler(
410 + axum::extract::State(state): axum::extract::State<AppState>,
411 + Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
412 + MaybeUser(session_user): MaybeUser,
413 + ) -> Result<Redirect, Response> {
414 + let user = session_user
415 + .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
416 +
417 + let thread_data = get_thread(&state.db, &thread_id_str).await?;
418 + let community = get_community(&state.db, &slug).await?;
419 +
420 + check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
421 +
422 + let role = get_role(&state.db, user.user_id, thread_data.community_id).await?;
423 + if !is_mod_or_owner(&role) {
424 + return Err(StatusCode::FORBIDDEN.into_response());
425 + }
426 +
427 + let thread_id = parse_uuid(&thread_id_str)?;
428 + mt_db::mutations::soft_delete_thread(&state.db, thread_id)
429 + .await
430 + .map_err(|e| {
431 + tracing::error!(error = ?e, "db error deleting thread");
432 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
433 + })?;
434 +
435 + log_mod_action(
436 + &state.db, Some(thread_data.community_id), user.user_id,
437 + ModAction::DeleteThread, Some(thread_data.author_id), Some(thread_id), None,
438 + ).await;
439 +
440 + Ok(Redirect::to(&format!(
441 + "/p/{slug}/{category_slug}?toast=Thread+deleted"
442 + )))
443 + }
@@ -0,0 +1,224 @@
1 + //! Thread view handler — post listing with footnotes, endorsements, link previews, tracking.
2 +
3 + use axum::{
4 + extract::{Path, Query},
5 + http::StatusCode,
6 + response::{IntoResponse, Response},
7 + };
8 + use tower_sessions::Session;
9 +
10 + use crate::auth::MaybeUser;
11 + use crate::csrf;
12 + use crate::templates::*;
13 + use crate::AppState;
14 +
15 + use std::collections::HashMap;
16 +
17 + use super::super::{
18 + check_community_access, get_community, get_role, get_thread, is_mod_or_owner,
19 + parse_uuid, template_user, PageQuery,
20 + };
21 +
22 + #[tracing::instrument(skip_all)]
23 + pub(in crate::routes) async fn thread(
24 + axum::extract::State(state): axum::extract::State<AppState>,
25 + Path((slug, _category, thread_id)): Path<(String, String, String)>,
26 + Query(page_query): Query<PageQuery>,
27 + session: Session,
28 + MaybeUser(session_user): MaybeUser,
29 + ) -> Result<impl IntoResponse, Response> {
30 + let csrf_token = Some(csrf::get_or_create_token(&session).await);
31 +
32 + let thread_data = get_thread(&state.db, &thread_id).await?;
33 + let community = get_community(&state.db, &slug).await?;
34 +
35 + check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
36 +
37 + let per_page: i64 = 50;
38 +
39 + let thread_uuid = parse_uuid(&thread_id)?;
40 + let total = mt_db::queries::count_posts_in_thread(&state.db, thread_uuid)
41 + .await
42 + .map_err(|e| {
43 + tracing::error!(error = ?e, "db error counting posts");
44 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
45 + })?;
46 +
47 + let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
48 + let total_pages = total_pages.max(1);
49 + let page = page_query.page.unwrap_or(1).max(1).min(total_pages);
50 + let offset = (page as i64 - 1) * per_page;
51 +
52 + let db_posts = mt_db::queries::list_posts_in_thread_paginated(
53 + &state.db, thread_uuid, per_page, offset,
54 + )
55 + .await
56 + .map_err(|e| {
57 + tracing::error!(error = ?e, "db error listing posts");
58 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
59 + })?;
60 +
61 + // Look up user's role in this community (if logged in)
62 + let role = if let Some(ref user) = session_user {
63 + get_role(&state.db, user.user_id, thread_data.community_id).await?
64 + } else {
65 + None
66 + };
67 +
68 + let mod_status = is_mod_or_owner(&role);
69 +
70 + // Check tracking status and update read position
71 + let is_tracked = if let Some(ref user) = session_user {
72 + mt_db::queries::is_thread_tracked(&state.db, user.user_id, thread_uuid)
73 + .await
74 + .unwrap_or(false)
75 + } else {
76 + false
77 + };
78 +
79 + // Batch-fetch footnotes and endorsements for all posts on this page
80 + let post_ids: Vec<uuid::Uuid> = db_posts.iter().map(|p| p.id).collect();
81 + let all_footnotes = mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids)
82 + .await
83 + .map_err(|e| {
84 + tracing::error!(error = ?e, "db error fetching footnotes");
85 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
86 + })?;
87 +
88 + let all_endorsements = mt_db::queries::list_endorsements_for_posts(&state.db, &post_ids)
89 + .await
90 + .map_err(|e| {
91 + tracing::error!(error = ?e, "db error fetching endorsements");
92 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
93 + })?;
94 +
95 + // Group endorsements: counts per post + set of posts current user endorsed
96 + let mut endorsement_counts: HashMap<String, u32> = HashMap::new();
97 + let mut user_endorsed: std::collections::HashSet<String> = std::collections::HashSet::new();
98 + for e in &all_endorsements {
99 + *endorsement_counts.entry(e.post_id.to_string()).or_insert(0) += 1;
100 + if session_user.as_ref().is_some_and(|u| u.user_id == e.endorser_id) {
101 + user_endorsed.insert(e.post_id.to_string());
102 + }
103 + }
104 +
105 + // Group footnotes by post_id
106 + let mut footnotes_by_post: HashMap<String, Vec<FootnoteViewRow>> = HashMap::new();
107 + for f in all_footnotes {
108 + footnotes_by_post
109 + .entry(f.post_id.to_string())
110 + .or_default()
111 + .push(FootnoteViewRow {
112 + author_name: f.author_name,
113 + body_html: f.body_html,
114 + timestamp: mt_core::time_format::relative_timestamp(f.created_at),
115 + });
116 + }
117 +
118 + // Batch-fetch link previews
119 + let all_link_previews = mt_db::queries::list_link_previews_for_posts(&state.db, &post_ids)
120 + .await
121 + .map_err(|e| {
122 + tracing::error!(error = ?e, "db error fetching link previews");
123 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
124 + })?;
125 +
126 + let mut link_previews_by_post: HashMap<String, Vec<LinkPreviewViewRow>> = HashMap::new();
127 + for lp in all_link_previews {
128 + link_previews_by_post
129 + .entry(lp.post_id.to_string())
130 + .or_default()
131 + .push(LinkPreviewViewRow {
132 + url: lp.url,
133 + title: lp.title,
134 + description: lp.description,
135 + });
136 + }
137 +
138 + // Build quote author map for attribution rendering
139 + let mut quote_authors: HashMap<uuid::Uuid, docengine::QuoteAuthor> = HashMap::new();
140 + for p in &db_posts {
141 + quote_authors.insert(p.id, docengine::QuoteAuthor {
142 + username: p.author_username.clone(),
143 + display_name: p.author_name.clone(),
144 + is_removed: p.removed_at.is_some(),
145 + });
146 + }
147 +
148 + let posts: Vec<PostRow> = db_posts
149 + .into_iter()
150 + .enumerate()
151 + .map(|(i, p)| {
152 + let is_removed = p.removed_at.is_some();
153 + let can_add_footnote = !is_removed
154 + && session_user.as_ref().is_some_and(|u| u.user_id == p.author_id);
155 + let can_remove = !is_removed && mod_status;
156 +
157 + let body_html = if is_removed {
158 + String::from("<p><em>[removed by moderator]</em></p>")
159 + } else {
160 + docengine::post_process_quotes(&p.body_html, &quote_authors)
161 + };
162 +
163 + let post_id_str = p.id.to_string();
164 + let footnotes = footnotes_by_post.remove(&post_id_str).unwrap_or_default();
165 + let link_previews = link_previews_by_post.remove(&post_id_str).unwrap_or_default();
166 + let endorsement_count = endorsement_counts.get(&post_id_str).copied().unwrap_or(0);
167 + let is_endorsed = user_endorsed.contains(&post_id_str);
168 + let can_endorse = !is_removed
169 + && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
170 + let can_flag = !is_removed
171 + && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
172 +
173 + PostRow {
174 + id: post_id_str,
175 + author_name: p.author_name,
176 + author_username: p.author_username,
177 + timestamp: mt_core::time_format::post_timestamp(p.created_at),
178 + body_html,
179 + is_op: i == 0 && offset == 0,
180 + is_removed,
181 + can_add_footnote,
182 + can_remove,
183 + can_flag,
184 + footnotes,
185 + link_previews,
186 + endorsement_count,
187 + is_endorsed,
188 + can_endorse,
189 + }
190 + })
191 + .collect();
192 +
193 + // If tracked, update read position to the last post on the current page
194 + if is_tracked
195 + && let Some(ref user) = session_user
196 + && let Some(last_post) = posts.last()
197 + && let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id)
198 + {
199 + let _ = mt_db::mutations::update_read_position(
200 + &state.db, user.user_id, thread_uuid, last_post_id,
201 + ).await;
202 + }
203 +
204 + let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
205 +
206 + Ok(ThreadTemplate {
207 + csrf_token,
208 + session_user,
209 + mnw_base_url: state.config.mnw_base_url.clone(),
210 + community_name: thread_data.community_name,
211 + community_slug: thread_data.community_slug,
212 + category_name: thread_data.category_name,
213 + category_slug: thread_data.category_slug,
214 + thread_id: thread_data.id.to_string(),
215 + thread_title: thread_data.title,
216 + locked: thread_data.locked,
217 + pinned: thread_data.pinned,
218 + is_mod: mod_status,
219 + can_mod_thread: mod_status,
220 + is_tracked,
221 + posts,
222 + pagination: Pagination::new(page, total, per_page),
223 + })
224 + }
@@ -1,4 +1,4 @@
1 - //! Read handlers — forum directory, community pages, category listings, thread view, user profiles.
1 + //! Read handlers — forum directory, community pages, category listings, user profiles.
2 2
3 3 use axum::{
4 4 extract::{Path, Query},
@@ -18,7 +18,7 @@ use std::collections::HashMap;
18 18 use mt_core::types::{SortColumn, SortOrder};
19 19
20 20 use super::super::{
21 - check_community_access, get_community, get_role, get_thread, is_mod_or_owner, is_owner,
21 + check_community_access, get_community, get_role, is_mod_or_owner, is_owner,
22 22 parse_uuid, template_user, CategoryQuery, PageQuery,
23 23 };
24 24
@@ -291,210 +291,6 @@ pub(in crate::routes) async fn category(
291 291 }
292 292
293 293 #[tracing::instrument(skip_all)]
294 - pub(in crate::routes) async fn thread(
295 - axum::extract::State(state): axum::extract::State<AppState>,
296 - Path((slug, _category, thread_id)): Path<(String, String, String)>,
297 - Query(page_query): Query<PageQuery>,
298 - session: Session,
299 - MaybeUser(session_user): MaybeUser,
300 - ) -> Result<impl IntoResponse, Response> {
301 - let csrf_token = Some(csrf::get_or_create_token(&session).await);
302 -
303 - let thread_data = get_thread(&state.db, &thread_id).await?;
304 - let community = get_community(&state.db, &slug).await?;
305 -
306 - check_community_access(&state.db, &community, session_user.as_ref().map(|u| u.user_id)).await?;
307 -
308 - let per_page: i64 = 50;
309 -
310 - let thread_uuid = parse_uuid(&thread_id)?;
311 - let total = mt_db::queries::count_posts_in_thread(&state.db, thread_uuid)
312 - .await
313 - .map_err(|e| {
314 - tracing::error!(error = ?e, "db error counting posts");
315 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
316 - })?;
317 -
318 - let total_pages = ((total as f64) / (per_page as f64)).ceil() as u32;
319 - let total_pages = total_pages.max(1);
320 - let page = page_query.page.unwrap_or(1).max(1).min(total_pages);
321 - let offset = (page as i64 - 1) * per_page;
322 -
323 - let db_posts = mt_db::queries::list_posts_in_thread_paginated(
324 - &state.db, thread_uuid, per_page, offset,
325 - )
326 - .await
327 - .map_err(|e| {
328 - tracing::error!(error = ?e, "db error listing posts");
329 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
330 - })?;
331 -
332 - // Look up user's role in this community (if logged in)
333 - let role = if let Some(ref user) = session_user {
334 - get_role(&state.db, user.user_id, thread_data.community_id).await?
335 - } else {
336 - None
337 - };
338 -
339 - let mod_status = is_mod_or_owner(&role);
340 -
341 - // Check tracking status and update read position
342 - let is_tracked = if let Some(ref user) = session_user {
343 - mt_db::queries::is_thread_tracked(&state.db, user.user_id, thread_uuid)
344 - .await
345 - .unwrap_or(false)
346 - } else {
347 - false
348 - };
349 -
350 - // Batch-fetch footnotes and endorsements for all posts on this page
351 - let post_ids: Vec<uuid::Uuid> = db_posts.iter().map(|p| p.id).collect();
352 - let all_footnotes = mt_db::queries::list_footnotes_for_posts(&state.db, &post_ids)
353 - .await
354 - .map_err(|e| {
355 - tracing::error!(error = ?e, "db error fetching footnotes");
356 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
357 - })?;
358 -
359 - let all_endorsements = mt_db::queries::list_endorsements_for_posts(&state.db, &post_ids)
360 - .await
361 - .map_err(|e| {
362 - tracing::error!(error = ?e, "db error fetching endorsements");
363 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
364 - })?;
365 -
366 - // Group endorsements: counts per post + set of posts current user endorsed
367 - let mut endorsement_counts: HashMap<String, u32> = HashMap::new();
368 - let mut user_endorsed: std::collections::HashSet<String> = std::collections::HashSet::new();
369 - for e in &all_endorsements {
370 - *endorsement_counts.entry(e.post_id.to_string()).or_insert(0) += 1;
371 - if session_user.as_ref().is_some_and(|u| u.user_id == e.endorser_id) {
372 - user_endorsed.insert(e.post_id.to_string());
373 - }
374 - }
375 -
376 - // Group footnotes by post_id
377 - let mut footnotes_by_post: HashMap<String, Vec<FootnoteViewRow>> = HashMap::new();
378 - for f in all_footnotes {
379 - footnotes_by_post
380 - .entry(f.post_id.to_string())
381 - .or_default()
382 - .push(FootnoteViewRow {
383 - author_name: f.author_name,
384 - body_html: f.body_html,
385 - timestamp: mt_core::time_format::relative_timestamp(f.created_at),
386 - });
387 - }
388 -
389 - // Batch-fetch link previews
390 - let all_link_previews = mt_db::queries::list_link_previews_for_posts(&state.db, &post_ids)
391 - .await
392 - .map_err(|e| {
393 - tracing::error!(error = ?e, "db error fetching link previews");
394 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
395 - })?;
396 -
397 - let mut link_previews_by_post: HashMap<String, Vec<LinkPreviewViewRow>> = HashMap::new();
398 - for lp in all_link_previews {
399 - link_previews_by_post
400 - .entry(lp.post_id.to_string())
401 - .or_default()
402 - .push(LinkPreviewViewRow {
403 - url: lp.url,
404 - title: lp.title,
405 - description: lp.description,
406 - });
407 - }
408 -
409 - // Build quote author map for attribution rendering
410 - let mut quote_authors: HashMap<uuid::Uuid, docengine::QuoteAuthor> = HashMap::new();
411 - for p in &db_posts {
412 - quote_authors.insert(p.id, docengine::QuoteAuthor {
413 - username: p.author_username.clone(),
414 - display_name: p.author_name.clone(),
415 - is_removed: p.removed_at.is_some(),
416 - });
417 - }
418 -
419 - let posts: Vec<PostRow> = db_posts
420 - .into_iter()
421 - .enumerate()
422 - .map(|(i, p)| {
423 - let is_removed = p.removed_at.is_some();
424 - let can_add_footnote = !is_removed
425 - && session_user.as_ref().is_some_and(|u| u.user_id == p.author_id);
426 - let can_remove = !is_removed && mod_status;
427 -
428 - let body_html = if is_removed {
429 - String::from("<p><em>[removed by moderator]</em></p>")
430 - } else {
431 - docengine::post_process_quotes(&p.body_html, &quote_authors)
432 - };
433 -
434 - let post_id_str = p.id.to_string();
435 - let footnotes = footnotes_by_post.remove(&post_id_str).unwrap_or_default();
436 - let link_previews = link_previews_by_post.remove(&post_id_str).unwrap_or_default();
437 - let endorsement_count = endorsement_counts.get(&post_id_str).copied().unwrap_or(0);
438 - let is_endorsed = user_endorsed.contains(&post_id_str);
439 - let can_endorse = !is_removed
440 - && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
441 - let can_flag = !is_removed
442 - && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
443 -
444 - PostRow {
445 - id: post_id_str,
446 - author_name: p.author_name,
447 - author_username: p.author_username,
448 - timestamp: mt_core::time_format::post_timestamp(p.created_at),
449 - body_html,
450 - is_op: i == 0 && offset == 0,
451 - is_removed,
452 - can_add_footnote,
453 - can_remove,
454 - can_flag,
455 - footnotes,
456 - link_previews,
457 - endorsement_count,
458 - is_endorsed,
459 - can_endorse,
460 - }
461 - })
462 - .collect();
463 -
464 - // If tracked, update read position to the last post on the current page
465 - if is_tracked
466 - && let Some(ref user) = session_user
467 - && let Some(last_post) = posts.last()
468 - && let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id)
469 - {
470 - let _ = mt_db::mutations::update_read_position(
471 - &state.db, user.user_id, thread_uuid, last_post_id,
472 - ).await;
473 - }
474 -
475 - let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
476 -
477 - Ok(ThreadTemplate {
478 - csrf_token,
479 - session_user,
480 - mnw_base_url: state.config.mnw_base_url.clone(),
481 - community_name: thread_data.community_name,
482 - community_slug: thread_data.community_slug,
483 - category_name: thread_data.category_name,
484 - category_slug: thread_data.category_slug,
485 - thread_id: thread_data.id.to_string(),
486 - thread_title: thread_data.title,
487 - locked: thread_data.locked,
488 - pinned: thread_data.pinned,
489 - is_mod: mod_status,
490 - can_mod_thread: mod_status,
491 - is_tracked,
492 - posts,
493 - pagination: Pagination::new(page, total, per_page),
494 - })
495 - }
496 -
497 - #[tracing::instrument(skip_all)]
498 294 pub(in crate::routes) async fn new_thread(
499 295 axum::extract::State(state): axum::extract::State<AppState>,
500 296 Path((slug, category_slug)): Path<(String, String)>,
@@ -0,0 +1,315 @@
1 + //! Shared route helpers — validation, permission checks, markdown rendering, enforcement.
2 +
3 + use axum::{
4 + http::StatusCode,
5 + response::{IntoResponse, Response},
6 + };
7 + use chrono::{DateTime, Duration, Utc};
8 + use uuid::Uuid;
9 +
10 + use mt_core::types::{CommunityRole, ModAction};
11 +
12 + use crate::auth;
13 + use crate::templates::*;
14 + use crate::AppState;
15 +
16 + // ============================================================================
17 + // Rate limiting constants
18 + // ============================================================================
19 +
20 + /// Per-user rate limit: max posts per window (complements per-IP tower-governor).
21 + pub(crate) const USER_POST_RATE_LIMIT: i64 = 15;
22 + pub(crate) const USER_POST_RATE_WINDOW_SECS: i64 = 60;
23 +
24 + // ============================================================================
25 + // Markdown rendering
26 + // ============================================================================
27 +
28 + /// Render markdown to HTML, stripping raw HTML events to prevent XSS.
29 + pub(crate) fn render_markdown(input: &str) -> String {
30 + docengine::render_strict(input)
31 + }
32 +
33 + /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members.
34 + pub(crate) fn render_markdown_with_mentions(
35 + input: &str,
36 + community_slug: &str,
37 + valid_usernames: &std::collections::HashSet<String>,
38 + ) -> String {
39 + let template = format!("/p/{community_slug}/u/{{username}}");
40 + let resolved = docengine::resolve_mentions(input, valid_usernames, &template);
41 + docengine::render_strict(&resolved)
42 + }
43 +
44 + // ============================================================================
45 + // Common helpers — reduce boilerplate in handlers
46 + // ============================================================================
47 +
48 + /// Fetch community by slug, returning 404/500 on failure.
49 + #[tracing::instrument(skip_all)]
50 + pub(crate) async fn get_community(
51 + db: &sqlx::PgPool,
52 + slug: &str,
53 + ) -> Result<mt_db::queries::CommunityRow, Response> {
54 + mt_db::queries::get_community_by_slug(db, slug)
55 + .await
56 + .map_err(|e| {
57 + tracing::error!(error = ?e, "db error fetching community");
58 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
59 + })?
60 + .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
61 + }
62 +
63 + /// Fetch thread with breadcrumb, returning 404/500 on failure.
64 + #[tracing::instrument(skip_all)]
65 + pub(crate) async fn get_thread(
66 + db: &sqlx::PgPool,
67 + thread_id_str: &str,
68 + ) -> Result<mt_db::queries::ThreadWithBreadcrumb, Response> {
69 + let thread_id = parse_uuid(thread_id_str)?;
70 + mt_db::queries::get_thread_with_breadcrumb(db, thread_id)
71 + .await
72 + .map_err(|e| {
73 + tracing::error!(error = ?e, "db error fetching thread");
74 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
75 + })?
76 + .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
77 + }
78 +
79 + /// Parse a UUID from a string, returning 404 on failure.
80 + #[allow(clippy::result_large_err)]
81 + pub(crate) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> {
82 + Uuid::parse_str(id_str).map_err(|_| StatusCode::NOT_FOUND.into_response())
83 + }
84 +
85 + /// Fetch a user's role in a community, returning 500 on DB error.
86 + #[tracing::instrument(skip_all)]
87 + pub(crate) async fn get_role(
88 + db: &sqlx::PgPool,
89 + user_id: Uuid,
90 + community_id: Uuid,
91 + ) -> Result<Option<CommunityRole>, Response> {
92 + let role_str = mt_db::queries::get_user_role(db, user_id, community_id)
93 + .await
94 + .map_err(|e| {
95 + tracing::error!(error = ?e, "db error fetching role");
96 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
97 + })?;
98 + Ok(role_str.and_then(|s| CommunityRole::from_db(&s)))
99 + }
100 +
101 + /// Look up a user by username, returning 422 if not found.
102 + #[tracing::instrument(skip_all)]
103 + pub(crate) async fn get_user_by_username(
104 + db: &sqlx::PgPool,
105 + username: &str,
106 + ) -> Result<Uuid, Response> {
107 + mt_db::queries::get_user_by_username(db, username)
108 + .await
109 + .map_err(|e| {
110 + tracing::error!(error = ?e, "db error looking up user");
111 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
112 + })?
113 + .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response())
114 + }
115 +
116 + /// Fire-and-forget mod log entry. Logs errors but never fails the request.
117 + pub(crate) async fn log_mod_action(
118 + db: &sqlx::PgPool,
119 + community_id: Option<Uuid>,
120 + actor_id: Uuid,
121 + action: ModAction,
122 + target_user: Option<Uuid>,
123 + target_id: Option<Uuid>,
124 + reason: Option<&str>,
125 + ) {
126 + if let Err(e) = mt_db::mutations::insert_mod_log(
127 + db, community_id, actor_id, action, target_user, target_id, reason,
128 + )
129 + .await
130 + {
131 + tracing::error!(error = %e, "failed to insert mod log");
132 + }
133 + }
134 +
135 + /// Convert a session user to a template session user.
136 + pub(crate) fn template_user(
137 + user: &auth::SessionUser,
138 + platform_admin_id: Option<Uuid>,
139 + ) -> TemplateSessionUser {
140 + TemplateSessionUser {
141 + is_platform_admin: platform_admin_id == Some(user.user_id),
142 + username: user.username.clone(),
143 + }
144 + }
145 +
146 + /// Validate a title field (1-256 chars).
147 + #[allow(clippy::result_large_err)]
148 + pub(crate) fn validate_title(text: &str) -> Result<&str, Response> {
149 + let t = text.trim();
150 + if t.is_empty() || t.len() > 256 {
151 + return Err((
152 + StatusCode::UNPROCESSABLE_ENTITY,
153 + "Title must be between 1 and 256 characters.",
154 + )
155 + .into_response());
156 + }
157 + Ok(t)
158 + }
159 +
160 + /// Validate a body/content field (1 to max chars).
161 + #[allow(clippy::result_large_err)]
162 + pub(crate) fn validate_body<'a>(text: &'a str, max: usize, field: &str) -> Result<&'a str, Response> {
163 + let t = text.trim();
164 + if t.is_empty() || t.len() > max {
165 + return Err((
166 + StatusCode::UNPROCESSABLE_ENTITY,
167 + format!("{field} must be between 1 and {max} characters."),
168 + )
169 + .into_response());
170 + }
171 + Ok(t)
172 + }
173 +
174 + // ============================================================================
175 + // Permission helpers
176 + // ============================================================================
177 +
178 + /// Is this user a moderator or owner in the community?
179 + pub(crate) fn is_mod_or_owner(role: &Option<CommunityRole>) -> bool {
180 + role.is_some_and(|r| r.is_mod_or_owner())
181 + }
182 +
183 + /// Is this user an owner of the community?
184 + pub(crate) fn is_owner(role: &Option<CommunityRole>) -> bool {
185 + role.is_some_and(|r| r.is_owner())
186 + }
187 +
188 + // ============================================================================
189 + // Enforcement helpers
190 + // ============================================================================
191 +
192 + /// Check community suspension + user ban. For read handlers.
193 + #[tracing::instrument(skip_all)]
194 + pub(crate) async fn check_community_access(
195 + db: &sqlx::PgPool,
196 + community: &mt_db::queries::CommunityRow,
197 + user_id: Option<Uuid>,
198 + ) -> Result<(), Response> {
199 + if community.suspended_at.is_some() {
200 + return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
201 + }
202 + if let Some(uid) = user_id {
203 + let banned = mt_db::queries::is_user_banned(db, community.id, uid)
204 + .await
205 + .map_err(|e| {
206 + tracing::error!(error = ?e, "db error checking ban status");
207 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
208 + })?;
209 + if banned {
210 + return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
211 + }
212 + }
213 + Ok(())
214 + }
215 +
216 + /// Check community suspension + platform suspension + user ban + user mute. For write handlers.
217 + #[tracing::instrument(skip_all)]
218 + pub(crate) async fn check_write_access(
219 + db: &sqlx::PgPool,
220 + community_id: Uuid,
221 + user_id: Uuid,
222 + community_suspended: bool,
223 + ) -> Result<(), Response> {
224 + if community_suspended {
225 + return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
226 + }
227 + let suspended = mt_db::queries::is_user_suspended(db, user_id)
228 + .await
229 + .map_err(|e| {
230 + tracing::error!(error = ?e, "db error checking user suspension");
231 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
232 + })?;
233 + if suspended {
234 + return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
235 + }
236 + let banned = mt_db::queries::is_user_banned(db, community_id, user_id)
237 + .await
238 + .map_err(|e| {
239 + tracing::error!(error = ?e, "db error checking ban status");
240 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
241 + })?;
242 + if banned {
243 + return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
244 + }
245 + let muted = mt_db::queries::is_user_muted(db, community_id, user_id)
246 + .await
247 + .map_err(|e| {
248 + tracing::error!(error = ?e, "db error checking mute status");
249 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
250 + })?;
251 + if muted {
252 + return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response());
253 + }
254 + Ok(())
255 + }
256 +
257 + /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit.
258 + #[tracing::instrument(skip_all)]
259 + pub(crate) async fn check_user_post_rate(
260 + db: &sqlx::PgPool,
261 + user_id: Uuid,
262 + ) -> Result<(), Response> {
263 + let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS)
264 + .await
265 + .map_err(|e| {
266 + tracing::error!(error = ?e, "db error checking user post rate");
267 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
268 + })?;
269 + if count >= USER_POST_RATE_LIMIT {
270 + return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response());
271 + }
272 + Ok(())
273 + }
274 +
275 + /// Parse a ban duration string into an optional expiration datetime.
276 + pub(crate) fn parse_duration(duration: &str) -> Option<DateTime<Utc>> {
277 + match duration {
278 + "permanent" => None,
279 + "1h" => Some(Utc::now() + Duration::hours(1)),
280 + "1d" => Some(Utc::now() + Duration::days(1)),
281 + "7d" => Some(Utc::now() + Duration::days(7)),
282 + "30d" => Some(Utc::now() + Duration::days(30)),
283 + _ => None,
284 + }
285 + }
286 +
287 + /// Helper: fetch community + verify owner role, returning 403 if not owner.
288 + #[tracing::instrument(skip_all)]
289 + pub(crate) async fn require_owner(
290 + state: &AppState,
291 + slug: &str,
292 + user: &auth::SessionUser,
293 + ) -> Result<mt_db::queries::CommunityRow, Response> {
294 + let community = get_community(&state.db, slug).await?;
295 + let role = get_role(&state.db, user.user_id, community.id).await?;
296 + if !is_owner(&role) {
297 + return Err(StatusCode::FORBIDDEN.into_response());
298 + }
299 + Ok(community)
300 + }
301 +
302 + /// Helper: fetch community + verify mod_or_owner role, returning 403 if not.
303 + #[tracing::instrument(skip_all)]
304 + pub(crate) async fn require_mod_or_owner(
305 + state: &AppState,
306 + slug: &str,
307 + user: &auth::SessionUser,
308 + ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
309 + let community = get_community(&state.db, slug).await?;
310 + let role = get_role(&state.db, user.user_id, community.id).await?;
311 + if !is_mod_or_owner(&role) {
312 + return Err(StatusCode::FORBIDDEN.into_response());
313 + }
314 + Ok((community, role))
315 + }
M src/routes/mod.rs +5 -302
@@ -3,6 +3,7 @@
3 3 mod admin;
4 4 mod flagging;
5 5 mod forum;
6 + mod helpers;
6 7 pub mod internal;
7 8 mod moderation;
8 9 mod search;
@@ -10,19 +11,18 @@ mod settings;
10 11 mod tracking;
11 12 mod uploads;
12 13
14 + // Re-export helpers so submodules can `use super::*` as before.
15 + pub(crate) use helpers::*;
16 +
13 17 use axum::{
14 18 http::StatusCode,
15 - response::{IntoResponse, Response},
19 + response::IntoResponse,
16 20 Json, Router,
17 21 routing::{get, post},
18 22 };
19 - use chrono::{DateTime, Duration, Utc};
20 23 use serde::Deserialize;
21 24 use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor};
22 25 use tower_sessions::Session;
23 - use uuid::Uuid;
24 -
25 - use mt_core::types::{CommunityRole, ModAction};
26 26
27 27 use crate::auth::{self, MaybeUser};
28 28 use crate::csrf;
@@ -37,10 +37,6 @@ use crate::AppState;
37 37 const WRITE_RATE_LIMIT_MS: u64 = 500;
38 38 const WRITE_RATE_LIMIT_BURST: u32 = 10;
39 39
40 - /// Per-user rate limit: max posts per window (complements per-IP tower-governor).
41 - const USER_POST_RATE_LIMIT: i64 = 15;
42 - const USER_POST_RATE_WINDOW_SECS: i64 = 60;
43 -
44 40 /// Build the forum route tree.
45 41 pub fn forum_routes(state: AppState) -> Router {
46 42 let write_rate_limit = std::sync::Arc::new(
@@ -250,299 +246,6 @@ pub(super) struct DeleteTagForm {
250 246 }
251 247
252 248 // ============================================================================
253 - // Markdown rendering
254 - // ============================================================================
255 -
256 - /// Render markdown to HTML, stripping raw HTML events to prevent XSS.
257 - pub(super) fn render_markdown(input: &str) -> String {
258 - docengine::render_strict(input)
259 - }
260 -
261 - /// Render markdown to HTML, resolving `@mentions` to profile links for valid community members.
262 - pub(super) fn render_markdown_with_mentions(
263 - input: &str,
264 - community_slug: &str,
265 - valid_usernames: &std::collections::HashSet<String>,
266 - ) -> String {
267 - let template = format!("/p/{community_slug}/u/{{username}}");
268 - let resolved = docengine::resolve_mentions(input, valid_usernames, &template);
269 - docengine::render_strict(&resolved)
270 - }
271 -
272 - // ============================================================================
273 - // Common helpers — reduce boilerplate in handlers
274 - // ============================================================================
275 -
276 - /// Fetch community by slug, returning 404/500 on failure.
277 - #[tracing::instrument(skip_all)]
278 - pub(super) async fn get_community(
279 - db: &sqlx::PgPool,
280 - slug: &str,
281 - ) -> Result<mt_db::queries::CommunityRow, Response> {
282 - mt_db::queries::get_community_by_slug(db, slug)
283 - .await
284 - .map_err(|e| {
285 - tracing::error!(error = ?e, "db error fetching community");
286 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
287 - })?
288 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
289 - }
290 -
291 - /// Fetch thread with breadcrumb, returning 404/500 on failure.
292 - #[tracing::instrument(skip_all)]
293 - pub(super) async fn get_thread(
294 - db: &sqlx::PgPool,
295 - thread_id_str: &str,
296 - ) -> Result<mt_db::queries::ThreadWithBreadcrumb, Response> {
297 - let thread_id = parse_uuid(thread_id_str)?;
298 - mt_db::queries::get_thread_with_breadcrumb(db, thread_id)
299 - .await
300 - .map_err(|e| {
301 - tracing::error!(error = ?e, "db error fetching thread");
302 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
303 - })?
304 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
305 - }
306 -
307 - /// Parse a UUID from a string, returning 404 on failure.
308 - #[allow(clippy::result_large_err)]
309 - pub(super) fn parse_uuid(id_str: &str) -> Result<Uuid, Response> {
310 - Uuid::parse_str(id_str).map_err(|_| StatusCode::NOT_FOUND.into_response())
311 - }
312 -
313 - /// Fetch a user's role in a community, returning 500 on DB error.
314 - #[tracing::instrument(skip_all)]
315 - pub(super) async fn get_role(
316 - db: &sqlx::PgPool,
317 - user_id: Uuid,
318 - community_id: Uuid,
319 - ) -> Result<Option<CommunityRole>, Response> {
320 - let role_str = mt_db::queries::get_user_role(db, user_id, community_id)
321 - .await
322 - .map_err(|e| {
323 - tracing::error!(error = ?e, "db error fetching role");
324 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
325 - })?;
326 - Ok(role_str.and_then(|s| CommunityRole::from_db(&s)))
327 - }
328 -
329 - /// Look up a user by username, returning 422 if not found.
330 - #[tracing::instrument(skip_all)]
331 - pub(super) async fn get_user_by_username(
332 - db: &sqlx::PgPool,
333 - username: &str,
334 - ) -> Result<Uuid, Response> {
335 - mt_db::queries::get_user_by_username(db, username)
336 - .await
337 - .map_err(|e| {
338 - tracing::error!(error = ?e, "db error looking up user");
339 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
340 - })?
341 - .ok_or_else(|| (StatusCode::UNPROCESSABLE_ENTITY, "User not found.").into_response())
342 - }
343 -
344 - /// Fire-and-forget mod log entry. Logs errors but never fails the request.
345 - pub(super) async fn log_mod_action(
346 - db: &sqlx::PgPool,
347 - community_id: Option<Uuid>,
348 - actor_id: Uuid,
349 - action: ModAction,
350 - target_user: Option<Uuid>,
351 - target_id: Option<Uuid>,
352 - reason: Option<&str>,
353 - ) {
354 - if let Err(e) = mt_db::mutations::insert_mod_log(
355 - db, community_id, actor_id, action, target_user, target_id, reason,
356 - )
357 - .await
358 - {
359 - tracing::error!(error = %e, "failed to insert mod log");
360 - }
361 - }
362 -
363 - /// Convert a session user to a template session user.
364 - pub(super) fn template_user(
365 - user: &auth::SessionUser,
366 - platform_admin_id: Option<Uuid>,
367 - ) -> TemplateSessionUser {
368 - TemplateSessionUser {
369 - is_platform_admin: platform_admin_id == Some(user.user_id),
370 - username: user.username.clone(),
371 - }
372 - }
373 -
374 - /// Validate a title field (1-256 chars).
375 - #[allow(clippy::result_large_err)]
376 - pub(super) fn validate_title(text: &str) -> Result<&str, Response> {
377 - let t = text.trim();
378 - if t.is_empty() || t.len() > 256 {
379 - return Err((
380 - StatusCode::UNPROCESSABLE_ENTITY,
381 - "Title must be between 1 and 256 characters.",
382 - )
383 - .into_response());
384 - }
385 - Ok(t)
386 - }
387 -
388 - /// Validate a body/content field (1 to max chars).
389 - #[allow(clippy::result_large_err)]
390 - pub(super) fn validate_body<'a>(text: &'a str, max: usize, field: &str) -> Result<&'a str, Response> {
391 - let t = text.trim();
392 - if t.is_empty() || t.len() > max {
393 - return Err((
394 - StatusCode::UNPROCESSABLE_ENTITY,
395 - format!("{field} must be between 1 and {max} characters."),
396 - )
397 - .into_response());
398 - }
399 - Ok(t)
400 - }
401 -
402 - // ============================================================================
403 - // Permission helpers
404 - // ============================================================================
405 -
406 - /// Is this user a moderator or owner in the community?
407 - pub(super) fn is_mod_or_owner(role: &Option<CommunityRole>) -> bool {
408 - role.is_some_and(|r| r.is_mod_or_owner())
409 - }
410 -
411 - /// Is this user an owner of the community?
412 - pub(super) fn is_owner(role: &Option<CommunityRole>) -> bool {
413 - role.is_some_and(|r| r.is_owner())
414 - }
415 -
416 - // ============================================================================
417 - // Enforcement helpers
418 - // ============================================================================
419 -
420 - /// Check community suspension + user ban. For read handlers.
421 - #[tracing::instrument(skip_all)]
422 - pub(super) async fn check_community_access(
423 - db: &sqlx::PgPool,
424 - community: &mt_db::queries::CommunityRow,
425 - user_id: Option<Uuid>,
426 - ) -> Result<(), Response> {
427 - if community.suspended_at.is_some() {
428 - return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
429 - }
430 - if let Some(uid) = user_id {
431 - let banned = mt_db::queries::is_user_banned(db, community.id, uid)
432 - .await
433 - .map_err(|e| {
434 - tracing::error!(error = ?e, "db error checking ban status");
435 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
436 - })?;
437 - if banned {
438 - return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
439 - }
440 - }
441 - Ok(())
442 - }
443 -
444 - /// Check community suspension + platform suspension + user ban + user mute. For write handlers.
445 - #[tracing::instrument(skip_all)]
446 - pub(super) async fn check_write_access(
447 - db: &sqlx::PgPool,
448 - community_id: Uuid,
449 - user_id: Uuid,
450 - community_suspended: bool,
451 - ) -> Result<(), Response> {
452 - if community_suspended {
453 - return Err((StatusCode::FORBIDDEN, "This community has been suspended.").into_response());
454 - }
455 - let suspended = mt_db::queries::is_user_suspended(db, user_id)
456 - .await
457 - .map_err(|e| {
458 - tracing::error!(error = ?e, "db error checking user suspension");
459 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
460 - })?;
461 - if suspended {
462 - return Err((StatusCode::FORBIDDEN, "Your account has been suspended.").into_response());
463 - }
464 - let banned = mt_db::queries::is_user_banned(db, community_id, user_id)
465 - .await
466 - .map_err(|e| {
467 - tracing::error!(error = ?e, "db error checking ban status");
468 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
469 - })?;
470 - if banned {
471 - return Err((StatusCode::FORBIDDEN, "You are banned from this community.").into_response());
472 - }
473 - let muted = mt_db::queries::is_user_muted(db, community_id, user_id)
474 - .await
475 - .map_err(|e| {
476 - tracing::error!(error = ?e, "db error checking mute status");
477 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
478 - })?;
479 - if muted {
480 - return Err((StatusCode::FORBIDDEN, "You are muted in this community.").into_response());
481 - }
482 - Ok(())
483 - }
484 -
485 - /// Per-user posting rate limit. Returns 429 if the user has exceeded the limit.
486 - #[tracing::instrument(skip_all)]
487 - pub(super) async fn check_user_post_rate(
488 - db: &sqlx::PgPool,
489 - user_id: Uuid,
490 - ) -> Result<(), Response> {
491 - let count = mt_db::queries::count_recent_posts_by_user(db, user_id, USER_POST_RATE_WINDOW_SECS)
492 - .await
493 - .map_err(|e| {
494 - tracing::error!(error = ?e, "db error checking user post rate");
495 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
496 - })?;
497 - if count >= USER_POST_RATE_LIMIT {
498 - return Err((StatusCode::TOO_MANY_REQUESTS, "You are posting too quickly. Please wait a moment.").into_response());
499 - }
500 - Ok(())
501 - }
502 -
503 - /// Parse a ban duration string into an optional expiration datetime.
504 - pub(super) fn parse_duration(duration: &str) -> Option<DateTime<Utc>> {
505 - match duration {
506 - "permanent" => None,
507 - "1h" => Some(Utc::now() + Duration::hours(1)),
508 - "1d" => Some(Utc::now() + Duration::days(1)),
509 - "7d" => Some(Utc::now() + Duration::days(7)),
510 - "30d" => Some(Utc::now() + Duration::days(30)),
511 - _ => None,
512 - }
513 - }
514 -
515 - /// Helper: fetch community + verify owner role, returning 403 if not owner.
516 - #[tracing::instrument(skip_all)]
517 - pub(super) async fn require_owner(
518 - state: &AppState,
519 - slug: &str,
520 - user: &auth::SessionUser,
521 - ) -> Result<mt_db::queries::CommunityRow, Response> {
522 - let community = get_community(&state.db, slug).await?;
523 - let role = get_role(&state.db, user.user_id, community.id).await?;
524 - if !is_owner(&role) {
525 - return Err(StatusCode::FORBIDDEN.into_response());
526 - }
527 - Ok(community)
528 - }
529 -
530 - /// Helper: fetch community + verify mod_or_owner role, returning 403 if not.
531 - #[tracing::instrument(skip_all)]
532 - pub(super) async fn require_mod_or_owner(
533 - state: &AppState,
534 - slug: &str,
535 - user: &auth::SessionUser,
536 - ) -> Result<(mt_db::queries::CommunityRow, Option<CommunityRole>), Response> {
537 - let community = get_community(&state.db, slug).await?;
538 - let role = get_role(&state.db, user.user_id, community.id).await?;
539 - if !is_mod_or_owner(&role) {
540 - return Err(StatusCode::FORBIDDEN.into_response());
541 - }
542 - Ok((community, role))
543 - }
544 -
545 - // ============================================================================
546 249 // Handlers
547 250 // ============================================================================
548 251
M src/storage.rs +15 -64
@@ -1,9 +1,6 @@
1 1 //! S3 storage client for image uploads.
2 + //! Delegates S3 operations to the shared `s3_storage` crate.
2 3
3 - use aws_sdk_s3::{
4 - config::{BehaviorVersion, Credentials, Region},
5 - Client,
6 - };
7 4 use uuid::Uuid;
8 5
9 6 use crate::config::S3Config;
@@ -11,8 +8,7 @@ use crate::config::S3Config;
11 8 /// S3 client wrapper for image storage.
12 9 #[derive(Clone)]
13 10 pub struct S3Storage {
14 - client: Client,
15 - bucket: String,
11 + inner: s3_storage::S3Client,
16 12 }
17 13
18 14 /// Maximum image size: 5 MB.
@@ -33,79 +29,34 @@ const ALLOWED_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp"];
33 29 impl S3Storage {
34 30 /// Create a new S3 client from configuration.
35 31 pub async fn new(config: &S3Config) -> Result<Self, String> {
36 - let credentials = Credentials::new(
37 - &config.access_key,
38 - &config.secret_key,
39 - None,
40 - None,
41 - "multithreaded",
42 - );
43 -
44 - let s3_config = aws_sdk_s3::Config::builder()
45 - .behavior_version(BehaviorVersion::latest())
46 - .region(Region::new(config.region.clone()))
47 - .endpoint_url(&config.endpoint)
48 - .credentials_provider(credentials)
49 - .force_path_style(true)
50 - .build();
51 -
52 - let client = Client::from_conf(s3_config);
53 -
54 - Ok(Self {
55 - client,
32 + let s3_config = s3_storage::S3Config {
33 + endpoint: config.endpoint.clone(),
56 34 bucket: config.bucket.clone(),
57 - })
35 + access_key: config.access_key.clone(),
36 + secret_key: config.secret_key.clone(),
37 + region: config.region.clone(),
38 + };
39 +
40 + let inner = s3_storage::S3Client::new(&s3_config).await?;
41 + Ok(Self { inner })
58 42 }
59 43
60 - /// Upload bytes to S3 and return the S3 key.
44 + /// Upload bytes to S3.
61 45 #[tracing::instrument(skip_all)]
62 46 pub async fn upload(&self, s3_key: &str, content_type: &str, data: Vec<u8>) -> Result<(), String> {
63 - self.client
64 - .put_object()
65 - .bucket(&self.bucket)
66 - .key(s3_key)
67 - .content_type(content_type)
68 - .body(data.into())
69 - .send()
70 - .await
71 - .map_err(|e| format!("S3 upload failed: {e}"))?;
72 - Ok(())
47 + self.inner.upload(s3_key, content_type, data, None).await
73 48 }
74 49
75 50 /// Download bytes from S3.
76 51 #[tracing::instrument(skip_all)]
77 52 pub async fn download(&self, s3_key: &str) -> Result<(Vec<u8>, String), String> {
78 - let resp = self
79 - .client
80 - .get_object()
81 - .bucket(&self.bucket)
82 - .key(s3_key)
83 - .send()
84 - .await
85 - .map_err(|e| format!("S3 download failed: {e}"))?;
86 -
87 - let content_type = resp.content_type().unwrap_or("application/octet-stream").to_string();
88 -
89 - let bytes = resp
90 - .body
91 - .collect()
92 - .await
93 - .map_err(|e| format!("S3 read body failed: {e}"))?;
94 -
95 - Ok((bytes.into_bytes().to_vec(), content_type))
53 + self.inner.download(s3_key).await
96 54 }
97 55
98 56 /// Delete an object from S3.
99 57 #[tracing::instrument(skip_all)]
100 58 pub async fn delete(&self, s3_key: &str) -> Result<(), String> {
101 - self.client
102 - .delete_object()
103 - .bucket(&self.bucket)
104 - .key(s3_key)
105 - .send()
106 - .await
107 - .map_err(|e| format!("S3 delete failed: {e}"))?;
108 - Ok(())
59 + self.inner.delete(s3_key).await
109 60 }
110 61 }
111 62
@@ -1409,7 +1409,7 @@ tr.mentioned {
1409 1409 =========================================== */
1410 1410
1411 1411 .form-inline { display: inline; }
1412 - .form-inline-row { display: inline; gap: 0.25rem; flex-direction: row; align-items: center; }
1412 + .form-inline-row { display: inline-flex; gap: 0.25rem; flex-direction: row; align-items: center; }
1413 1413 .btn-secondary { text-decoration: none; }
1414 1414 .section-heading { margin-bottom: 1.5rem; }
1415 1415 .section-heading-top { margin-top: 1.5rem; }