max / multithreaded
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
21 files changed,
+1976 insertions,
-1103 deletions
| @@ -163,9 +163,9 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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", | |
| @@ -2916,6 +2915,15 @@ | |||
| 2916 | 2915 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2917 | 2916 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" | |
| 2918 | 2917 | ||
| 2918 | + | [[package]] | |
| 2919 | + | name = "s3-storage" | |
| 2920 | + | version = "0.1.0" | |
| 2921 | + | dependencies = [ | |
| 2922 | + | "aws-config", | |
| 2923 | + | "aws-sdk-s3", | |
| 2924 | + | "tracing", | |
| 2925 | + | ] | |
| 2926 | + | ||
| 2919 | 2927 | [[package]] | |
| 2920 | 2928 | name = "schannel" | |
| 2921 | 2929 | version = "0.1.29" |
| @@ -35,8 +35,7 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 |
| @@ -104,70 +104,6 @@ | |||
| 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 | |
| 107 | + | --- | |
| 108 | 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 | |
| 109 | + | See [audit_history.md](./audit_history.md) for full chronological audit log. |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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,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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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; } |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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( | |
| @@ -249,299 +245,6 @@ | |||
| 249 | 245 | pub(super) tag_id: String, | |
| 250 | 246 | } | |
| 251 | 247 | ||
| 252 | - | // ============================================================================ | |
| 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 | 248 | // ============================================================================ | |
| 546 | 249 | // Handlers | |
| 547 | 250 | // ============================================================================ |
| @@ -37,17 +37,7 @@ | |||
| 37 | 37 | {% endfor %} | |
| 38 | 38 | </tbody> | |
| 39 | 39 | </table> | |
| 40 | - | {% if pagination.total_pages > 1 %} | |
| 41 | - | <nav class="pagination" aria-label="Page navigation"> | |
| 42 | - | {% if pagination.has_prev %} | |
| 43 | - | <a href="?page={{ pagination.current_page - 1 }}" class="pagination-link">Previous</a> | |
| 44 | - | {% endif %} | |
| 45 | - | <span class="pagination-info">Page {{ pagination.current_page }} of {{ pagination.total_pages }}</span> | |
| 46 | - | {% if pagination.has_next %} | |
| 47 | - | <a href="?page={{ pagination.current_page + 1 }}" class="pagination-link">Next</a> | |
| 48 | - | {% endif %} | |
| 49 | - | </nav> | |
| 50 | - | {% endif %} | |
| 40 | + | {% include "partials/pagination.html" %} | |
| 51 | 41 | {% endif %} | |
| 52 | 42 | </div> | |
| 53 | 43 | {% endblock %} |
| @@ -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 @@ | |||
| 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 @@ | |||
| 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::*; |
| @@ -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 @@ | |||
| 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 | ||
| @@ -290,210 +290,6 @@ | |||
| 290 | 290 | }) | |
| 291 | 291 | } | |
| 292 | 292 | ||
| 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 | 293 | #[tracing::instrument(skip_all)] | |
| 498 | 294 | pub(in crate::routes) async fn new_thread( | |
| 499 | 295 | axum::extract::State(state): axum::extract::State<AppState>, |