max / multithreaded
21 files changed,
+1977 insertions,
-1104 deletions
| @@ -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" |
| @@ -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) |
| @@ -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("ed_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("ed_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, "e_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, "e_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 | + | } |
| @@ -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 |
| @@ -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; } |