Skip to main content

max / multithreaded

Phases 13-24: immutable posts, endorsements, flagging, tags, tracking, mentions, link previews, profiles, search, image uploads, directory pagination, auto-hide threshold Immutable posts with author footnotes and verified quoting (SHA-256). Post endorsements (silent appreciation, visible to author/endorser/mods). Post flagging with mod queue (dismiss/remove workflow) and configurable auto-hide threshold per community. Community-scoped tags with thread assignment and category filtering. Server-side tracked threads with /tracked page and localStorage unread indicators. @mentions with profile links and post_mentions table. Server-side OpenGraph link previews with caching. Community user profiles with post history and endorsement counts. Fuzzy search modal (trigram + ts_vector). Image uploads via S3 with drag-drop and paste support. Forum directory pagination. Draft auto-save. Privacy/tracking transparency page. Opt-out for unread tracking. Split routes/forum.rs into routes/forum/ directory module. Added mt.js client-side JavaScript (drafts, search, quoting, unread, uploads). Migrations 011-020. 222 tests (150 integration + 56 unit lib + 16 unit mt-core). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-16 23:53 UTC
Commit: bd5c9150f157c0bdc04316b235c00f431790ce21
Parent: 188d0b0
73 files changed, +9028 insertions, -2031 deletions
M Cargo.lock +474 -5
@@ -120,6 +120,476 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
120 120 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
121 121
122 122 [[package]]
123 + name = "aws-config"
124 + version = "1.8.15"
125 + source = "registry+https://github.com/rust-lang/crates.io-index"
126 + checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc"
127 + dependencies = [
128 + "aws-credential-types",
129 + "aws-runtime",
130 + "aws-sdk-sso",
131 + "aws-sdk-ssooidc",
132 + "aws-sdk-sts",
133 + "aws-smithy-async",
134 + "aws-smithy-http 0.63.6",
135 + "aws-smithy-json 0.62.5",
136 + "aws-smithy-runtime",
137 + "aws-smithy-runtime-api",
138 + "aws-smithy-types",
139 + "aws-types",
140 + "bytes",
141 + "fastrand",
142 + "hex",
143 + "http 1.4.0",
144 + "sha1",
145 + "time",
146 + "tokio",
147 + "tracing",
148 + "url",
149 + "zeroize",
150 + ]
151 +
152 + [[package]]
153 + name = "aws-credential-types"
154 + version = "1.2.14"
155 + source = "registry+https://github.com/rust-lang/crates.io-index"
156 + checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7"
157 + dependencies = [
158 + "aws-smithy-async",
159 + "aws-smithy-runtime-api",
160 + "aws-smithy-types",
161 + "zeroize",
162 + ]
163 +
164 + [[package]]
165 + name = "aws-lc-rs"
166 + version = "1.16.1"
167 + source = "registry+https://github.com/rust-lang/crates.io-index"
168 + checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
169 + dependencies = [
170 + "aws-lc-sys",
171 + "zeroize",
172 + ]
173 +
174 + [[package]]
175 + name = "aws-lc-sys"
176 + version = "0.38.0"
177 + source = "registry+https://github.com/rust-lang/crates.io-index"
178 + checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
179 + dependencies = [
180 + "cc",
181 + "cmake",
182 + "dunce",
183 + "fs_extra",
184 + ]
185 +
186 + [[package]]
187 + name = "aws-runtime"
188 + version = "1.7.2"
189 + source = "registry+https://github.com/rust-lang/crates.io-index"
190 + checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17"
191 + dependencies = [
192 + "aws-credential-types",
193 + "aws-sigv4",
194 + "aws-smithy-async",
195 + "aws-smithy-eventstream",
196 + "aws-smithy-http 0.63.6",
197 + "aws-smithy-runtime",
198 + "aws-smithy-runtime-api",
199 + "aws-smithy-types",
200 + "aws-types",
201 + "bytes",
202 + "bytes-utils",
203 + "fastrand",
204 + "http 0.2.12",
205 + "http 1.4.0",
206 + "http-body 0.4.6",
207 + "http-body 1.0.1",
208 + "percent-encoding",
209 + "pin-project-lite",
210 + "tracing",
211 + "uuid",
212 + ]
213 +
214 + [[package]]
215 + name = "aws-sdk-s3"
216 + version = "1.119.0"
217 + source = "registry+https://github.com/rust-lang/crates.io-index"
218 + checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c"
219 + dependencies = [
220 + "aws-credential-types",
221 + "aws-runtime",
222 + "aws-sigv4",
223 + "aws-smithy-async",
224 + "aws-smithy-checksums",
225 + "aws-smithy-eventstream",
226 + "aws-smithy-http 0.62.6",
227 + "aws-smithy-json 0.61.9",
228 + "aws-smithy-runtime",
229 + "aws-smithy-runtime-api",
230 + "aws-smithy-types",
231 + "aws-smithy-xml",
232 + "aws-types",
233 + "bytes",
234 + "fastrand",
235 + "hex",
236 + "hmac",
237 + "http 0.2.12",
238 + "http 1.4.0",
239 + "http-body 0.4.6",
240 + "lru",
241 + "percent-encoding",
242 + "regex-lite",
243 + "sha2",
244 + "tracing",
245 + "url",
246 + ]
247 +
248 + [[package]]
249 + name = "aws-sdk-sso"
250 + version = "1.96.0"
251 + source = "registry+https://github.com/rust-lang/crates.io-index"
252 + checksum = "f64a6eded248c6b453966e915d32aeddb48ea63ad17932682774eb026fbef5b1"
253 + dependencies = [
254 + "aws-credential-types",
255 + "aws-runtime",
256 + "aws-smithy-async",
257 + "aws-smithy-http 0.63.6",
258 + "aws-smithy-json 0.62.5",
259 + "aws-smithy-observability",
260 + "aws-smithy-runtime",
261 + "aws-smithy-runtime-api",
262 + "aws-smithy-types",
263 + "aws-types",
264 + "bytes",
265 + "fastrand",
266 + "http 0.2.12",
267 + "http 1.4.0",
268 + "regex-lite",
269 + "tracing",
270 + ]
271 +
272 + [[package]]
273 + name = "aws-sdk-ssooidc"
274 + version = "1.98.0"
275 + source = "registry+https://github.com/rust-lang/crates.io-index"
276 + checksum = "db96d720d3c622fcbe08bae1c4b04a72ce6257d8b0584cb5418da00ae20a344f"
277 + dependencies = [
278 + "aws-credential-types",
279 + "aws-runtime",
280 + "aws-smithy-async",
281 + "aws-smithy-http 0.63.6",
282 + "aws-smithy-json 0.62.5",
283 + "aws-smithy-observability",
284 + "aws-smithy-runtime",
285 + "aws-smithy-runtime-api",
286 + "aws-smithy-types",
287 + "aws-types",
288 + "bytes",
289 + "fastrand",
290 + "http 0.2.12",
291 + "http 1.4.0",
292 + "regex-lite",
293 + "tracing",
294 + ]
295 +
296 + [[package]]
297 + name = "aws-sdk-sts"
298 + version = "1.100.0"
299 + source = "registry+https://github.com/rust-lang/crates.io-index"
300 + checksum = "fafbdda43b93f57f699c5dfe8328db590b967b8a820a13ccdd6687355dfcc7ca"
301 + dependencies = [
302 + "aws-credential-types",
303 + "aws-runtime",
304 + "aws-smithy-async",
305 + "aws-smithy-http 0.63.6",
306 + "aws-smithy-json 0.62.5",
307 + "aws-smithy-observability",
308 + "aws-smithy-query",
309 + "aws-smithy-runtime",
310 + "aws-smithy-runtime-api",
311 + "aws-smithy-types",
312 + "aws-smithy-xml",
313 + "aws-types",
314 + "fastrand",
315 + "http 0.2.12",
316 + "http 1.4.0",
317 + "regex-lite",
318 + "tracing",
319 + ]
320 +
321 + [[package]]
322 + name = "aws-sigv4"
323 + version = "1.4.2"
324 + source = "registry+https://github.com/rust-lang/crates.io-index"
325 + checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4"
326 + dependencies = [
327 + "aws-credential-types",
328 + "aws-smithy-eventstream",
329 + "aws-smithy-http 0.63.6",
330 + "aws-smithy-runtime-api",
331 + "aws-smithy-types",
332 + "bytes",
333 + "crypto-bigint 0.5.5",
334 + "form_urlencoded",
335 + "hex",
336 + "hmac",
337 + "http 0.2.12",
338 + "http 1.4.0",
339 + "p256",
340 + "percent-encoding",
341 + "ring",
342 + "sha2",
343 + "subtle",
344 + "time",
345 + "tracing",
346 + "zeroize",
347 + ]
348 +
349 + [[package]]
350 + name = "aws-smithy-async"
351 + version = "1.2.14"
352 + source = "registry+https://github.com/rust-lang/crates.io-index"
353 + checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc"
354 + dependencies = [
355 + "futures-util",
356 + "pin-project-lite",
357 + "tokio",
358 + ]
359 +
360 + [[package]]
361 + name = "aws-smithy-checksums"
362 + version = "0.63.12"
363 + source = "registry+https://github.com/rust-lang/crates.io-index"
364 + checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae"
365 + dependencies = [
366 + "aws-smithy-http 0.62.6",
367 + "aws-smithy-types",
368 + "bytes",
369 + "crc-fast",
370 + "hex",
371 + "http 0.2.12",
372 + "http-body 0.4.6",
373 + "md-5",
374 + "pin-project-lite",
375 + "sha1",
376 + "sha2",
377 + "tracing",
378 + ]
379 +
380 + [[package]]
381 + name = "aws-smithy-eventstream"
382 + version = "0.60.20"
383 + source = "registry+https://github.com/rust-lang/crates.io-index"
384 + checksum = "faf09d74e5e32f76b8762da505a3cd59303e367a664ca67295387baa8c1d7548"
385 + dependencies = [
386 + "aws-smithy-types",
387 + "bytes",
388 + "crc32fast",
389 + ]
390 +
391 + [[package]]
392 + name = "aws-smithy-http"
393 + version = "0.62.6"
394 + source = "registry+https://github.com/rust-lang/crates.io-index"
395 + checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b"
396 + dependencies = [
397 + "aws-smithy-eventstream",
398 + "aws-smithy-runtime-api",
399 + "aws-smithy-types",
400 + "bytes",
401 + "bytes-utils",
402 + "futures-core",
403 + "futures-util",
404 + "http 0.2.12",
405 + "http 1.4.0",
406 + "http-body 0.4.6",
407 + "percent-encoding",
408 + "pin-project-lite",
409 + "pin-utils",
410 + "tracing",
411 + ]
412 +
413 + [[package]]
414 + name = "aws-smithy-http"
415 + version = "0.63.6"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231"
418 + dependencies = [
419 + "aws-smithy-runtime-api",
420 + "aws-smithy-types",
421 + "bytes",
422 + "bytes-utils",
423 + "futures-core",
424 + "futures-util",
425 + "http 1.4.0",
426 + "http-body 1.0.1",
427 + "http-body-util",
428 + "percent-encoding",
429 + "pin-project-lite",
430 + "pin-utils",
431 + "tracing",
432 + ]
433 +
434 + [[package]]
435 + name = "aws-smithy-http-client"
436 + version = "1.1.12"
437 + source = "registry+https://github.com/rust-lang/crates.io-index"
438 + checksum = "6a2f165a7feee6f263028b899d0a181987f4fa7179a6411a32a439fba7c5f769"
439 + dependencies = [
440 + "aws-smithy-async",
441 + "aws-smithy-runtime-api",
442 + "aws-smithy-types",
443 + "h2 0.3.27",
444 + "h2 0.4.13",
445 + "http 0.2.12",
446 + "http 1.4.0",
447 + "http-body 0.4.6",
448 + "hyper 0.14.32",
449 + "hyper 1.8.1",
450 + "hyper-rustls 0.24.2",
451 + "hyper-rustls 0.27.7",
452 + "hyper-util",
453 + "pin-project-lite",
454 + "rustls 0.21.12",
455 + "rustls 0.23.37",
456 + "rustls-native-certs",
457 + "rustls-pki-types",
458 + "tokio",
459 + "tokio-rustls 0.26.4",
460 + "tower",
461 + "tracing",
462 + ]
463 +
464 + [[package]]
465 + name = "aws-smithy-json"
466 + version = "0.61.9"
467 + source = "registry+https://github.com/rust-lang/crates.io-index"
468 + checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551"
469 + dependencies = [
470 + "aws-smithy-types",
471 + ]
472 +
473 + [[package]]
474 + name = "aws-smithy-json"
475 + version = "0.62.5"
476 + source = "registry+https://github.com/rust-lang/crates.io-index"
477 + checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a"
478 + dependencies = [
479 + "aws-smithy-types",
480 + ]
481 +
482 + [[package]]
483 + name = "aws-smithy-observability"
484 + version = "0.2.6"
485 + source = "registry+https://github.com/rust-lang/crates.io-index"
486 + checksum = "a06c2315d173edbf1920da8ba3a7189695827002e4c0fc961973ab1c54abca9c"
487 + dependencies = [
488 + "aws-smithy-runtime-api",
489 + ]
490 +
491 + [[package]]
492 + name = "aws-smithy-query"
493 + version = "0.60.15"
494 + source = "registry+https://github.com/rust-lang/crates.io-index"
495 + checksum = "1a56d79744fb3edb5d722ef79d86081e121d3b9422cb209eb03aea6aa4f21ebd"
496 + dependencies = [
497 + "aws-smithy-types",
498 + "urlencoding",
499 + ]
500 +
501 + [[package]]
502 + name = "aws-smithy-runtime"
503 + version = "1.10.3"
504 + source = "registry+https://github.com/rust-lang/crates.io-index"
505 + checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110"
506 + dependencies = [
507 + "aws-smithy-async",
508 + "aws-smithy-http 0.63.6",
509 + "aws-smithy-http-client",
510 + "aws-smithy-observability",
511 + "aws-smithy-runtime-api",
512 + "aws-smithy-types",
513 + "bytes",
514 + "fastrand",
515 + "http 0.2.12",
516 + "http 1.4.0",
517 + "http-body 0.4.6",
518 + "http-body 1.0.1",
519 + "http-body-util",
520 + "pin-project-lite",
521 + "pin-utils",
522 + "tokio",
523 + "tracing",
524 + ]
525 +
526 + [[package]]
527 + name = "aws-smithy-runtime-api"
528 + version = "1.11.6"
529 + source = "registry+https://github.com/rust-lang/crates.io-index"
530 + checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6"
531 + dependencies = [
532 + "aws-smithy-async",
533 + "aws-smithy-types",
534 + "bytes",
535 + "http 0.2.12",
536 + "http 1.4.0",
537 + "pin-project-lite",
538 + "tokio",
539 + "tracing",
540 + "zeroize",
541 + ]
542 +
543 + [[package]]
544 + name = "aws-smithy-types"
545 + version = "1.4.7"
546 + source = "registry+https://github.com/rust-lang/crates.io-index"
547 + checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c"
548 + dependencies = [
549 + "base64-simd",
550 + "bytes",
551 + "bytes-utils",
552 + "futures-core",
553 + "http 0.2.12",
554 + "http 1.4.0",
555 + "http-body 0.4.6",
556 + "http-body 1.0.1",
557 + "http-body-util",
558 + "itoa",
559 + "num-integer",
560 + "pin-project-lite",
561 + "pin-utils",
562 + "ryu",
563 + "serde",
564 + "time",
565 + "tokio",
566 + "tokio-util",
567 + ]
568 +
569 + [[package]]
570 + name = "aws-smithy-xml"
571 + version = "0.60.15"
572 + source = "registry+https://github.com/rust-lang/crates.io-index"
573 + checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3"
574 + dependencies = [
575 + "xmlparser",
576 + ]
577 +
578 + [[package]]
579 + name = "aws-types"
580 + version = "1.3.14"
581 + source = "registry+https://github.com/rust-lang/crates.io-index"
582 + checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9"
583 + dependencies = [
584 + "aws-credential-types",
585 + "aws-smithy-async",
586 + "aws-smithy-runtime-api",
587 + "aws-smithy-types",
588 + "rustc_version",
589 + "tracing",
590 + ]
591 +
592 + [[package]]
123 593 name = "axum"
124 594 version = "0.8.8"
125 595 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -130,15 +600,16 @@ dependencies = [
130 600 "bytes",
131 601 "form_urlencoded",
132 602 "futures-util",
133 - "http",
134 - "http-body",
603 + "http 1.4.0",
604 + "http-body 1.0.1",
135 605 "http-body-util",
136 - "hyper",
606 + "hyper 1.8.1",
137 607 "hyper-util",
138 608 "itoa",
139 609 "matchit",
140 610 "memchr",
141 611 "mime",
612 + "multer",
142 613 "percent-encoding",
143 614 "pin-project-lite",
144 615 "serde_core",
@@ -163,8 +634,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
163 634 dependencies = [
164 635 "bytes",
165 636 "futures-core",
166 - "http",
167 - "http-body",
Lines truncated
M Cargo.toml +9 -2
@@ -21,9 +21,9 @@ tracing = "0.1"
21 21 tracing-subscriber = { version = "0.3", features = ["env-filter"] }
22 22
23 23 # Web
24 - axum = { version = "0.8", features = ["ws"] }
24 + axum = { version = "0.8", features = ["ws", "multipart"] }
25 25 tower = "0.5"
26 - tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
26 + tower-http = { version = "0.6", features = ["fs", "cors", "trace", "set-header"] }
27 27 tower-sessions = "0.14"
28 28 tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
29 29
@@ -33,6 +33,10 @@ sha2 = "0.10"
33 33 base64 = "0.22"
34 34 rand = "0.8"
35 35
36 + # S3 storage
37 + aws-sdk-s3 = "1.119"
38 + aws-config = { version = "1.8", features = ["behavior-version-latest"] }
39 +
36 40 # Database
37 41 sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
38 42
@@ -81,8 +85,11 @@ pulldown-cmark = { workspace = true }
81 85 ammonia = { workspace = true }
82 86 tower_governor = { workspace = true }
83 87 governor = { workspace = true }
88 + aws-sdk-s3 = { workspace = true }
89 + aws-config = { workspace = true }
84 90 dotenvy = "0.15"
85 91 hex = "0.4"
92 + regex-lite = "0.1"
86 93 urlencoding = "2"
87 94 time = "0.3"
88 95
@@ -4,6 +4,25 @@ use chrono::{DateTime, Utc};
4 4 use sqlx::PgPool;
5 5 use uuid::Uuid;
6 6
7 + /// Ensure a user has a membership in a community. Creates a 'member' role if none exists.
8 + #[tracing::instrument(skip_all)]
9 + pub async fn ensure_membership(
10 + pool: &PgPool,
11 + user_id: Uuid,
12 + community_id: Uuid,
13 + ) -> Result<(), sqlx::Error> {
14 + sqlx::query(
15 + "INSERT INTO memberships (user_id, community_id, role)
16 + VALUES ($1, $2, 'member')
17 + ON CONFLICT (user_id, community_id) DO NOTHING",
18 + )
19 + .bind(user_id)
20 + .bind(community_id)
21 + .execute(pool)
22 + .await?;
23 + Ok(())
24 + }
25 +
7 26 /// Insert a new thread and return its ID.
8 27 #[tracing::instrument(skip_all)]
9 28 pub async fn create_thread(
@@ -54,34 +73,42 @@ pub async fn create_post(
54 73 Ok(row.0)
55 74 }
56 75
57 - /// Update a post's body (markdown + html) and set edited_at.
76 + /// Insert a footnote on a post. Returns the footnote ID.
58 77 #[tracing::instrument(skip_all)]
59 - pub async fn update_post_body(
78 + pub async fn insert_footnote(
60 79 pool: &PgPool,
61 80 post_id: Uuid,
81 + author_id: Uuid,
62 82 body_markdown: &str,
63 83 body_html: &str,
64 - ) -> Result<(), sqlx::Error> {
65 - sqlx::query(
66 - "UPDATE posts SET body_markdown = $2, body_html = $3, edited_at = now()
67 - WHERE id = $1",
84 + ) -> Result<Uuid, sqlx::Error> {
85 + let row: (Uuid,) = sqlx::query_as(
86 + "INSERT INTO post_footnotes (post_id, author_id, body_markdown, body_html)
87 + VALUES ($1, $2, $3, $4)
88 + RETURNING id",
68 89 )
69 90 .bind(post_id)
91 + .bind(author_id)
70 92 .bind(body_markdown)
71 93 .bind(body_html)
72 - .execute(pool)
94 + .fetch_one(pool)
73 95 .await?;
74 - Ok(())
96 + Ok(row.0)
75 97 }
76 98
77 - /// Soft-delete a post: destroy content, set deleted_at.
99 + /// Mod-remove a post: set removed_by/removed_at. Content stays intact for audit.
78 100 #[tracing::instrument(skip_all)]
79 - pub async fn soft_delete_post(pool: &PgPool, post_id: Uuid) -> Result<(), sqlx::Error> {
101 + pub async fn mod_remove_post(
102 + pool: &PgPool,
103 + post_id: Uuid,
104 + removed_by_id: Uuid,
105 + ) -> Result<(), sqlx::Error> {
80 106 sqlx::query(
81 - "UPDATE posts SET body_markdown = '', body_html = '<p>[deleted]</p>', deleted_at = now()
107 + "UPDATE posts SET removed_by = $2, removed_at = now()
82 108 WHERE id = $1",
83 109 )
84 110 .bind(post_id)
111 + .bind(removed_by_id)
85 112 .execute(pool)
86 113 .await?;
87 114 Ok(())
@@ -149,13 +176,17 @@ pub async fn update_community(
149 176 community_id: Uuid,
150 177 name: &str,
151 178 description: Option<&str>,
179 + auto_hide_threshold: Option<i32>,
152 180 ) -> Result<(), sqlx::Error> {
153 - sqlx::query("UPDATE communities SET name = $2, description = $3 WHERE id = $1")
154 - .bind(community_id)
155 - .bind(name)
156 - .bind(description)
157 - .execute(pool)
158 - .await?;
181 + sqlx::query(
182 + "UPDATE communities SET name = $2, description = $3, auto_hide_threshold = $4 WHERE id = $1",
183 + )
184 + .bind(community_id)
185 + .bind(name)
186 + .bind(description)
187 + .bind(auto_hide_threshold)
188 + .execute(pool)
189 + .await?;
159 190 Ok(())
160 191 }
161 192
@@ -404,3 +435,328 @@ pub async fn unsuspend_user(
404 435 .await?;
405 436 Ok(())
406 437 }
438 +
439 + // ============================================================================
440 + // Tracked thread mutations
441 + // ============================================================================
442 +
443 + /// Track a thread (upsert).
444 + #[tracing::instrument(skip_all)]
445 + pub async fn track_thread(
446 + pool: &PgPool,
447 + user_id: Uuid,
448 + thread_id: Uuid,
449 + ) -> Result<(), sqlx::Error> {
450 + sqlx::query(
451 + "INSERT INTO tracked_threads (user_id, thread_id)
452 + VALUES ($1, $2)
453 + ON CONFLICT (user_id, thread_id) DO NOTHING",
454 + )
455 + .bind(user_id)
456 + .bind(thread_id)
457 + .execute(pool)
458 + .await?;
459 + Ok(())
460 + }
461 +
462 + /// Untrack a thread.
463 + #[tracing::instrument(skip_all)]
464 + pub async fn untrack_thread(
465 + pool: &PgPool,
466 + user_id: Uuid,
467 + thread_id: Uuid,
468 + ) -> Result<(), sqlx::Error> {
469 + sqlx::query(
470 + "DELETE FROM tracked_threads WHERE user_id = $1 AND thread_id = $2",
471 + )
472 + .bind(user_id)
473 + .bind(thread_id)
474 + .execute(pool)
475 + .await?;
476 + Ok(())
477 + }
478 +
479 + /// Stop tracking all threads for a user.
480 + #[tracing::instrument(skip_all)]
481 + pub async fn untrack_all(
482 + pool: &PgPool,
483 + user_id: Uuid,
484 + ) -> Result<(), sqlx::Error> {
485 + sqlx::query("DELETE FROM tracked_threads WHERE user_id = $1")
486 + .bind(user_id)
487 + .execute(pool)
488 + .await?;
489 + Ok(())
490 + }
491 +
492 + /// Update the read position for a tracked thread (set last_read_post_id to the last post).
493 + #[tracing::instrument(skip_all)]
494 + pub async fn update_read_position(
495 + pool: &PgPool,
496 + user_id: Uuid,
497 + thread_id: Uuid,
498 + last_post_id: Uuid,
499 + ) -> Result<(), sqlx::Error> {
500 + sqlx::query(
501 + "UPDATE tracked_threads SET last_read_post_id = $3
502 + WHERE user_id = $1 AND thread_id = $2",
503 + )
504 + .bind(user_id)
505 + .bind(thread_id)
506 + .bind(last_post_id)
507 + .execute(pool)
508 + .await?;
509 + Ok(())
510 + }
511 +
512 + // ============================================================================
513 + // Tag mutations
514 + // ============================================================================
515 +
516 + /// Create a tag in a community. Returns the tag ID.
517 + #[tracing::instrument(skip_all)]
518 + pub async fn create_tag(
519 + pool: &PgPool,
520 + community_id: Uuid,
521 + name: &str,
522 + slug: &str,
523 + ) -> Result<Uuid, sqlx::Error> {
524 + let row: (Uuid,) = sqlx::query_as(
525 + "INSERT INTO tags (community_id, name, slug) VALUES ($1, $2, $3) RETURNING id",
526 + )
527 + .bind(community_id)
528 + .bind(name)
529 + .bind(slug)
530 + .fetch_one(pool)
531 + .await?;
532 + Ok(row.0)
533 + }
534 +
535 + /// Delete a tag (CASCADE removes thread_tags rows).
536 + #[tracing::instrument(skip_all)]
537 + pub async fn delete_tag(pool: &PgPool, tag_id: Uuid) -> Result<(), sqlx::Error> {
538 + sqlx::query("DELETE FROM tags WHERE id = $1")
539 + .bind(tag_id)
540 + .execute(pool)
541 + .await?;
542 + Ok(())
543 + }
544 +
545 + /// Set the tags for a thread (delete existing + insert new, in a transaction).
546 + #[tracing::instrument(skip_all)]
547 + pub async fn set_thread_tags(
548 + pool: &PgPool,
549 + thread_id: Uuid,
550 + tag_ids: &[Uuid],
551 + ) -> Result<(), sqlx::Error> {
552 + let mut tx = pool.begin().await?;
553 + sqlx::query("DELETE FROM thread_tags WHERE thread_id = $1")
554 + .bind(thread_id)
555 + .execute(&mut *tx)
556 + .await?;
557 + for tag_id in tag_ids {
558 + sqlx::query("INSERT INTO thread_tags (thread_id, tag_id) VALUES ($1, $2)")
559 + .bind(thread_id)
560 + .bind(tag_id)
561 + .execute(&mut *tx)
562 + .await?;
563 + }
564 + tx.commit().await?;
565 + Ok(())
566 + }
567 +
568 + // ============================================================================
569 + // Flag mutations
570 + // ============================================================================
571 +
572 + /// Insert a flag on a post. ON CONFLICT DO NOTHING (idempotent per user+post).
573 + #[tracing::instrument(skip_all)]
574 + pub async fn insert_flag(
575 + pool: &PgPool,
576 + post_id: Uuid,
577 + flagger_id: Uuid,
578 + reason: &str,
579 + detail: Option<&str>,
580 + ) -> Result<(), sqlx::Error> {
581 + sqlx::query(
582 + "INSERT INTO post_flags (post_id, flagger_id, reason, detail)
583 + VALUES ($1, $2, $3, $4)
584 + ON CONFLICT (post_id, flagger_id) DO NOTHING",
585 + )
586 + .bind(post_id)
587 + .bind(flagger_id)
588 + .bind(reason)
589 + .bind(detail)
590 + .execute(pool)
591 + .await?;
592 + Ok(())
593 + }
594 +
595 + /// Resolve a single flag.
596 + #[tracing::instrument(skip_all)]
597 + pub async fn resolve_flag(
598 + pool: &PgPool,
599 + flag_id: Uuid,
600 + resolved_by: Uuid,
601 + resolution: &str,
602 + ) -> Result<(), sqlx::Error> {
603 + sqlx::query(
604 + "UPDATE post_flags SET resolved_at = now(), resolved_by = $2, resolution = $3
605 + WHERE id = $1 AND resolved_at IS NULL",
606 + )
607 + .bind(flag_id)
608 + .bind(resolved_by)
609 + .bind(resolution)
610 + .execute(pool)
611 + .await?;
612 + Ok(())
613 + }
614 +
615 + /// Resolve all unresolved flags for a given post.
616 + #[tracing::instrument(skip_all)]
617 + pub async fn resolve_all_flags_for_post(
618 + pool: &PgPool,
619 + post_id: Uuid,
620 + resolved_by: Uuid,
621 + resolution: &str,
622 + ) -> Result<(), sqlx::Error> {
623 + sqlx::query(
624 + "UPDATE post_flags SET resolved_at = now(), resolved_by = $2, resolution = $3
625 + WHERE post_id = $1 AND resolved_at IS NULL",
626 + )
627 + .bind(post_id)
628 + .bind(resolved_by)
629 + .bind(resolution)
630 + .execute(pool)
631 + .await?;
632 + Ok(())
633 + }
634 +
635 + // ============================================================================
636 + // Link preview mutations
637 + // ============================================================================
638 +
639 + /// Insert a link preview for a post. Ignores duplicates.
640 + #[tracing::instrument(skip_all)]
641 + pub async fn insert_link_preview(
642 + pool: &PgPool,
643 + post_id: Uuid,
644 + url: &str,
645 + title: Option<&str>,
646 + description: Option<&str>,
647 + ) -> Result<(), sqlx::Error> {
648 + sqlx::query(
649 + "INSERT INTO link_previews (post_id, url, title, description)
650 + VALUES ($1, $2, $3, $4)
651 + ON CONFLICT (post_id, url) DO NOTHING",
652 + )
653 + .bind(post_id)
654 + .bind(url)
655 + .bind(title)
656 + .bind(description)
657 + .execute(pool)
658 + .await?;
659 + Ok(())
660 + }
661 +
662 + // ============================================================================
663 + // Mention mutations
664 + // ============================================================================
665 +
666 + /// Insert mention rows for a post. Ignores duplicates.
667 + #[tracing::instrument(skip_all)]
668 + pub async fn insert_mentions(
669 + pool: &PgPool,
670 + post_id: Uuid,
671 + user_ids: &[Uuid],
672 + ) -> Result<(), sqlx::Error> {
673 + for user_id in user_ids {
674 + sqlx::query(
675 + "INSERT INTO post_mentions (post_id, mentioned_user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
676 + )
677 + .bind(post_id)
678 + .bind(user_id)
679 + .execute(pool)
680 + .await?;
681 + }
682 + Ok(())
683 + }
684 +
685 + // ============================================================================
686 + // Endorsement mutations
687 + // ============================================================================
688 +
689 + /// Toggle endorsement: insert if missing, delete if exists. Returns true if now endorsed.
690 + #[tracing::instrument(skip_all)]
691 + pub async fn toggle_endorsement(
692 + pool: &PgPool,
693 + post_id: Uuid,
694 + endorser_id: Uuid,
695 + ) -> Result<bool, sqlx::Error> {
696 + let result = sqlx::query(
697 + "INSERT INTO post_endorsements (post_id, endorser_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
698 + )
699 + .bind(post_id)
700 + .bind(endorser_id)
701 + .execute(pool)
702 + .await?;
703 +
704 + if result.rows_affected() == 0 {
705 + // Already existed — remove it
706 + sqlx::query("DELETE FROM post_endorsements WHERE post_id = $1 AND endorser_id = $2")
707 + .bind(post_id)
708 + .bind(endorser_id)
709 + .execute(pool)
710 + .await?;
711 + Ok(false)
712 + } else {
713 + Ok(true)
714 + }
715 + }
716 +
717 + // ============================================================================
718 + // Image uploads
719 + // ============================================================================
720 +
721 + /// Insert an uploaded image record.
722 + #[tracing::instrument(skip_all)]
723 + pub async fn insert_image(
724 + pool: &PgPool,
725 + uploader_id: Uuid,
726 + community_id: Uuid,
727 + s3_key: &str,
728 + filename: &str,
729 + content_type: &str,
730 + size_bytes: i64,
731 + ) -> Result<Uuid, sqlx::Error> {
732 + sqlx::query_scalar(
733 + "INSERT INTO images (uploader_id, community_id, s3_key, filename, content_type, size_bytes)
734 + VALUES ($1, $2, $3, $4, $5, $6)
735 + RETURNING id",
736 + )
737 + .bind(uploader_id)
738 + .bind(community_id)
739 + .bind(s3_key)
740 + .bind(filename)
741 + .bind(content_type)
742 + .bind(size_bytes)
743 + .fetch_one(pool)
744 + .await
745 + }
746 +
747 + /// Mark an image as removed by a moderator.
748 + #[tracing::instrument(skip_all)]
749 + pub async fn remove_image(
750 + pool: &PgPool,
751 + image_id: Uuid,
752 + removed_by: Uuid,
753 + ) -> Result<(), sqlx::Error> {
754 + sqlx::query(
755 + "UPDATE images SET removed_at = now(), removed_by = $2 WHERE id = $1 AND removed_at IS NULL",
756 + )
757 + .bind(image_id)
758 + .bind(removed_by)
759 + .execute(pool)
760 + .await?;
761 + Ok(())
762 + }
@@ -15,6 +15,7 @@ pub struct CommunityRow {
15 15 pub slug: String,
16 16 pub description: Option<String>,
17 17 pub suspended_at: Option<DateTime<Utc>>,
18 + pub auto_hide_threshold: Option<i32>,
18 19 }
19 20
20 21 #[derive(sqlx::FromRow)]
@@ -67,6 +68,18 @@ pub struct PostWithAuthor {
67 68 pub created_at: DateTime<Utc>,
68 69 pub edited_at: Option<DateTime<Utc>>,
69 70 pub deleted_at: Option<DateTime<Utc>>,
71 + pub removed_at: Option<DateTime<Utc>>,
72 + }
73 +
74 + #[derive(sqlx::FromRow)]
75 + pub struct FootnoteWithAuthor {
76 + pub id: Uuid,
77 + pub post_id: Uuid,
78 + pub author_id: Uuid,
79 + pub author_name: String,
80 + pub author_username: String,
81 + pub body_html: String,
82 + pub created_at: DateTime<Utc>,
70 83 }
71 84
72 85 #[derive(sqlx::FromRow)]
@@ -98,9 +111,9 @@ pub struct CommunityListRow {
98 111 pub thread_count: i64,
99 112 }
100 113
101 - /// List all non-suspended communities with category and thread counts.
114 + /// List non-suspended communities with category and thread counts (paginated).
102 115 #[tracing::instrument(skip_all)]
103 - pub async fn list_communities(pool: &PgPool) -> Result<Vec<CommunityListRow>, sqlx::Error> {
116 + pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<CommunityListRow>, sqlx::Error> {
104 117 sqlx::query_as::<_, CommunityListRow>(
105 118 "SELECT co.name, co.slug, co.description,
106 119 COUNT(DISTINCT c.id) AS category_count,
@@ -110,19 +123,30 @@ pub async fn list_communities(pool: &PgPool) -> Result<Vec<CommunityListRow>, sq
110 123 LEFT JOIN threads t ON t.category_id = c.id
111 124 WHERE co.suspended_at IS NULL
112 125 GROUP BY co.id
113 - ORDER BY co.name",
126 + ORDER BY co.name
127 + LIMIT $1 OFFSET $2",
114 128 )
129 + .bind(limit)
130 + .bind(offset)
115 131 .fetch_all(pool)
116 132 .await
117 133 }
118 134
135 + /// Count non-suspended communities.
136 + #[tracing::instrument(skip_all)]
137 + pub async fn count_communities(pool: &PgPool) -> Result<i64, sqlx::Error> {
138 + sqlx::query_scalar("SELECT COUNT(*) FROM communities WHERE suspended_at IS NULL")
139 + .fetch_one(pool)
140 + .await
141 + }
142 +
119 143 #[tracing::instrument(skip_all)]
120 144 pub async fn get_community_by_slug(
121 145 pool: &PgPool,
122 146 slug: &str,
123 147 ) -> Result<Option<CommunityRow>, sqlx::Error> {
124 148 sqlx::query_as::<_, CommunityRow>(
125 - "SELECT id, name, slug, description, suspended_at FROM communities WHERE slug = $1",
149 + "SELECT id, name, slug, description, suspended_at, auto_hide_threshold FROM communities WHERE slug = $1",
126 150 )
127 151 .bind(slug)
128 152 .fetch_optional(pool)
@@ -296,7 +320,8 @@ pub async fn list_posts_in_thread(
296 320 "SELECT p.id, p.author_id,
297 321 COALESCE(u.display_name, u.username) AS author_name,
298 322 u.username AS author_username,
299 - p.body_html, p.created_at, p.edited_at, p.deleted_at
323 + p.body_html, p.created_at, p.edited_at, p.deleted_at,
324 + p.removed_at
300 325 FROM posts p
301 326 JOIN users u ON u.mnw_account_id = p.author_id
302 327 WHERE p.thread_id = $1
@@ -318,7 +343,8 @@ pub async fn list_posts_in_thread_paginated(
318 343 "SELECT p.id, p.author_id,
319 344 COALESCE(u.display_name, u.username) AS author_name,
320 345 u.username AS author_username,
321 - p.body_html, p.created_at, p.edited_at, p.deleted_at
346 + p.body_html, p.created_at, p.edited_at, p.deleted_at,
347 + p.removed_at
322 348 FROM posts p
323 349 JOIN users u ON u.mnw_account_id = p.author_id
324 350 WHERE p.thread_id = $1
@@ -345,6 +371,23 @@ pub async fn count_posts_in_thread(
345 371 .await
346 372 }
347 373
374 + /// Count posts + footnotes by a user in the last N seconds (for per-user rate limiting).
375 + #[tracing::instrument(skip_all)]
376 + pub async fn count_recent_posts_by_user(
377 + pool: &PgPool,
378 + user_id: Uuid,
379 + seconds: i64,
380 + ) -> Result<i64, sqlx::Error> {
381 + sqlx::query_scalar(
382 + "SELECT (SELECT COUNT(*) FROM posts WHERE author_id = $1 AND created_at > NOW() - make_interval(secs => $2))
383 + + (SELECT COUNT(*) FROM post_footnotes WHERE author_id = $1 AND created_at > NOW() - make_interval(secs => $2))",
384 + )
385 + .bind(user_id)
386 + .bind(seconds as f64)
387 + .fetch_one(pool)
388 + .await
389 + }
390 +
348 391 #[tracing::instrument(skip_all)]
349 392 pub async fn get_post_for_edit(
350 393 pool: &PgPool,
@@ -459,9 +502,76 @@ pub async fn get_category_by_id(
459 502 }
460 503
461 504 // ============================================================================
505 + // Footnote queries
506 + // ============================================================================
507 +
508 + /// Batch-fetch footnotes for a set of post IDs, joined with author info.
509 + #[tracing::instrument(skip_all)]
510 + pub async fn list_footnotes_for_posts(
511 + pool: &PgPool,
512 + post_ids: &[Uuid],
513 + ) -> Result<Vec<FootnoteWithAuthor>, sqlx::Error> {
514 + sqlx::query_as::<_, FootnoteWithAuthor>(
515 + "SELECT f.id, f.post_id, f.author_id,
516 + COALESCE(u.display_name, u.username) AS author_name,
517 + u.username AS author_username,
518 + f.body_html, f.created_at
519 + FROM post_footnotes f
520 + JOIN users u ON u.mnw_account_id = f.author_id
521 + WHERE f.post_id = ANY($1)
522 + ORDER BY f.created_at",
523 + )
524 + .bind(post_ids)
525 + .fetch_all(pool)
526 + .await
527 + }
528 +
529 + /// Count footnotes on a specific post.
530 + #[tracing::instrument(skip_all)]
531 + pub async fn count_footnotes_for_post(
532 + pool: &PgPool,
533 + post_id: Uuid,
534 + ) -> Result<i64, sqlx::Error> {
535 + sqlx::query_scalar("SELECT COUNT(*) FROM post_footnotes WHERE post_id = $1")
536 + .bind(post_id)
537 + .fetch_one(pool)
538 + .await
539 + }
540 +
541 + /// Fetch a post's author_id and body_markdown for quote verification.
542 + #[tracing::instrument(skip_all)]
543 + pub async fn get_post_body_markdown(
544 + pool: &PgPool,
545 + post_id: Uuid,
546 + ) -> Result<Option<(Uuid, String)>, sqlx::Error> {
547 + let row: Option<(Uuid, String)> = sqlx::query_as(
548 + "SELECT author_id, body_markdown FROM posts WHERE id = $1",
549 + )
550 + .bind(post_id)
551 + .fetch_optional(pool)
552 + .await?;
553 + Ok(row)
554 + }
555 +
556 + // ============================================================================
462 557 // Ban / mute queries
463 558 // ============================================================================
464 559
560 + /// Check if a user is platform-suspended (by admin).
561 + #[tracing::instrument(skip_all)]
562 + pub async fn is_user_suspended(
563 + pool: &PgPool,
564 + user_id: Uuid,
565 + ) -> Result<bool, sqlx::Error> {
566 + let count: i64 = sqlx::query_scalar(
567 + "SELECT COUNT(*) FROM users WHERE mnw_account_id = $1 AND suspended_at IS NOT NULL",
568 + )
569 + .bind(user_id)
570 + .fetch_one(pool)
571 + .await?;
572 + Ok(count > 0)
573 + }
574 +
465 575 /// Check if user has an active ban in a community.
466 576 #[tracing::instrument(skip_all)]
467 577 pub async fn is_user_banned(
@@ -617,6 +727,7 @@ pub struct UserProfileRow {
617 727 pub role: String,
618 728 pub joined_at: DateTime<Utc>,
619 729 pub post_count: i64,
730 + pub endorsement_count: i64,
620 731 }
621 732
622 733 /// Fetch a user's profile within a specific community.
@@ -640,7 +751,15 @@ pub async fn get_user_profile_in_community(
640 751 WHERE p.author_id = u.mnw_account_id
641 752 AND c.community_id = co.id
642 753 AND p.deleted_at IS NULL
643 - AND t.deleted_at IS NULL) AS post_count
754 + AND t.deleted_at IS NULL) AS post_count,
755 + (SELECT COUNT(*) FROM post_endorsements pe
756 + JOIN posts p ON p.id = pe.post_id
757 + JOIN threads t ON t.id = p.thread_id
758 + JOIN categories c ON c.id = t.category_id
759 + WHERE p.author_id = u.mnw_account_id
760 + AND c.community_id = co.id
761 + AND p.deleted_at IS NULL
762 + AND t.deleted_at IS NULL) AS endorsement_count
644 763 FROM users u
645 764 JOIN memberships m ON m.user_id = u.mnw_account_id
646 765 JOIN communities co ON co.id = m.community_id
@@ -784,3 +903,531 @@ pub async fn search_users(
784 903 .fetch_all(pool)
785 904 .await
786 905 }
906 +
907 + // ============================================================================
908 + // Link preview queries
909 + // ============================================================================
910 +
911 + #[derive(sqlx::FromRow)]
912 + pub struct LinkPreviewRow {
913 + pub post_id: Uuid,
914 + pub url: String,
915 + pub title: Option<String>,
916 + pub description: Option<String>,
917 + }
918 +
919 + /// Batch-fetch link previews for a set of post IDs.
920 + #[tracing::instrument(skip_all)]
921 + pub async fn list_link_previews_for_posts(
922 + pool: &PgPool,
923 + post_ids: &[Uuid],
924 + ) -> Result<Vec<LinkPreviewRow>, sqlx::Error> {
925 + sqlx::query_as::<_, LinkPreviewRow>(
926 + "SELECT post_id, url, title, description
927 + FROM link_previews
928 + WHERE post_id = ANY($1)
929 + ORDER BY fetched_at",
930 + )
931 + .bind(post_ids)
932 + .fetch_all(pool)
933 + .await
934 + }
935 +
936 + // ============================================================================
937 + // Mention queries
938 + // ============================================================================
939 +
940 + /// Batch-check which threads have at least one mention of the given user.
941 + #[tracing::instrument(skip_all)]
942 + pub async fn get_threads_with_mentions_for_user(
943 + pool: &PgPool,
944 + user_id: Uuid,
945 + thread_ids: &[Uuid],
946 + ) -> Result<Vec<Uuid>, sqlx::Error> {
947 + if thread_ids.is_empty() {
948 + return Ok(Vec::new());
949 + }
950 + sqlx::query_scalar(
951 + "SELECT DISTINCT p.thread_id
952 + FROM post_mentions pm
953 + JOIN posts p ON p.id = pm.post_id
954 + WHERE pm.mentioned_user_id = $1
955 + AND p.thread_id = ANY($2)",
956 + )
957 + .bind(user_id)
958 + .bind(thread_ids)
959 + .fetch_all(pool)
960 + .await
961 + }
962 +
963 + /// Resolve usernames to user IDs, filtered to community members.
964 + #[tracing::instrument(skip_all)]
965 + pub async fn resolve_usernames_in_community(
966 + pool: &PgPool,
967 + community_id: Uuid,
968 + usernames: &[String],
969 + ) -> Result<std::collections::HashMap<String, Uuid>, sqlx::Error> {
970 + if usernames.is_empty() {
971 + return Ok(std::collections::HashMap::new());
972 + }
973 + let rows: Vec<(String, Uuid)> = sqlx::query_as(
974 + "SELECT u.username, u.mnw_account_id
975 + FROM users u
976 + WHERE u.username = ANY($1)
977 + AND u.mnw_account_id IN (SELECT user_id FROM memberships WHERE community_id = $2)",
978 + )
979 + .bind(usernames)
980 + .bind(community_id)
981 + .fetch_all(pool)
982 + .await?;
983 + Ok(rows.into_iter().collect())
984 + }
985 +
986 + // ============================================================================
987 + // Endorsement queries
988 + // ============================================================================
989 +
990 + #[derive(sqlx::FromRow)]
991 + pub struct EndorsementRow {
992 + pub post_id: Uuid,
993 + pub endorser_id: Uuid,
994 + }
995 +
996 + // ============================================================================
997 + // Tracked thread queries
998 + // ============================================================================
999 +
1000 + /// Check if a user is tracking a specific thread.
1001 + #[tracing::instrument(skip_all)]
1002 + pub async fn is_thread_tracked(
1003 + pool: &PgPool,
1004 + user_id: Uuid,
1005 + thread_id: Uuid,
1006 + ) -> Result<bool, sqlx::Error> {
1007 + let count: i64 = sqlx::query_scalar(
1008 + "SELECT COUNT(*) FROM tracked_threads WHERE user_id = $1 AND thread_id = $2",
1009 + )
1010 + .bind(user_id)
1011 + .bind(thread_id)
1012 + .fetch_one(pool)
1013 + .await?;
1014 + Ok(count > 0)
1015 + }
1016 +
1017 + #[derive(sqlx::FromRow)]
1018 + pub struct TrackedThreadRow {
1019 + pub thread_id: Uuid,
1020 + pub thread_title: String,
1021 + pub community_name: String,
1022 + pub community_slug: String,
1023 + pub category_slug: String,
1024 + pub unread_count: i64,
1025 + pub has_mention: bool,
1026 + pub tracked_at: DateTime<Utc>,
1027 + }
1028 +
1029 + /// List a user's tracked threads with unread post counts.
1030 + #[tracing::instrument(skip_all)]
1031 + pub async fn list_tracked_threads(
1032 + pool: &PgPool,
1033 + user_id: Uuid,
1034 + ) -> Result<Vec<TrackedThreadRow>, sqlx::Error> {
1035 + sqlx::query_as::<_, TrackedThreadRow>(
1036 + "SELECT tt.thread_id,
1037 + t.title AS thread_title,
1038 + co.name AS community_name,
1039 + co.slug AS community_slug,
1040 + cat.slug AS category_slug,
1041 + (SELECT COUNT(*) FROM posts p
1042 + WHERE p.thread_id = tt.thread_id
1043 + AND (tt.last_read_post_id IS NULL OR p.created_at > (
1044 + SELECT created_at FROM posts WHERE id = tt.last_read_post_id
1045 + ))
1046 + ) AS unread_count,
1047 + EXISTS (
1048 + SELECT 1 FROM post_mentions pm
1049 + JOIN posts p ON p.id = pm.post_id
1050 + WHERE pm.mentioned_user_id = tt.user_id
1051 + AND p.thread_id = tt.thread_id
1052 + ) AS has_mention,
1053 + tt.tracked_at
1054 + FROM tracked_threads tt
1055 + JOIN threads t ON t.id = tt.thread_id
1056 + JOIN categories cat ON cat.id = t.category_id
1057 + JOIN communities co ON co.id = cat.community_id
1058 + WHERE tt.user_id = $1 AND t.deleted_at IS NULL AND co.suspended_at IS NULL
1059 + ORDER BY t.last_activity_at DESC",
1060 + )
1061 + .bind(user_id)
1062 + .fetch_all(pool)
1063 + .await
1064 + }
1065 +
1066 + // ============================================================================
1067 + // Tag queries
1068 + // ============================================================================
1069 +
1070 + #[derive(sqlx::FromRow)]
1071 + pub struct TagRow {
1072 + pub id: Uuid,
1073 + pub name: String,
1074 + pub slug: String,
1075 + }
1076 +
1077 + /// List all tags for a community.
1078 + #[tracing::instrument(skip_all)]
1079 + pub async fn list_tags_for_community(
1080 + pool: &PgPool,
1081 + community_id: Uuid,
1082 + ) -> Result<Vec<TagRow>, sqlx::Error> {
1083 + sqlx::query_as::<_, TagRow>(
1084 + "SELECT id, name, slug FROM tags WHERE community_id = $1 ORDER BY name",
1085 + )
1086 + .bind(community_id)
1087 + .fetch_all(pool)
1088 + .await
1089 + }
1090 +
1091 + #[derive(sqlx::FromRow)]
1092 + pub struct ThreadTagRow {
1093 + pub thread_id: Uuid,
1094 + pub tag_name: String,
1095 + pub tag_slug: String,
1096 + }
1097 +
1098 + /// Batch-fetch tags for a set of thread IDs.
1099 + #[tracing::instrument(skip_all)]
1100 + pub async fn list_tags_for_threads(
1101 + pool: &PgPool,
1102 + thread_ids: &[Uuid],
1103 + ) -> Result<Vec<ThreadTagRow>, sqlx::Error> {
1104 + sqlx::query_as::<_, ThreadTagRow>(
1105 + "SELECT tt.thread_id, t.name AS tag_name, t.slug AS tag_slug
1106 + FROM thread_tags tt
1107 + JOIN tags t ON t.id = tt.tag_id
1108 + WHERE tt.thread_id = ANY($1)
1109 + ORDER BY t.name",
1110 + )
1111 + .bind(thread_ids)
1112 + .fetch_all(pool)
1113 + .await
1114 + }
1115 +
1116 + /// Count threads in a category, optionally filtered by tag slug.
1117 + #[tracing::instrument(skip_all)]
1118 + pub async fn count_threads_in_category_filtered(
1119 + pool: &PgPool,
1120 + community_slug: &str,
1121 + category_slug: &str,
1122 + tag_slug: Option<&str>,
1123 + ) -> Result<i64, sqlx::Error> {
1124 + if let Some(tag) = tag_slug {
1125 + sqlx::query_scalar(
1126 + "SELECT COUNT(DISTINCT t.id)
1127 + FROM threads t
1128 + JOIN categories c ON c.id = t.category_id
1129 + JOIN communities co ON co.id = c.community_id
1130 + JOIN thread_tags tt ON tt.thread_id = t.id
1131 + JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id
1132 + WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL",
1133 + )
1134 + .bind(community_slug)
1135 + .bind(category_slug)
1136 + .bind(tag)
1137 + .fetch_one(pool)
1138 + .await
1139 + } else {
1140 + count_threads_in_category(pool, community_slug, category_slug).await
1141 + }
1142 + }
1143 +
1144 + /// List threads with sorting, optionally filtered by tag slug.
1145 + #[tracing::instrument(skip_all)]
1146 + #[allow(clippy::too_many_arguments)]
1147 + pub async fn list_threads_in_category_sorted_filtered(
1148 + pool: &PgPool,
1149 + community_slug: &str,
1150 + category_slug: &str,
1151 + sort: &str,
1152 + order: &str,
1153 + limit: i64,
1154 + offset: i64,
1155 + tag_slug: Option<&str>,
1156 + ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> {
1157 + if tag_slug.is_none() {
1158 + return list_threads_in_category_sorted(pool, community_slug, category_slug, sort, order, limit, offset).await;
1159 + }
1160 +
1161 + let tag = tag_slug.unwrap();
1162 + let order_clause = match (sort, order) {
1163 + ("replies", "asc") => "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC",
1164 + ("replies", _) => "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC",
1165 + (_, "asc") => "ORDER BY t.pinned DESC, t.last_activity_at ASC",
1166 + _ => "ORDER BY t.pinned DESC, t.last_activity_at DESC",
1167 + };
1168 +
1169 + let query = format!(
1170 + "SELECT t.id, t.title,
1171 + COALESCE(u.display_name, u.username) AS author_name,
1172 + u.username AS author_username,
1173 + (COUNT(p.id) - 1) AS reply_count,
1174 + t.last_activity_at,
1175 + t.pinned, t.locked
1176 + FROM threads t
1177 + JOIN categories c ON c.id = t.category_id
1178 + JOIN communities co ON co.id = c.community_id
1179 + JOIN users u ON u.mnw_account_id = t.author_id
1180 + LEFT JOIN posts p ON p.thread_id = t.id
1181 + JOIN thread_tags tt ON tt.thread_id = t.id
1182 + JOIN tags tg ON tg.id = tt.tag_id AND tg.slug = $3 AND tg.community_id = co.id
1183 + WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL
1184 + GROUP BY t.id, t.title, u.display_name, u.username,
1185 + t.last_activity_at, t.pinned, t.locked
1186 + {order_clause}
1187 + LIMIT $4 OFFSET $5"
1188 + );
1189 +
1190 + sqlx::query_as::<_, ThreadWithMeta>(&query)
1191 + .bind(community_slug)
1192 + .bind(category_slug)
1193 + .bind(tag)
1194 + .bind(limit)
Lines truncated
@@ -0,0 +1,16 @@
1 + -- Phase 14: Immutable posts + footnotes
2 + -- Posts become permanent records. Corrections via author footnotes.
3 + -- Mod removal preserves content in DB for audit.
4 +
5 + CREATE TABLE post_footnotes (
6 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7 + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
8 + author_id UUID NOT NULL REFERENCES users(mnw_account_id),
9 + body_markdown TEXT NOT NULL,
10 + body_html TEXT NOT NULL,
11 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
12 + );
13 + CREATE INDEX idx_post_footnotes_post_id ON post_footnotes(post_id);
14 +
15 + ALTER TABLE posts ADD COLUMN removed_by UUID REFERENCES users(mnw_account_id);
16 + ALTER TABLE posts ADD COLUMN removed_at TIMESTAMPTZ;
@@ -0,0 +1,8 @@
1 + CREATE TABLE post_endorsements (
2 + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
3 + endorser_id UUID NOT NULL REFERENCES users(mnw_account_id),
4 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
5 + PRIMARY KEY (post_id, endorser_id)
6 + );
7 +
8 + CREATE INDEX idx_post_endorsements_endorser ON post_endorsements(endorser_id);
@@ -0,0 +1,14 @@
1 + CREATE TABLE post_flags (
2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
4 + flagger_id UUID NOT NULL REFERENCES users(mnw_account_id),
5 + reason TEXT NOT NULL CHECK (reason IN ('spam', 'rule_breaking', 'off_topic')),
6 + detail TEXT,
7 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8 + resolved_at TIMESTAMPTZ,
9 + resolved_by UUID REFERENCES users(mnw_account_id),
10 + resolution TEXT CHECK (resolution IN ('dismissed', 'removed')),
11 + UNIQUE (post_id, flagger_id)
12 + );
13 + CREATE INDEX idx_post_flags_post ON post_flags(post_id);
14 + CREATE INDEX idx_post_flags_unresolved ON post_flags(resolved_at) WHERE resolved_at IS NULL;
@@ -0,0 +1,14 @@
1 + CREATE TABLE tags (
2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 + community_id UUID NOT NULL REFERENCES communities(id) ON DELETE CASCADE,
4 + name TEXT NOT NULL,
5 + slug TEXT NOT NULL,
6 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
7 + UNIQUE (community_id, slug)
8 + );
9 + CREATE TABLE thread_tags (
10 + thread_id UUID NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
11 + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
12 + PRIMARY KEY (thread_id, tag_id)
13 + );
14 + CREATE INDEX idx_thread_tags_tag ON thread_tags(tag_id);
@@ -0,0 +1,8 @@
1 + CREATE TABLE tracked_threads (
2 + user_id UUID NOT NULL REFERENCES users(mnw_account_id) ON DELETE CASCADE,
3 + thread_id UUID NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
4 + last_read_post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
5 + tracked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
6 + PRIMARY KEY (user_id, thread_id)
7 + );
8 + CREATE INDEX idx_tracked_threads_user ON tracked_threads(user_id);
@@ -0,0 +1,7 @@
1 + CREATE TABLE post_mentions (
2 + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
3 + mentioned_user_id UUID NOT NULL REFERENCES users(mnw_account_id) ON DELETE CASCADE,
4 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
5 + PRIMARY KEY (post_id, mentioned_user_id)
6 + );
7 + CREATE INDEX idx_post_mentions_user ON post_mentions(mentioned_user_id);
@@ -0,0 +1,10 @@
1 + CREATE TABLE link_previews (
2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
4 + url TEXT NOT NULL,
5 + title TEXT,
6 + description TEXT,
7 + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8 + UNIQUE (post_id, url)
9 + );
10 + CREATE INDEX idx_link_previews_post ON link_previews(post_id);
@@ -0,0 +1,16 @@
1 + -- Full-text search: pg_trgm for fuzzy matching + tsvector for ranking.
2 +
3 + CREATE EXTENSION IF NOT EXISTS pg_trgm;
4 +
5 + -- Trigram GIN indexes for ILIKE/similarity queries
6 + CREATE INDEX idx_threads_title_trgm ON threads USING GIN (title gin_trgm_ops);
7 + CREATE INDEX idx_posts_body_trgm ON posts USING GIN (body_markdown gin_trgm_ops);
8 +
9 + -- tsvector columns for full-text search ranking
10 + ALTER TABLE threads ADD COLUMN search_tsv tsvector
11 + GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
12 + ALTER TABLE posts ADD COLUMN search_tsv tsvector
13 + GENERATED ALWAYS AS (to_tsvector('english', body_markdown)) STORED;
14 +
15 + CREATE INDEX idx_threads_search_tsv ON threads USING GIN (search_tsv);
16 + CREATE INDEX idx_posts_search_tsv ON posts USING GIN (search_tsv);
@@ -0,0 +1,17 @@
1 + -- Image uploads for forum posts
2 + CREATE TABLE images (
3 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
4 + uploader_id UUID NOT NULL REFERENCES users(mnw_account_id),
5 + community_id UUID NOT NULL REFERENCES communities(id),
6 + s3_key TEXT NOT NULL,
7 + filename TEXT NOT NULL,
8 + content_type TEXT NOT NULL,
9 + size_bytes BIGINT NOT NULL,
10 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
11 + removed_at TIMESTAMPTZ,
12 + removed_by UUID REFERENCES users(mnw_account_id)
13 + );
14 +
15 + CREATE INDEX idx_images_uploader ON images(uploader_id);
16 + CREATE INDEX idx_images_community ON images(community_id);
17 + CREATE INDEX idx_images_s3_key ON images(s3_key);
@@ -0,0 +1,3 @@
1 + -- Per-community auto-hide threshold for flagged posts.
2 + -- NULL = disabled. When set, posts with >= threshold pending flags are auto-hidden.
3 + ALTER TABLE communities ADD COLUMN auto_hide_threshold INTEGER;
@@ -11,6 +11,28 @@ pub struct Config {
11 11 /// Whether to set the `Secure` flag on session cookies.
12 12 /// Defaults to `true`. Set `COOKIE_SECURE=false` for local HTTP development.
13 13 pub cookie_secure: bool,
14 + /// S3 storage configuration. None if S3 env vars are missing.
15 + pub s3: Option<S3Config>,
16 + }
17 +
18 + #[derive(Clone)]
19 + pub struct S3Config {
20 + pub endpoint: String,
21 + pub bucket: String,
22 + pub access_key: String,
23 + pub secret_key: String,
24 + pub region: String,
25 + }
26 +
27 + impl S3Config {
28 + fn from_env() -> Option<Self> {
29 + let endpoint = std::env::var("S3_ENDPOINT").ok()?;
30 + let bucket = std::env::var("S3_BUCKET").ok()?;
31 + let access_key = std::env::var("S3_ACCESS_KEY").ok()?;
32 + let secret_key = std::env::var("S3_SECRET_KEY").ok()?;
33 + let region = std::env::var("S3_REGION").unwrap_or_else(|_| "us-east-1".to_string());
34 + Some(Self { endpoint, bucket, access_key, secret_key, region })
35 + }
14 36 }
15 37
16 38 impl Config {
@@ -28,6 +50,7 @@ impl Config {
28 50 cookie_secure: std::env::var("COOKIE_SECURE")
29 51 .map(|v| v != "false")
30 52 .unwrap_or(true),
53 + s3: S3Config::from_env(),
31 54 }
32 55 }
33 56 }
M src/lib.rs +4
@@ -3,13 +3,16 @@
3 3 pub mod auth;
4 4 pub mod config;
5 5 pub mod csrf;
6 + pub mod link_preview;
6 7 pub mod markdown;
7 8 pub mod routes;
8 9 pub mod seed;
10 + pub mod storage;
9 11 pub mod templates;
10 12
11 13 use config::Config;
12 14 use sqlx::PgPool;
15 + use std::sync::Arc;
13 16
14 17 /// Shared application state available to all handlers.
15 18 #[derive(Clone)]
@@ -17,4 +20,5 @@ pub struct AppState {
17 20 pub db: PgPool,
18 21 pub config: Config,
19 22 pub http: reqwest::Client,
23 + pub s3: Option<Arc<storage::S3Storage>>,
20 24 }
@@ -0,0 +1,190 @@
1 + //! Link preview — server-side OpenGraph metadata fetch for post URLs.
2 +
3 + use pulldown_cmark::{Event, Parser, Tag};
4 +
5 + /// Maximum number of URLs to extract per post.
6 + const MAX_URLS: usize = 3;
7 +
8 + /// Maximum response body size to read (1 MB).
9 + const MAX_BODY_SIZE: usize = 1_048_576;
10 +
11 + /// Extract unique http/https URLs from markdown text via pulldown_cmark link parsing.
12 + /// Returns at most `MAX_URLS` URLs.
13 + pub fn extract_urls(input: &str) -> Vec<String> {
14 + let parser = Parser::new(input);
15 + let mut seen = std::collections::HashSet::new();
16 + let mut urls = Vec::new();
17 +
18 + for event in parser {
19 + if let Event::Start(Tag::Link { dest_url, .. }) = event {
20 + let url = dest_url.to_string();
21 + if (url.starts_with("http://") || url.starts_with("https://"))
22 + && seen.insert(url.clone())
23 + {
24 + urls.push(url);
25 + if urls.len() >= MAX_URLS {
26 + break;
27 + }
28 + }
29 + }
30 + }
31 +
32 + urls
33 + }
34 +
35 + /// Fetch OpenGraph metadata from a URL. Returns `(og:title, og:description)`.
36 + /// Best-effort: returns None on any error (timeout, too large, parse failure).
37 + #[tracing::instrument(skip_all)]
38 + pub async fn fetch_og_metadata(
39 + http: &reqwest::Client,
40 + url: &str,
41 + ) -> Option<(Option<String>, Option<String>)> {
42 + let resp = http
43 + .get(url)
44 + .timeout(std::time::Duration::from_secs(5))
45 + .header("User-Agent", "Multithreaded/LinkPreview")
46 + .send()
47 + .await
48 + .ok()?;
49 +
50 + if !resp.status().is_success() {
51 + return None;
52 + }
53 +
54 + // Read body in chunks, capping at MAX_BODY_SIZE
55 + let mut body = Vec::new();
56 + let mut stream = resp;
57 + while body.len() < MAX_BODY_SIZE {
58 + let chunk = stream.chunk().await.ok()??;
59 + body.extend_from_slice(&chunk);
60 + }
61 +
62 + let html = String::from_utf8_lossy(&body);
63 +
64 + let og_title = extract_og_meta(&html, "og:title");
65 + let og_desc = extract_og_meta(&html, "og:description");
66 +
67 + // Fall back to <title> tag if no og:title
68 + let title = og_title.or_else(|| extract_html_title(&html));
69 +
70 + if title.is_some() || og_desc.is_some() {
71 + Some((title, og_desc))
72 + } else {
73 + None
74 + }
75 + }
76 +
77 + /// Extract a `<meta property="..." content="...">` value from HTML.
78 + fn extract_og_meta(html: &str, property: &str) -> Option<String> {
79 + static OG_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
80 + regex_lite::Regex::new(
81 + r#"<meta\s[^>]*?property\s*=\s*"([^"]*)"[^>]*?content\s*=\s*"([^"]*)"[^>]*?>"#,
82 + )
83 + .unwrap()
84 + });
85 + static OG_RE_REV: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
86 + regex_lite::Regex::new(
87 + r#"<meta\s[^>]*?content\s*=\s*"([^"]*)"[^>]*?property\s*=\s*"([^"]*)"[^>]*?>"#,
88 + )
89 + .unwrap()
90 + });
91 +
92 + // Try property-first order
93 + for caps in OG_RE.captures_iter(html) {
94 + if &caps[1] == property {
95 + let val = caps[2].trim().to_string();
96 + if !val.is_empty() {
97 + return Some(val);
98 + }
99 + }
100 + }
101 + // Try content-first order (some sites put content before property)
102 + for caps in OG_RE_REV.captures_iter(html) {
103 + if &caps[2] == property {
104 + let val = caps[1].trim().to_string();
105 + if !val.is_empty() {
106 + return Some(val);
107 + }
108 + }
109 + }
110 + None
111 + }
112 +
113 + /// Extract the `<title>` tag content from HTML.
114 + fn extract_html_title(html: &str) -> Option<String> {
115 + static TITLE_RE: std::sync::LazyLock<regex_lite::Regex> =
116 + std::sync::LazyLock::new(|| regex_lite::Regex::new(r"<title[^>]*>([^<]+)</title>").unwrap());
117 + TITLE_RE.captures(html).map(|c| c[1].trim().to_string())
118 + }
119 +
120 + #[cfg(test)]
121 + mod tests {
122 + use super::*;
123 +
124 + #[test]
125 + fn extract_urls_from_markdown() {
126 + let input = "Check [this](https://example.com) and [that](https://other.com/page).";
127 + let urls = extract_urls(input);
128 + assert_eq!(urls, vec!["https://example.com", "https://other.com/page"]);
129 + }
130 +
131 + #[test]
132 + fn extract_urls_skips_non_http() {
133 + let input = "[mail](mailto:a@b.com) [site](https://x.com)";
134 + let urls = extract_urls(input);
135 + assert_eq!(urls, vec!["https://x.com"]);
136 + }
137 +
138 + #[test]
139 + fn extract_urls_caps_at_three() {
140 + let input = "[a](https://1.com) [b](https://2.com) [c](https://3.com) [d](https://4.com)";
141 + let urls = extract_urls(input);
142 + assert_eq!(urls.len(), 3);
143 + }
144 +
145 + #[test]
146 + fn extract_urls_deduplicates() {
147 + let input = "[a](https://same.com) [b](https://same.com)";
148 + let urls = extract_urls(input);
149 + assert_eq!(urls, vec!["https://same.com"]);
150 + }
151 +
152 + #[test]
153 + fn extract_urls_no_links() {
154 + let urls = extract_urls("no links here");
155 + assert!(urls.is_empty());
156 + }
157 +
158 + #[test]
159 + fn og_meta_property_first() {
160 + let html = r#"<meta property="og:title" content="My Page">"#;
161 + assert_eq!(extract_og_meta(html, "og:title"), Some("My Page".to_string()));
162 + }
163 +
164 + #[test]
165 + fn og_meta_content_first() {
166 + let html = r#"<meta content="Description here" property="og:description">"#;
167 + assert_eq!(
168 + extract_og_meta(html, "og:description"),
169 + Some("Description here".to_string())
170 + );
171 + }
172 +
173 + #[test]
174 + fn og_meta_missing() {
175 + let html = r#"<meta property="og:image" content="img.png">"#;
176 + assert_eq!(extract_og_meta(html, "og:title"), None);
177 + }
178 +
179 + #[test]
180 + fn html_title_fallback() {
181 + let html = "<html><head><title>Page Title</title></head></html>";
182 + assert_eq!(extract_html_title(html), Some("Page Title".to_string()));
183 + }
184 +
185 + #[test]
186 + fn html_title_missing() {
187 + let html = "<html><head></head></html>";
188 + assert_eq!(extract_html_title(html), None);
189 + }
190 + }
M src/main.rs +41
@@ -4,6 +4,7 @@ use tokio::net::TcpListener;
4 4 use tower_http::services::ServeDir;
5 5 use tower_sessions::SessionManagerLayer;
6 6 use tower_sessions::cookie::SameSite;
7 + use tower_sessions::ExpiredDeletion;
7 8 use tower_sessions_sqlx_store::PostgresStore;
8 9 use tracing_subscriber::EnvFilter;
9 10
@@ -38,6 +39,23 @@ async fn main() {
38 39
39 40 let config = Config::from_env();
40 41
42 + // Optional S3 storage for image uploads
43 + let s3 = if let Some(ref s3_config) = config.s3 {
44 + match multithreaded::storage::S3Storage::new(s3_config).await {
45 + Ok(client) => {
46 + tracing::info!("S3 storage configured (bucket: {})", s3_config.bucket);
47 + Some(std::sync::Arc::new(client))
48 + }
49 + Err(e) => {
50 + tracing::warn!("S3 storage unavailable: {e}");
51 + None
52 + }
53 + }
54 + } else {
55 + tracing::info!("S3 storage not configured (image uploads disabled)");
56 + None
57 + };
58 +
41 59 let state = AppState {
42 60 db: pool.clone(),
43 61 config,
@@ -46,12 +64,19 @@ async fn main() {
46 64 .connect_timeout(std::time::Duration::from_secs(5))
47 65 .build()
48 66 .expect("failed to build HTTP client"),
67 + s3,
49 68 };
50 69
51 70 // Session store backed by PostgreSQL
52 71 let session_store = PostgresStore::new(pool);
53 72 session_store.migrate().await.expect("failed to migrate session store");
54 73
74 + let deletion_task = tokio::task::spawn(
75 + session_store
76 + .clone()
77 + .continuously_delete_expired(tokio::time::Duration::from_secs(3600)),
78 + );
79 +
55 80 let session_layer = SessionManagerLayer::new(session_store)
56 81 .with_name("mt_session")
57 82 .with_same_site(SameSite::Lax)
@@ -63,6 +88,20 @@ async fn main() {
63 88 let app = multithreaded::routes::forum_routes(state)
64 89 .layer(axum::middleware::from_fn(csrf::csrf_middleware))
65 90 .layer(session_layer)
91 + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
92 + axum::http::header::CONTENT_SECURITY_POLICY,
93 + axum::http::HeaderValue::from_static(
94 + "default-src 'self'; img-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
95 + ),
96 + ))
97 + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
98 + axum::http::header::X_CONTENT_TYPE_OPTIONS,
99 + axum::http::HeaderValue::from_static("nosniff"),
100 + ))
101 + .layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
102 + axum::http::header::X_FRAME_OPTIONS,
103 + axum::http::HeaderValue::from_static("DENY"),
104 + ))
66 105 .nest_service("/static", ServeDir::new("static"));
67 106
68 107 let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
@@ -82,6 +121,8 @@ async fn main() {
82 121 .with_graceful_shutdown(shutdown_signal())
83 122 .await
84 123 .expect("server error");
124 +
125 + deletion_task.abort();
85 126 }
86 127
87 128 async fn shutdown_signal() {
M src/markdown.rs +291 -15
@@ -1,6 +1,8 @@
1 1 //! Markdown rendering with HTML sanitization.
2 2
3 - use pulldown_cmark::{CowStr, Event, Parser, Tag, html};
3 + use std::collections::HashSet;
4 +
5 + use pulldown_cmark::{CowStr, Event, Parser, Tag, TagEnd, html};
4 6
5 7 /// Returns true if the URL uses a scheme not in the safe allowlist.
6 8 ///
@@ -38,22 +40,219 @@ pub fn render(input: &str) -> String {
38 40 title,
39 41 id,
40 42 })),
41 - Event::Start(Tag::Image {
42 - link_type,
43 - dest_url,
44 - title,
45 - id,
46 - }) if has_dangerous_scheme(&dest_url) => Some(Event::Start(Tag::Image {
47 - link_type,
48 - dest_url: CowStr::Borrowed("#"),
49 - title,
50 - id,
51 - })),
43 + // Strip images entirely — alt text passes through as plain text
44 + Event::Start(Tag::Image { .. }) | Event::End(TagEnd::Image) => None,
52 45 other => Some(other),
53 46 });
54 47 let mut output = String::new();
55 48 html::push_html(&mut output, parser);
56 - ammonia::clean(&output)
49 + ammonia::Builder::default()
50 + .link_rel(Some("noopener noreferrer nofollow"))
51 + .clean(&output)
52 + .to_string()
53 + }
54 +
55 + /// Quote author info for attribution rendering.
56 + pub struct QuoteAuthor {
57 + pub username: String,
58 + pub display_name: String,
59 + pub is_removed: bool,
60 + }
61 +
62 + /// HTML-escape a string for safe interpolation into raw HTML.
63 + fn html_escape(s: &str) -> String {
64 + s.replace('&', "&amp;")
65 + .replace('<', "&lt;")
66 + .replace('>', "&gt;")
67 + .replace('"', "&quot;")
68 + .replace('\'', "&#x27;")
69 + }
70 +
71 + /// Post-process rendered HTML to replace `[quote:POST_ID:HASH]` markers with attribution.
72 + pub fn post_process_quotes(
73 + html: &str,
74 + quote_authors: &std::collections::HashMap<uuid::Uuid, QuoteAuthor>,
75 + ) -> String {
76 + static QUOTE_RE: std::sync::LazyLock<regex_lite::Regex> = std::sync::LazyLock::new(|| {
77 + regex_lite::Regex::new(r"\[quote:([0-9a-f\-]{36}):([0-9a-f]{8})\]").unwrap()
78 + });
79 + QUOTE_RE.replace_all(html, |caps: &regex_lite::Captures| {
80 + let post_id_str = &caps[1];
81 + if let Ok(post_id) = uuid::Uuid::parse_str(post_id_str)
82 + && let Some(author) = quote_authors.get(&post_id)
83 + {
84 + if author.is_removed {
85 + format!(
86 + "<cite class=\"quote-attribution\"><a href=\"#post-{}\">(original post removed)</a></cite>",
87 + post_id_str
88 + )
89 + } else {
90 + format!(
91 + "<cite class=\"quote-attribution\"><a href=\"#post-{}\">— {} (@{})</a></cite>",
92 + post_id_str,
93 + html_escape(&author.display_name),
94 + html_escape(&author.username),
95 + )
96 + }
97 + } else {
98 + caps[0].to_string()
99 + }
100 + })
101 + .to_string()
102 + }
103 +
104 + // ============================================================================
105 + // @Mention extraction + resolution
106 + // ============================================================================
107 +
108 + /// Extract unique `@username` mentions from raw markdown input.
109 + /// Skips mentions inside inline code (backtick-wrapped).
110 + pub fn extract_mention_usernames(input: &str) -> Vec<String> {
111 + static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> =
112 + std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap());
113 +
114 + // Strip inline code spans and fenced code blocks before scanning
115 + let stripped = strip_code_spans(input);
116 + let mut seen = HashSet::new();
117 + let mut result = Vec::new();
118 + for caps in MENTION_RE.captures_iter(&stripped) {
119 + let username = caps[1].to_string();
120 + if seen.insert(username.clone()) {
121 + result.push(username);
122 + }
123 + }
124 + result
125 + }
126 +
127 + /// Replace `@username` with markdown profile links for valid community members.
128 + /// Unknown usernames are left as plain text.
129 + pub fn resolve_mentions(
130 + input: &str,
131 + community_slug: &str,
132 + valid_usernames: &HashSet<String>,
133 + ) -> String {
134 + static MENTION_RE: std::sync::LazyLock<regex_lite::Regex> =
135 + std::sync::LazyLock::new(|| regex_lite::Regex::new(r"@([A-Za-z0-9_-]+)").unwrap());
136 +
137 + // We need to avoid replacing mentions inside backtick code spans.
138 + // Strategy: split on code spans, only replace in non-code segments.
139 + let mut result = String::with_capacity(input.len());
140 + let mut pos = 0;
141 +
142 + for (code_start, code_end) in code_span_ranges(input) {
143 + // Process the text before this code span
144 + let before = &input[pos..code_start];
145 + result.push_str(&replace_mentions(before, community_slug, valid_usernames, &MENTION_RE));
146 + // Copy the code span verbatim
147 + result.push_str(&input[code_start..code_end]);
148 + pos = code_end;
149 + }
150 + // Process remaining text after the last code span
151 + let tail = &input[pos..];
152 + result.push_str(&replace_mentions(tail, community_slug, valid_usernames, &MENTION_RE));
153 +
154 + result
155 + }
156 +
157 + fn replace_mentions(
158 + text: &str,
159 + community_slug: &str,
160 + valid_usernames: &HashSet<String>,
161 + re: &regex_lite::Regex,
162 + ) -> String {
163 + re.replace_all(text, |caps: &regex_lite::Captures| {
164 + let username = &caps[1];
165 + if valid_usernames.contains(username) {
166 + format!("[@{username}](/p/{community_slug}/u/{username})")
167 + } else {
168 + caps[0].to_string()
169 + }
170 + })
171 + .to_string()
172 + }
173 +
174 + /// Strip inline code (backtick) and fenced code blocks, replacing with spaces.
175 + fn strip_code_spans(input: &str) -> String {
176 + let mut out = String::with_capacity(input.len());
177 + let mut chars = input.chars().peekable();
178 +
179 + while let Some(ch) = chars.next() {
180 + if ch == '`' {
181 + // Count consecutive backticks
182 + let mut tick_count = 1;
183 + while chars.peek() == Some(&'`') {
184 + tick_count += 1;
185 + chars.next();
186 + }
187 + // Find the matching closing backticks
188 + let mut skipped = 0;
189 + while let Some(c) = chars.next() {
190 + skipped += 1;
191 + if c == '`' {
192 + let mut close_count = 1;
193 + while chars.peek() == Some(&'`') {
194 + close_count += 1;
195 + chars.next();
196 + }
197 + if close_count == tick_count {
198 + break;
199 + }
200 + }
201 + }
202 + // Replace the code span content (+ delimiters) with spaces
203 + let total = tick_count * 2 + skipped;
204 + for _ in 0..total {
205 + out.push(' ');
206 + }
207 + } else {
208 + out.push(ch);
209 + }
210 + }
211 + out
212 + }
213 +
214 + /// Return byte ranges of inline code spans and fenced code blocks.
215 + fn code_span_ranges(input: &str) -> Vec<(usize, usize)> {
216 + let mut ranges = Vec::new();
217 + let bytes = input.as_bytes();
218 + let len = bytes.len();
219 + let mut i = 0;
220 +
221 + while i < len {
222 + if bytes[i] == b'`' {
223 + let start = i;
224 + let mut tick_count = 0;
225 + while i < len && bytes[i] == b'`' {
226 + tick_count += 1;
227 + i += 1;
228 + }
229 + // Search for matching closing backticks
230 + let mut found = false;
231 + while i < len {
232 + if bytes[i] == b'`' {
233 + let mut close_count = 0;
234 + while i < len && bytes[i] == b'`' {
235 + close_count += 1;
236 + i += 1;
237 + }
238 + if close_count == tick_count {
239 + ranges.push((start, i));
240 + found = true;
241 + break;
242 + }
243 + } else {
244 + i += 1;
245 + }
246 + }
247 + if !found {
248 + // Unclosed — treat from start to end as code
249 + ranges.push((start, len));
250 + }
251 + } else {
252 + i += 1;
253 + }
254 + }
255 + ranges
57 256 }
58 257
59 258 #[cfg(test)]
@@ -163,6 +362,13 @@ mod tests {
163 362 }
164 363
165 364 #[test]
365 + fn links_have_nofollow() {
366 + let result = render("[example](https://example.com)");
367 + assert!(result.contains("nofollow"), "links should have rel=nofollow");
368 + assert!(result.contains("noopener"), "links should have rel=noopener");
369 + }
370 +
371 + #[test]
166 372 fn javascript_url_sanitized() {
167 373 let result = render("[click me](javascript:alert(1))");
168 374 assert!(result.contains("click me"));
@@ -211,9 +417,79 @@ mod tests {
211 417 }
212 418
213 419 #[test]
214 - fn javascript_url_in_image_sanitized() {
420 + fn images_stripped_alt_text_preserved() {
421 + let result = render("![alt text](https://example.com/img.png)");
422 + assert!(!result.contains("<img"));
423 + assert!(!result.contains("example.com"));
424 + assert!(result.contains("alt text"));
425 + }
426 +
427 + #[test]
428 + fn javascript_url_in_image_stripped() {
215 429 let result = render("![alt](javascript:alert(1))");
216 430 assert!(!result.contains("javascript:"));
217 - assert!(result.contains(r##"src="#""##));
431 + assert!(!result.contains("<img"));
432 + }
433 +
434 + // ========================================================================
435 + // @Mention tests
436 + // ========================================================================
437 +
438 + #[test]
439 + fn extract_mentions_basic() {
440 + let usernames = extract_mention_usernames("Hello @alice and @bob!");
441 + assert_eq!(usernames, vec!["alice", "bob"]);
442 + }
443 +
444 + #[test]
445 + fn extract_mentions_deduplicates() {
446 + let usernames = extract_mention_usernames("@alice said @alice agrees");
447 + assert_eq!(usernames, vec!["alice"]);
448 + }
449 +
450 + #[test]
451 + fn extract_mentions_skips_code_spans() {
452 + let usernames = extract_mention_usernames("Hello `@notreal` and @real");
453 + assert_eq!(usernames, vec!["real"]);
454 + }
455 +
456 + #[test]
457 + fn extract_mentions_skips_fenced_code() {
458 + let usernames = extract_mention_usernames("text\n```\n@inside\n```\n@outside");
459 + assert_eq!(usernames, vec!["outside"]);
460 + }
461 +
462 + #[test]
463 + fn extract_mentions_empty() {
464 + let usernames = extract_mention_usernames("no mentions here");
465 + assert!(usernames.is_empty());
466 + }
467 +
468 + #[test]
469 + fn resolve_mentions_valid_replaced() {
470 + let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect();
471 + let result = resolve_mentions("Hello @alice!", "test-community", &valid);
472 + assert_eq!(result, "Hello [@alice](/p/test-community/u/alice)!");
473 + }
474 +
475 + #[test]
476 + fn resolve_mentions_unknown_left_alone() {
477 + let valid: HashSet<String> = HashSet::new();
478 + let result = resolve_mentions("Hello @unknown!", "test", &valid);
479 + assert_eq!(result, "Hello @unknown!");
480 + }
481 +
482 + #[test]
483 + fn resolve_mentions_in_code_not_replaced() {
484 + let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect();
485 + let result = resolve_mentions("Use `@alice` in code", "test", &valid);
486 + assert_eq!(result, "Use `@alice` in code");
487 + }
488 +
489 + #[test]
490 + fn resolve_mentions_mixed_valid_invalid() {
491 + let valid: HashSet<String> = ["alice"].iter().map(|s| s.to_string()).collect();
492 + let result = resolve_mentions("@alice and @unknown", "slug", &valid);
493 + assert_eq!(result, "[@alice](/p/slug/u/alice) and @unknown");
218 494 }
219 495 }
@@ -7,14 +7,13 @@ use axum::{
7 7 Form,
8 8 };
9 9 use tower_sessions::Session;
10 - use uuid::Uuid;
11 10
12 11 use crate::auth::PlatformAdmin;
13 12 use crate::csrf;
14 13 use crate::templates::*;
15 14 use crate::AppState;
16 15
17 - use super::{AdminSearchQuery, SuspendForm};
16 + use super::{log_mod_action, parse_uuid, AdminSearchQuery, SuspendForm};
18 17
19 18 #[tracing::instrument(skip_all)]
20 19 pub(super) async fn admin_dashboard(
@@ -82,9 +81,7 @@ pub(super) async fn suspend_community_handler(
82 81 Path(id): Path<String>,
83 82 Form(form): Form<SuspendForm>,
84 83 ) -> Result<Redirect, Response> {
85 - let community_id = Uuid::parse_str(&id)
86 - .map_err(|_| StatusCode::NOT_FOUND.into_response())?;
87 -
84 + let community_id = parse_uuid(&id)?;
88 85 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
89 86
90 87 mt_db::mutations::suspend_community(&state.db, community_id, reason)
@@ -94,12 +91,10 @@ pub(super) async fn suspend_community_handler(
94 91 StatusCode::INTERNAL_SERVER_ERROR.into_response()
95 92 })?;
96 93
97 - if let Err(e) = mt_db::mutations::insert_mod_log(
94 + log_mod_action(
98 95 &state.db, None, admin.user_id,
99 96 "suspend_community", None, Some(community_id), reason,
100 - ).await {
101 - tracing::error!(error = %e, "failed to insert mod log");
102 - }
97 + ).await;
103 98
104 99 Ok(Redirect::to("/_admin?toast=Community+suspended"))
105 100 }
@@ -110,8 +105,7 @@ pub(super) async fn unsuspend_community_handler(
110 105 PlatformAdmin(admin): PlatformAdmin,
111 106 Path(id): Path<String>,
112 107 ) -> Result<Redirect, Response> {
113 - let community_id = Uuid::parse_str(&id)
114 - .map_err(|_| StatusCode::NOT_FOUND.into_response())?;
108 + let community_id = parse_uuid(&id)?;
115 109
116 110 mt_db::mutations::unsuspend_community(&state.db, community_id)
117 111 .await
@@ -120,12 +114,10 @@ pub(super) async fn unsuspend_community_handler(
120 114 StatusCode::INTERNAL_SERVER_ERROR.into_response()
121 115 })?;
122 116
123 - if let Err(e) = mt_db::mutations::insert_mod_log(
117 + log_mod_action(
124 118 &state.db, None, admin.user_id,
125 119 "unsuspend_community", None, Some(community_id), None,
126 - ).await {
127 - tracing::error!(error = %e, "failed to insert mod log");
128 - }
120 + ).await;
129 121
130 122 Ok(Redirect::to("/_admin?toast=Community+unsuspended"))
131 123 }
@@ -137,9 +129,7 @@ pub(super) async fn suspend_user_handler(
137 129 Path(id): Path<String>,
138 130 Form(form): Form<SuspendForm>,
139 131 ) -> Result<Redirect, Response> {
140 - let user_id = Uuid::parse_str(&id)
141 - .map_err(|_| StatusCode::NOT_FOUND.into_response())?;
142 -
132 + let user_id = parse_uuid(&id)?;
143 133 let reason = form.reason.as_deref().filter(|r| !r.trim().is_empty());
144 134
145 135 mt_db::mutations::suspend_user(&state.db, user_id, reason)
@@ -149,12 +139,10 @@ pub(super) async fn suspend_user_handler(
149 139 StatusCode::INTERNAL_SERVER_ERROR.into_response()
150 140 })?;
151 141
152 - if let Err(e) = mt_db::mutations::insert_mod_log(
142 + log_mod_action(
153 143 &state.db, None, admin.user_id,
154 144 "suspend_user", Some(user_id), None, reason,
155 - ).await {
156 - tracing::error!(error = %e, "failed to insert mod log");
157 - }
145 + ).await;
158 146
159 147 Ok(Redirect::to("/_admin?toast=User+suspended"))
160 148 }
@@ -165,8 +153,7 @@ pub(super) async fn unsuspend_user_handler(
165 153 PlatformAdmin(admin): PlatformAdmin,
166 154 Path(id): Path<String>,
167 155 ) -> Result<Redirect, Response> {
168 - let user_id = Uuid::parse_str(&id)
169 - .map_err(|_| StatusCode::NOT_FOUND.into_response())?;
156 + let user_id = parse_uuid(&id)?;
170 157
171 158 mt_db::mutations::unsuspend_user(&state.db, user_id)
172 159 .await
@@ -175,12 +162,10 @@ pub(super) async fn unsuspend_user_handler(
175 162 StatusCode::INTERNAL_SERVER_ERROR.into_response()
176 163 })?;
177 164
178 - if let Err(e) = mt_db::mutations::insert_mod_log(
165 + log_mod_action(
179 166 &state.db, None, admin.user_id,
180 167 "unsuspend_user", Some(user_id), None, None,
181 - ).await {
182 - tracing::error!(error = %e, "failed to insert mod log");
183 - }
168 + ).await;
184 169
185 170 Ok(Redirect::to("/_admin?toast=User+unsuspended"))
186 171 }
M src/routes/mod.rs +239 -64
A static/mt.js +530
M templates/base.html +13 -203
M todo.md +10 -274
M todo_done.md +166