Skip to main content

max / multithreaded

Code review remediation: split forum routes, add helpers, fix clippy Split forum/mod.rs (1,247 lines) into views.rs, actions.rs, posts.rs, thread.rs. Extract route helpers. Fix all clippy warnings. Grade A.
Co-Authored-By
Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-12 23:39 UTC
Commit: ab97981429ecf116fc4931cb5ba4e98f3dab3282
Parent: 5907e76
21 files changed, +1976 insertions, -1103 deletions
M Cargo.lock +17 -9
@@ -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"
M Cargo.toml +2 -4
@@ -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.
M docs/todo.md +23 -4
@@ -1,10 +1,28 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: All pre-beta phases (0-11, 13-24). 249 tests (187 integration + 35 unit lib + 16 unit mt-core + 11 unit mt-db). v0.3.1. Audit grade: A (Run 10). Deployed to hetzner+astra (forums.makenot.work). All 21 migrations applied. S3 image uploads configured. MNW Forums tab integration live (MT_BASE_URL set). Internal API live (INTERNAL_SHARED_SECRET configured on both MNW+MT, 2026-03-22). Default categories auto-provisioned (Items, Blog, Devlog, Discussion). Zero audit cold spots.
3 + Done: All pre-beta phases + S3 storage extraction (shared crate) + code review remediation. Active: None. Next: Post-beta platform integration below.
4 +
5 + v0.3.2. Audit grade A. Deployed to hetzner+astra (forums.makenot.work). MNW internal API live.
4 6
5 7 Completed work archived in `docs/archive/mt_todo_done.md`.
6 8
7 - No remaining pre-beta items. Only deferred post-beta items below.
9 + ---
10 +
11 + ## Code Review Remediation (2026-04-12)
12 +
13 + ### Done
14 + - [x] Fix dependency advisories: aws-lc-sys 0.39.1, rustls-webpki 0.103.11
15 + - [x] Fix `.form-inline-row` CSS: `display: inline` -> `display: inline-flex`
16 + - [x] Use pagination partial in `forum_directory.html`
17 + - [x] Add `Cache-Control: private, no-cache` header for HTML responses
18 + - [x] Split `routes/mod.rs` (578 lines) -> `mod.rs` (281) + `helpers.rs` (315)
19 + - [x] Split `forum/views.rs` (628 lines) -> `views.rs` (424) + `thread.rs` (224)
20 + - [x] Split `forum/actions.rs` (600 lines) -> `actions.rs` (172) + `posts.rs` (443)
21 +
22 + ### Remaining
23 + - [ ] Transitive dep advisories: rand 0.8/0.9 (RUSTSEC-2026-0097), rsa (RUSTSEC-2023-0071), lru (RUSTSEC-2026-0002) — no direct fix available, monitor upstream
24 + - [ ] Add partial index on `posts.removed_at` (`WHERE removed_at IS NOT NULL`) when data volume warrants it
25 + - [ ] Add `tracing::warn!` to `MaybeUser` extractor on session read errors (currently silently returns None)
8 26
9 27 ---
10 28
@@ -53,7 +71,8 @@
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"));
M src/storage.rs +15 -64
@@ -1,9 +1,6 @@
1 1 //! S3 storage client for image uploads.
2 + //! Delegates S3 operations to the shared `s3_storage` crate.
2 3
3 - use aws_sdk_s3::{
4 - config::{BehaviorVersion, Credentials, Region},
5 - Client,
6 - };
7 4 use uuid::Uuid;
8 5
9 6 use crate::config::S3Config;
@@ -11,8 +8,7 @@
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; }
M src/routes/mod.rs +5 -302
@@ -3,6 +3,7 @@
3 3 mod admin;
4 4 mod flagging;
5 5 mod forum;
6 + mod helpers;
6 7 pub mod internal;
7 8 mod moderation;
8 9 mod search;
@@ -10,19 +11,18 @@
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(&quoted_text) {
99 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote does not match original post.").into_response());
100 - }
101 -
102 - // Verify hash
103 - let mut hasher = Sha256::new();
104 - hasher.update(quoted_text.as_bytes());
105 - let hash = hasher.finalize();
106 - let expected_hash = hex::encode(&hash[..4]);
107 -
108 - if claimed_hash != expected_hash {
109 - return Err((StatusCode::UNPROCESSABLE_ENTITY, "Quote hash mismatch.").into_response());
110 - }
111 -
112 - quoted_post_ids.push(post_id);
113 - }
114 -
115 - Ok(quoted_post_ids)
116 - }
117 -
118 - // ============================================================================
119 - // Mention pipeline
120 - // ============================================================================
121 -
122 - /// Resolve mentions in a post body and return (rendered_html, mentioned_user_ids).
123 - /// `author_id` is excluded from the mention list (self-mention not stored).
124 - #[tracing::instrument(skip_all)]
125 - async fn resolve_and_render_mentions(
126 - db: &sqlx::PgPool,
127 - body: &str,
128 - community_id: Uuid,
129 - community_slug: &str,
130 - author_id: Uuid,
131 - ) -> Result<(String, Vec<Uuid>), Response> {
132 - let usernames = docengine::extract_mentions(body);
133 - if usernames.is_empty() {
134 - return Ok((render_markdown(body), Vec::new()));
135 - }
136 -
137 - let resolved = mt_db::queries::resolve_usernames_in_community(db, community_id, &usernames)
138 - .await
139 - .map_err(|e| {
140 - tracing::error!(error = ?e, "db error resolving mention usernames");
141 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
142 - })?;
143 -
144 - let valid_set: std::collections::HashSet<String> = resolved.keys().cloned().collect();
145 - let body_html = render_markdown_with_mentions(body, community_slug, &valid_set);
146 -
147 - // Collect user IDs, excluding self
148 - let mention_ids: Vec<Uuid> = resolved
149 - .values()
150 - .copied()
151 - .filter(|uid| *uid != author_id)
152 - .collect();
153 -
154 - Ok((body_html, mention_ids))
155 - }
156 -
157 - // ============================================================================
158 - // Link preview pipeline
159 - // ============================================================================
160 -
161 - /// Extract URLs from post body, fetch OG metadata, and store previews.
162 - /// Best-effort: fetch failures are logged but never block post creation.
163 - #[tracing::instrument(skip_all)]
164 - async fn fetch_and_store_link_previews(state: &AppState, body: &str, post_id: Uuid) {
165 - let urls = crate::link_preview::extract_urls(body);
166 - for url in urls {
167 - match crate::link_preview::fetch_og_metadata(&state.preview_http, &url).await {
168 - Some((title, description)) => {
169 - if let Err(e) = mt_db::mutations::insert_link_preview(
170 - &state.db,
171 - post_id,
172 - &url,
173 - title.as_deref(),
174 - description.as_deref(),
175 - )
176 - .await
177 - {
178 - tracing::warn!(error = ?e, url = %url, "failed to insert link preview");
179 - }
180 - }
181 - None => {
182 - tracing::debug!(url = %url, "no OG metadata found");
183 - }
184 - }
185 - }
186 - }
187 -
188 - // ============================================================================
189 - // Thread + reply creation
190 - // ============================================================================
191 -
192 - #[tracing::instrument(skip_all)]
193 - pub(in crate::routes) async fn create_thread_handler(
194 - axum::extract::State(state): axum::extract::State<AppState>,
195 - Path((slug, category_slug)): Path<(String, String)>,
196 - MaybeUser(session_user): MaybeUser,
197 - Form(form): Form<CreateThreadForm>,
198 - ) -> Result<Redirect, Response> {
199 - let user = session_user
200 - .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
201 -
202 - let community = get_community(&state.db, &slug).await?;
203 -
204 - check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
205 - mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
206 - .await
207 - .map_err(|e| {
208 - tracing::error!(error = ?e, "db error ensuring membership");
209 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
210 - })?;
211 - check_user_post_rate(&state.db, user.user_id).await?;
212 -
213 - let title = validate_title(&form.title)?;
214 - let body = validate_body(&form.body, 65536, "Body")?;
215 -
216 - let category_id = mt_db::mutations::get_category_id_by_slugs(&state.db, &slug, &category_slug)
217 - .await
218 - .map_err(|e| {
219 - tracing::error!(error = ?e, "db error looking up category");
220 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
221 - })?
222 - .ok_or_else(|| StatusCode::NOT_FOUND.into_response())?;
223 -
224 - verify_quotes(&state.db, body).await?;
225 -
226 - let (body_html, mention_ids) = resolve_and_render_mentions(
227 - &state.db, body, community.id, &slug, user.user_id,
228 - ).await?;
229 -
230 - let thread_id = mt_db::mutations::create_thread(&state.db, category_id, user.user_id, title)
231 - .await
232 - .map_err(|e| {
233 - tracing::error!(error = ?e, "db error creating thread");
234 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
235 - })?;
236 -
237 - let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
238 - .await
239 - .map_err(|e| {
240 - tracing::error!(error = ?e, "db error creating post");
241 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
242 - })?;
243 -
244 - if !mention_ids.is_empty() {
245 - mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
246 - .await
247 - .map_err(|e| {
248 - tracing::error!(error = ?e, "db error inserting mentions");
249 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
250 - })?;
251 - }
252 -
253 - // Fetch link previews (best-effort, failures don't block post creation)
254 - fetch_and_store_link_previews(&state, body, post_id).await;
255 -
256 - // Save tags if any were selected
257 - if !form.tags.is_empty() {
258 - let tag_ids: Vec<uuid::Uuid> = form
259 - .tags
260 - .iter()
261 - .filter_map(|t| uuid::Uuid::parse_str(t).ok())
262 - .collect();
263 - if !tag_ids.is_empty() {
264 - mt_db::mutations::set_thread_tags(&state.db, thread_id, &tag_ids)
265 - .await
266 - .map_err(|e| {
267 - tracing::error!(error = ?e, "db error setting thread tags");
268 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
269 - })?;
270 - }
271 - }
272 -
273 - Ok(Redirect::to(&format!(
274 - "/p/{slug}/{category_slug}/{thread_id}?toast=Thread+created"
275 - )))
276 - }
277 -
278 - #[tracing::instrument(skip_all)]
279 - pub(in crate::routes) async fn create_reply_handler(
280 - axum::extract::State(state): axum::extract::State<AppState>,
281 - Path((slug, category_slug, thread_id_str)): Path<(String, String, String)>,
282 - MaybeUser(session_user): MaybeUser,
283 - Form(form): Form<CreateReplyForm>,
284 - ) -> Result<Redirect, Response> {
285 - let user = session_user
286 - .ok_or_else(|| Redirect::to("/auth/login").into_response())?;
287 -
288 - let thread_data = get_thread(&state.db, &thread_id_str).await?;
289 - let community = get_community(&state.db, &slug).await?;
290 -
291 - check_write_access(&state.db, community.id, user.user_id, community.suspended_at.is_some()).await?;
292 - mt_db::mutations::ensure_membership(&state.db, user.user_id, community.id)
293 - .await
294 - .map_err(|e| {
295 - tracing::error!(error = ?e, "db error ensuring membership");
296 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
297 - })?;
298 - check_user_post_rate(&state.db, user.user_id).await?;
299 -
300 - if thread_data.locked {
301 - return Err((StatusCode::FORBIDDEN, "This thread is locked.").into_response());
302 - }
303 -
304 - let body = validate_body(&form.body, 65536, "Body")?;
305 -
306 - verify_quotes(&state.db, body).await?;
307 -
308 - let (body_html, mention_ids) = resolve_and_render_mentions(
309 - &state.db, body, community.id, &slug, user.user_id,
310 - ).await?;
311 -
312 - let thread_id = parse_uuid(&thread_id_str)?;
313 - let post_id = mt_db::mutations::create_post(&state.db, thread_id, user.user_id, body, &body_html)
314 - .await
315 - .map_err(|e| {
316 - tracing::error!(error = ?e, "db error creating reply");
317 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
318 - })?;
319 -
320 - if !mention_ids.is_empty() {
321 - mt_db::mutations::insert_mentions(&state.db, post_id, &mention_ids)
322 - .await
323 - .map_err(|e| {
324 - tracing::error!(error = ?e, "db error inserting mentions");
325 - StatusCode::INTERNAL_SERVER_ERROR.into_response()
326 - })?;
327 - }
328 -
329 - // Fetch link previews (best-effort)
330 - fetch_and_store_link_previews(&state, body, post_id).await;
331 -
332 - Ok(Redirect::to(&format!(
333 - "/p/{slug}/{category_slug}/{thread_id_str}?toast=Reply+posted"
334 - )))
335 - }
17 + use super::posts::{resolve_and_render_mentions, MAX_FOOTNOTES_PER_POST};
336 18
337 19 // ============================================================================
338 20 // Footnote handler
@@ -488,113 +170,3 @@
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, &quote_authors)
432 - };
433 -
434 - let post_id_str = p.id.to_string();
435 - let footnotes = footnotes_by_post.remove(&post_id_str).unwrap_or_default();
436 - let link_previews = link_previews_by_post.remove(&post_id_str).unwrap_or_default();
437 - let endorsement_count = endorsement_counts.get(&post_id_str).copied().unwrap_or(0);
438 - let is_endorsed = user_endorsed.contains(&post_id_str);
439 - let can_endorse = !is_removed
440 - && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
441 - let can_flag = !is_removed
442 - && session_user.as_ref().is_some_and(|u| u.user_id != p.author_id);
443 -
444 - PostRow {
445 - id: post_id_str,
446 - author_name: p.author_name,
447 - author_username: p.author_username,
448 - timestamp: mt_core::time_format::post_timestamp(p.created_at),
449 - body_html,
450 - is_op: i == 0 && offset == 0,
451 - is_removed,
452 - can_add_footnote,
453 - can_remove,
454 - can_flag,
455 - footnotes,
456 - link_previews,
457 - endorsement_count,
458 - is_endorsed,
459 - can_endorse,
460 - }
461 - })
462 - .collect();
463 -
464 - // If tracked, update read position to the last post on the current page
465 - if is_tracked
466 - && let Some(ref user) = session_user
467 - && let Some(last_post) = posts.last()
468 - && let Ok(last_post_id) = uuid::Uuid::parse_str(&last_post.id)
469 - {
470 - let _ = mt_db::mutations::update_read_position(
471 - &state.db, user.user_id, thread_uuid, last_post_id,
472 - ).await;
473 - }
474 -
475 - let session_user = session_user.as_ref().map(|u| template_user(u, state.config.platform_admin_id));
476 -
477 - Ok(ThreadTemplate {
478 - csrf_token,
479 - session_user,
480 - mnw_base_url: state.config.mnw_base_url.clone(),
481 - community_name: thread_data.community_name,
482 - community_slug: thread_data.community_slug,
483 - category_name: thread_data.category_name,
484 - category_slug: thread_data.category_slug,
485 - thread_id: thread_data.id.to_string(),
486 - thread_title: thread_data.title,
487 - locked: thread_data.locked,
488 - pinned: thread_data.pinned,
489 - is_mod: mod_status,
490 - can_mod_thread: mod_status,
491 - is_tracked,
492 - posts,
493 - pagination: Pagination::new(page, total, per_page),
494 - })
495 - }
496 -
497 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>,