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, +9261 insertions, -2041 deletions
M Cargo.lock +474 -5
@@ -119,6 +119,476 @@
119 119 source = "registry+https://github.com/rust-lang/crates.io-index"
120 120 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
121 121
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 +
122 592 [[package]]
123 593 name = "axum"
124 594 version = "0.8.8"
@@ -130,15 +600,16 @@
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 @@
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 @@
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 @@
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 @@
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
M todo.md +11 -275
@@ -1,6 +1,6 @@
1 1 # Multithreaded — Todo
2 2
3 - Done: Phases 0-11. 106 tests (65 integration + 25 unit lib + 16 unit mt-core). v0.2.3. Routes split into directory module (`routes/`). Graceful shutdown + reqwest timeouts. Unused deps removed. First formal audit: B+ (2026-03-14). All 10 audit findings resolved (1 HIGH + 4 MEDIUM + 5 SMALL). Rate limiting (tower-governor). Expired ban cleanup (opportunistic). Test coverage gaps closed. Ammonia HTML sanitizer (defense-in-depth). Audit grade: A. Initial git commit done. UI aligned with MNW: header nav (Library, Discover, Feed, Profile, Dashboard link to MNW), footer ("Powered by Makenot.work"), nav link styling (IBM Plex Mono, opacity transitions), dead CSS removed. Deployed to hetzner (forums.makenot.work) alongside MNW (2026-03-15). Cross-compiled via cargo-zigbuild, reqwest uses rustls-tls. OAuth app registered in MNW production DB. Caddy reverse proxy + Cloudflare Origin CA + Authenticated Origin Pulls (mTLS). PoM monitoring configured. Public URL verified (HTTP 200 through Cloudflare).
3 + Done: All pre-beta phases (0-11, 13-24). 222 tests (150 integration + 56 unit lib + 16 unit mt-core). v0.2.4. Audit grade: A. Deployed to hetzner (forums.makenot.work). All remaining items from completed phases resolved. MNW Forums tab integration live.
4 4
5 5 Completed work archived in [todo_done.md](todo_done.md).
6 6
@@ -8,278 +8,12 @@
8 8
9 9 ---
10 10
11 - ## Remaining from earlier phases
12 -
13 - - [x] Caddy config on alpha-west-1 (reverse proxy alongside MNW, forums.makenot.work)
14 - - [x] Deploy to alpha-west-1 (hetzner, alongside live MNW deployment)
15 - - [x] DNS A record: `forums.makenot.work` → `5.78.144.244` (Cloudflare, proxy ON)
16 - - [x] Verify forums.makenot.work loads via public URL (HTTP 200 confirmed)
17 - - [ ] Manual moderation testing (ban, mute, unban/unmute, mod log, admin dashboard)
18 - - [ ] Paginate forum directory (if needed, when many projects exist)
19 -
20 - ---
21 -
22 - Phases 13-24 ordered smallest to largest.
23 -
24 - ---
25 -
26 - ## Phase 13 — Draft Auto-Save
27 -
28 - localStorage draft persistence. Prevents lost work on accidental navigation.
29 -
30 - ### Remaining
31 -
32 - - [ ] JS: on reply/new-thread textarea `input` event (debounced 1s), save content to `localStorage` keyed by page URL
33 - - [ ] On page load: if draft exists for current URL, restore textarea content + show subtle "Draft restored" indicator
34 - - [ ] Clear draft on successful form submission
35 - - [ ] "Discard draft" link next to the indicator
36 - - [ ] Drafts expire after 7 days (check timestamp on restore, discard if stale)
37 - - [ ] Respects Phase 14 privacy toggle — if localStorage tracking is off, no drafts saved either
38 - - [ ] No server-side storage, no sync across devices
39 -
40 - ---
41 -
42 - ## Phase 14 — Immutable Posts + Footnotes
43 -
44 - Posts are permanent records. No user-facing delete or edit. Corrections via author footnotes.
45 -
46 - ### Remaining
47 -
48 - #### Remove edit/delete for users
49 - - [ ] Remove "Edit" and "Delete" action links from post UI (keep mod "Remove" action)
50 - - [ ] Remove `edit_post` and `delete_post` user routes (keep mod-level removal)
51 - - [ ] Remove edit window logic (`POST_EDIT_WINDOW_SECONDS`)
52 - - [ ] Update thread edit: title editing removed for authors (mods can still rename for housekeeping)
53 - - [ ] Mod "Remove" replaces content with "[removed by moderator]" — original stays in DB for audit, hidden from display
54 - - [ ] Update integration tests: remove edit/delete happy-path tests, add tests for footnotes + mod removal
55 -
56 - #### Author footnotes
57 - - [ ] Migration: `post_footnotes` table — `(id UUID, post_id UUID, author_id UUID, body TEXT, created_at TIMESTAMPTZ)`
58 - - [ ] "Add footnote" link on own posts (same position as old edit link, `.post-action-link` styling)
59 - - [ ] `POST /p/{slug}/{cat}/{thread_id}/{post_id}/footnote` endpoint — author-only, markdown body
60 - - [ ] Footnotes rendered below post body in distinct block: smaller font, muted color, "Author's note (2h ago):" prefix
61 - - [ ] Multiple footnotes per post, displayed in chronological order
62 - - [ ] Footnotes are immutable — no editing or deleting footnotes
63 - - [ ] Footnotes go through same markdown + XSS pipeline as posts
64 - - [ ] Integration tests: add footnote, non-author rejected, footnote renders, multiple footnotes ordered
65 -
66 - ---
67 -
68 - ## Phase 15 — Endorsements
69 -
70 - Silent appreciation. No counts displayed publicly, no notifications. Reduces "+1" noise replies.
71 -
72 - ### Remaining
73 -
74 - - [ ] Migration: `post_endorsements` table — `(post_id UUID, user_id UUID, created_at TIMESTAMPTZ)`, primary key `(post_id, user_id)`
75 - - [ ] "Endorse" button on each post (not on own posts, logged-in only). Toggle — click again to remove.
76 - - [ ] `POST /p/{slug}/{cat}/{thread_id}/{post_id}/endorse` — toggle endpoint
77 - - [ ] No public count displayed on posts. No notification to author.
78 - - [ ] Author can see endorsement count on their own posts (subtle, e.g. "3 endorsements" in muted text visible only to them)
79 - - [ ] Community profile (Phase 18): total endorsements received as a profile stat
80 - - [ ] Mods can see endorsement counts on all posts (quality signal for moderation)
81 - - [ ] Integration tests: endorse, un-endorse, no self-endorse, count visible to author only
82 -
83 - ---
84 -
85 - ## Phase 16 — Unread/New Tracking
86 -
87 - Two-tier system: local by default (no server cost), opt-in server-side for tracked threads.
88 -
89 - ### Remaining
90 -
91 - #### Tier 1 — Local (localStorage, no login required)
92 - - [ ] On thread listing pages, emit `data-thread-id` and `data-reply-count` on each `<tr>`
93 - - [ ] JS: on page load, compare each thread's reply count against `localStorage` map (`mt_thread_state`). If thread exists but count increased → add `.unread` CSS class (bold title + dot indicator)
94 - - [ ] JS: when user clicks into a thread, update stored count to current
95 - - [ ] LRU cap: evict oldest entries when map exceeds 1000 threads
96 - - [ ] Opt-out toggle: footer link or settings gear, "Unread tracking: On / Off", stored as `localStorage.mt_tracking_enabled`. When off, no reads/writes to thread state, no visual indicators
97 - - [ ] Toggle label explains: "Tracks which threads have new replies. Stored in your browser only — never sent to the server."
98 -
99 - #### Tier 2 — Server-side tracked threads (logged-in, explicit opt-in)
100 - - [ ] Migration: `tracked_threads` table — `(user_id, thread_id, last_read_post_id, tracked_at)`
101 - - [ ] "Track" button on thread page with title text: "Server will remember your read position for this thread across devices"
102 - - [ ] `POST /p/{slug}/{cat}/{thread_id}/track` + `POST .../untrack` endpoints
103 - - [ ] On opening a tracked thread, upsert `last_read_post_id` to latest post
104 - - [ ] Category listing: tracked threads with new posts show count badge ("3 new") distinct from tier-1 bold
105 - - [ ] `/tracked` page: all tracked threads across communities, sorted by recent activity, with unread counts
106 - - [ ] `/tracked` header: "These threads are tracked on the server. Your read position syncs across devices." + "Stop tracking all" action
107 - - [ ] Integration tests: track/untrack, read position update, unread counts, stop-all
108 -
109 - #### Privacy transparency
110 - - [ ] Static page or modal linked from footer: "How tracking works" — explains both tiers, what is stored where, how to opt out/delete
111 - - [ ] Browser tracking: localStorage only, never sent to server, cleared by browser data or toggle
112 - - [ ] Thread tracking: server-side, tied to account, deletable via untrack or stop-all
113 - - [ ] "No analytics, no third-party sharing"
114 - - [ ] Reference MNW principle: "All Makenot.work apps follow the same tracking principles"
115 -
116 - ---
117 -
118 - ## Phase 17 — Post Flagging
119 -
120 - Users flag posts, mods review in a queue. No auto-moderation yet (future: per-community toggle creators can enable).
121 -
122 - ### Remaining
123 -
124 - #### Data model
125 - - [ ] Migration: `post_flags` table — `(id UUID, post_id UUID, flagger_id UUID, reason ENUM('spam','rule_breaking','off_topic'), detail TEXT NULL, created_at TIMESTAMPTZ, resolved_at TIMESTAMPTZ NULL, resolved_by UUID NULL, resolution ENUM('dismissed','removed') NULL)`
126 - - [ ] Unique constraint on `(post_id, flagger_id)` — one flag per user per post
127 -
128 - #### User-facing
129 - - [ ] "Flag" link on each post (`.post-action-link` styling, not on own posts)
130 - - [ ] Flag form: radio buttons for reason (Spam, Rule-breaking, Off-topic) + optional freetext detail
131 - - [ ] `POST /p/{slug}/{cat}/{thread_id}/{post_id}/flag` — logged-in only, one per user per post, duplicate silently ignored
132 - - [ ] Visual confirmation: toast "Post flagged" on success
133 -
134 - #### Mod review
135 - - [ ] "Flags" section on moderation page (`/p/{slug}/moderation`) showing pending flags
136 - - [ ] Each flag entry: post content preview, flag reason, detail if provided, flagger username, timestamp, flag count (if multiple users flagged same post)
137 - - [ ] Mod actions: "Dismiss" (resolve flag, no action) or "Remove" (mod-remove post + resolve flag + mod log entry)
138 - - [ ] Resolved flags hidden from queue, visible in mod log
139 -
140 - #### Tests
141 - - [ ] Integration tests: flag a post, duplicate flag rejected, non-logged-in rejected, mod dismiss, mod remove + flag resolved, flag count display
142 -
143 - #### Deferred: auto-moderation
144 - - [ ] Future: per-community setting `auto_hide_threshold` (default: off). When flag count reaches threshold, post auto-hidden pending mod review. Creators can enable, MNW official forums keep it off.
145 -
146 - ---
147 -
148 - ## Phase 18 — Tags
149 -
150 - Lightweight cross-cutting labels on threads. Creator-defined per community.
151 -
152 - ### Remaining
153 -
154 - - [ ] Migration: `tags` table — `(id UUID, community_id UUID, name TEXT, slug TEXT, created_at TIMESTAMPTZ)`, unique on `(community_id, slug)`. `thread_tags` join table — `(thread_id UUID, tag_id UUID)`.
155 - - [ ] Community settings (owner): create/delete tags for their community
156 - - [ ] Thread creation + thread view: tag selector (multi-select from community's tag list)
157 - - [ ] Tags displayed on thread listing rows (small badges after title)
158 - - [ ] Category view: filter by tag via query param (`?tag=solved`)
159 - - [ ] Common patterns: `[solved]`, `[bug]`, `[question]`, `[announcement]` — but no built-in defaults, fully creator-defined
160 - - [ ] Integration tests: create tag, apply to thread, filter by tag, remove tag, tag scoped to community
161 -
162 - ---
163 -
164 - ## Phase 19 — @Mentions (passive)
165 -
166 - `@username` renders as a link. Mentioned threads get a distinct visual indicator on next visit. No notifications, no email, no push.
167 -
168 - ### Remaining
169 -
170 - - [ ] Parse `@username` in post body during markdown rendering — render as link to community-scoped profile (`/p/{slug}/u/{username}`)
171 - - [ ] Migration: `post_mentions` table — `(post_id UUID, mentioned_user_id UUID, created_at TIMESTAMPTZ)`
172 - - [ ] On post creation: extract `@username` tokens, resolve to user IDs, insert into `post_mentions`
173 - - [ ] Thread listing: threads where the current user was mentioned since their last visit get a violet (`#6c5ce7`) accent indicator — distinct from unread bold (tier-1) and tracked count badge (tier-2)
174 - - [ ] `/tracked` page (Phase 16): mention-highlighted threads appear in a "Mentioned" section
175 - - [ ] No notification, no email, no badge count. Just: when you visit, threads with your mentions are visually distinct.
176 - - [ ] Integration tests: mention parsed and stored, mention renders as profile link, mention indicator on thread listing, self-mention ignored
177 -
178 - ---
179 -
180 - ## Phase 20 — Link Previews (minimal)
181 -
182 - Server-side OpenGraph fetch on post creation. No embeds, no iframes, no third-party JS.
183 -
184 - ### Remaining
185 -
186 - - [ ] On post creation: extract URLs from body, fetch OpenGraph `og:title` + `og:description` for each (server-side, with timeout + size limit)
187 - - [ ] Store preview data with the post (JSON column or separate `link_previews` table — `(post_id, url, title, description)`)
188 - - [ ] Render below the link in the post: small card with title + description, linked to URL. Muted styling, no images.
189 - - [ ] Fetch happens once at post creation time, not on every render. If OG fetch fails, plain link, no retry.
190 - - [ ] Rate limit: max 3 previews per post. Skip URLs to known-problematic domains.
191 - - [ ] No YouTube embeds, no iframe injection, no tracking pixels from third-party content.
192 - - [ ] Integration tests: link preview stored on create, failed fetch degrades to plain link, preview renders
193 -
194 - ---
195 -
196 - ## Phase 21 — Verified Quoting
197 -
198 - Select-to-quote with hash verification. Quoted text is validated against original on submit.
199 -
200 - ### Remaining
201 -
202 - #### Quote format
203 - - [ ] Quote block format: `> quoted text\n> <quote post="POST_ID" hash="HASH">` where hash is truncated SHA-256 (6 hex chars) of quoted text
204 - - [ ] On render: strip `<quote>` metadata line, render attribution as "— @username, post #N" link below blockquote
205 - - [ ] Markdown renderer: recognize and handle `<quote>` tag (strip from rendered output, replace with attribution)
206 -
207 - #### Selection + insertion
208 - - [ ] Text selection UX: user selects text within a `.post-body`, "Quote" button appears (floating near selection or as a popup)
209 - - [ ] JS: capture selected text, compute SHA-256 hash via SubtleCrypto, insert formatted blockquote into reply textarea
210 - - [ ] If textarea has existing content, append quote (multi-quote support)
211 - - [ ] Scroll to reply form + focus cursor after inserted quote
212 -
213 - #### Server-side verification
214 - - [ ] On reply submit: extract all `<quote post="X" hash="Y">` from body
215 - - [ ] For each: fetch post X body, verify quoted text is a substring, verify hash matches
216 - - [ ] Reject with clear error if any quote is fabricated or altered: "Quote doesn't match the original post"
217 - - [ ] No edge case for edited posts — posts are immutable (Phase 14), hash always valid
218 - - [ ] Integration tests: valid quote accepted, fabricated quote rejected, altered quote rejected, multi-quote post, quote from nonexistent post rejected
219 -
220 - ---
221 -
222 - ## Phase 22 — User Profiles
223 -
224 - Per-community profiles (not MT-global). Each forum is its own world. MNW Forums tab is the only aggregate view.
225 -
226 - ### Remaining
227 -
228 - #### Community-scoped profile page (`/p/{slug}/u/{username}`)
229 - - [ ] Route: `GET /p/{slug}/u/{username}` — public, anyone can view
230 - - [ ] Pull avatar + display name from stored MNW userinfo (already have `avatar_url`, `display_name` from OAuth)
231 - - [ ] Display: avatar, display name, join date for this community (membership `created_at`), post count in this community
232 - - [ ] Recent activity: list of recent posts/threads in this community only (paginated, newest first)
233 - - [ ] Role badge for this community (member/moderator/owner)
234 - - [ ] Template + CSS (reuse existing design patterns — data-table for activity, badge styles for roles)
235 - - [ ] Query: user activity scoped to single community (posts + threads in community, membership with role)
236 - - [ ] Usernames in thread/post listings link to `/p/{slug}/u/{username}` (community-scoped, not MNW profile)
237 -
238 - #### MT user summary API
239 - - [ ] `GET /api/user/{user_id}/summary` — returns JSON: list of memberships (community name, slug, role, join date, post count), tracked thread counts per community
240 - - [ ] Auth: requires valid MNW session or internal service token (MNW server-to-server)
241 - - [ ] Lightweight — MNW fetches this when rendering the account page Forums tab
242 -
243 - #### MNW Forums tab (on MNW account page)
244 - - [ ] New tab on MNW account/settings page, hidden until user has >= 1 MT membership
245 - - [ ] MNW server fetches MT summary API on tab render
246 - - [ ] Shows: communities with roles, tracked threads with unread counts, per-community settings links
247 - - [ ] Each community links to community-scoped profile (`{mt_base_url}/p/{slug}/u/{username}`)
248 - - [ ] Private — only visible to account owner
249 - - [ ] MNW-side: new route handler, template partial, config for MT API base URL
250 -
251 - ---
252 -
253 - ## Phase 23 — Search (fuzzy-find modal)
254 -
255 - Instant fuzzy search modal — press `/` or click search icon, results stream in as you type. No page reload.
256 -
257 - ### Remaining
258 -
259 - - [ ] Migration: `CREATE EXTENSION pg_trgm`, add GIN trigram indexes on `threads.title` and `posts.body`, add tsvector columns + GIN indexes for full-text ranking
260 - - [ ] Search query: combine `pg_trgm` similarity (typo tolerance) + `tsvector`/`ts_rank` (stemming + relevance), title matches ranked above body, recency boost for tiebreaks
261 - - [ ] Search endpoint: `GET /search?q=...&scope=...` returning HTML fragments (HTMX-compatible), scoped to community when on `/p/{slug}` with "search all" toggle
262 - - [ ] Search modal UI: overlay triggered by `/` key or header search icon, focused input, HTMX `hx-get` with `keyup changed delay:150ms`, results rendered inline (thread title with bolded match, body snippet, community/category breadcrumb, relative timestamp)
263 - - [ ] Keyboard navigation: arrow keys to move selection, Enter to navigate, Esc to close
264 - - [ ] Integration tests: search by exact title, fuzzy/typo match, body content match, scoped vs global, empty query returns nothing
265 -
266 - ---
267 -
268 - ## Phase 24 — Image Uploads
269 -
270 - Inline images in posts via S3. Drag-and-drop + paste support.
271 -
272 - ### Remaining
273 -
274 - - [ ] S3 bucket configuration (env vars: bucket, region, credentials — reuse MNW S3 infra if possible)
275 - - [ ] `POST /p/{slug}/upload` endpoint — logged-in only, accepts image files, returns markdown image link
276 - - [ ] File validation: image types only (png, jpg, gif, webp), max size (e.g. 5MB), strip EXIF metadata
277 - - [ ] JS: drag-and-drop + paste handler on reply/new-thread textarea — upload file, insert `![](url)` at cursor
278 - - [ ] Upload progress indicator (replace placeholder text while uploading)
279 - - [ ] Rendered images: max-width constrained, clickable to view full size (lightbox or new tab)
280 - - [ ] Rate limit: max uploads per user per hour
281 - - [ ] Mod tools: ability to remove uploaded images (removes from S3 + replaces in post with "[image removed]")
282 - - [ ] Integration tests: upload image, invalid type rejected, oversized rejected, image renders in post
11 + ## Pre-Beta
12 +
13 + - [ ] Deploy latest to hetzner+astra (v0.2.5 — includes phases 23-24, auto-hide, directory pagination, moderation tests)
14 + - [ ] Set `MT_BASE_URL=https://forums.makenot.work` in MNW production env (enables Forums dashboard tab)
15 + - [ ] Run migration 020 (auto_hide_threshold) on production DB
16 + - [ ] Configure S3 env vars on production (enables image uploads)
283 17
284 18 ---
285 19
@@ -306,7 +40,9 @@
306 40 | DB mutations | `crates/mt-db/src/mutations.rs` |
307 41 | Templates (Rust) | `src/templates/` |
308 42 | Templates (HTML) | `templates/` |
309 - | Routes | `src/routes/` (mod.rs, forum.rs, moderation.rs, settings.rs, admin.rs) |
43 + | Link previews | `src/link_preview.rs` |
44 + | S3 storage | `src/storage.rs` |
45 + | Routes | `src/routes/` (mod.rs, forum/{mod,views,actions}.rs, moderation.rs, settings.rs, admin.rs, flagging.rs, tracking.rs, search.rs, uploads.rs) |
310 46 | Auth (OAuth) | `src/auth.rs` |
311 47 | CSRF | `src/csrf.rs` |
312 48 | Markdown | `src/markdown.rs` |
@@ -314,7 +50,7 @@
314 50 | Seed data | `src/seed.rs` |
315 51 | Entry point | `src/main.rs` |
316 52 | Library root | `src/lib.rs` |
317 - | Migrations | `migrations/` |
53 + | Migrations | `migrations/` (001-020) |
318 54 | CSS | `static/style.css` |
319 55 | Deploy config | `deploy/` |
320 56 | Integration tests | `tests/` |
M todo_done.md +166
@@ -176,3 +176,169 @@
176 176 - [x] CommunityRow updated with suspended_at field
177 177 - [x] CommunityTemplate updated with is_mod_or_owner field
178 178 - [x] deploy/env.production updated with PLATFORM_ADMIN_ID
179 +
180 + ## Phase 14 — Immutable Posts + Footnotes
181 +
182 + - [x] Migration 011: `post_footnotes` table + `removed_by`/`removed_at` on posts
183 + - [x] Removed `update_post_body()`, `soft_delete_post()` from mutations.rs
184 + - [x] Added `insert_footnote()`, `mod_remove_post()` to mutations.rs
185 + - [x] Added `FootnoteWithAuthor`, `list_footnotes_for_posts()`, `get_post_body_markdown()` to queries.rs
186 + - [x] `PostWithAuthor` updated with `removed_at`
187 + - [x] Removed post edit/delete routes and handlers (`edit_post_form`, `edit_post_handler`, `delete_post_handler`)
188 + - [x] Removed `EditPostForm`, `EDIT_WINDOW_MINUTES`, `can_edit_post()`, `can_delete()`
189 + - [x] Restricted thread edit/delete to mod/owner only (was author OR mod)
190 + - [x] Added `add_footnote_handler()` — author-only, validates body, renders markdown, inserts footnote
191 + - [x] Added `mod_remove_post_handler()` in moderation.rs — mod/owner only, sets removed_by/removed_at, mod log entry
192 + - [x] Thread handler: batch-fetches footnotes, builds FootnoteViewRow per post, removed_at display
193 + - [x] Template changes: `PostRow` (removed is_edited/can_edit/can_delete, added is_removed/can_add_footnote/can_remove/footnotes), `FootnoteViewRow`, `ThreadTemplate.can_mod_thread`
194 + - [x] Deleted `EditPostTemplate`, `edit_post.html`
195 + - [x] Rewritten `thread.html`: post-item with data-post-id, mod remove button, footnotes section, footnote form (details/summary)
196 + - [x] CSS: .post-footnotes, .footnote, .footnote-prefix, .footnote-form-toggle, .post-removed
197 +
198 + ## Phase 21 — Verified Quoting
199 +
200 + - [x] `[quote:POST_ID:HASH]` format — HASH = first 8 hex chars of SHA-256 of quoted text
201 + - [x] `verify_quotes()` in forum.rs: regex extraction, substring check, hash verification, 422 on mismatch
202 + - [x] Verification wired into `create_reply_handler` and `create_thread_handler`
203 + - [x] `post_process_quotes()` in markdown.rs: replaces markers with `<cite class="quote-attribution">` linking to `#post-POST_ID`
204 + - [x] Thread handler: builds quote_authors map from post IDs, passes to post_process_quotes
205 + - [x] Inline JS: mouseup text selection → floating "Quote" button → SHA-256 via crypto.subtle.digest → blockquote + marker → insert into reply textarea → scroll to form
206 + - [x] CSS: .quote-attribution, .quote-btn
207 + - [x] Added regex-lite dependency
208 + - [x] 15 new integration tests: user_cannot_edit/delete_post, mod_can_remove_post, mod_can_edit/delete_thread, user_cannot_edit/delete_thread, add_footnote_by_author, add_footnote_non_author_rejected, multiple_footnotes_ordered, footnote_on_removed_post_rejected, valid_quote_accepted, fabricated_quote_rejected, altered_quote_rejected, quote_renders_with_attribution
209 +
210 + ## Phase 15 — Post Endorsements
211 +
212 + - [x] Migration 012: `post_endorsements` table — composite PK, CASCADE on post delete, index on endorser_id
213 + - [x] `list_endorsements_for_posts` batch query, `toggle_endorsement` mutation (INSERT ON CONFLICT + DELETE toggle)
214 + - [x] "Endorse" button on posts (not own, not removed, logged-in). Toggle, `.endorsed` CSS class.
215 + - [x] No public count. Count visible to: author, endorsers, mods only.
216 + - [x] Muted users CAN endorse. Banned/suspended cannot.
217 + - [x] 8 integration tests
218 +
219 + ## Phase 13 — Draft Auto-Save
220 +
221 + - [x] JS IIFE: debounced 1s save to localStorage keyed by page URL, for `#body` and `#reply-body` textareas
222 + - [x] Draft restore on page load with "Draft restored" indicator + "Discard" link
223 + - [x] Clear draft on form submission
224 + - [x] 7-day draft expiry
225 + - [x] Respects `mt_tracking_enabled` localStorage toggle
226 +
227 + ## Phase 17 — Post Flagging
228 +
229 + - [x] Migration 013: `post_flags` table — reason CHECK (spam/rule_breaking/off_topic), UNIQUE(post_id, flagger_id), indexes
230 + - [x] "Flag" `<details>` toggle on posts with radio buttons + optional detail textarea
231 + - [x] `POST /p/{slug}/{cat}/{thread_id}/posts/{post_id}/flag` — duplicate silently ignored
232 + - [x] "Pending Flags" section on moderation page with dismiss/remove actions
233 + - [x] `POST /p/{slug}/moderation/flags/{flag_id}/dismiss` + `.../remove`
234 + - [x] Remove via flag: mod-removes post + resolves all flags + mod log entry
235 + - [x] New route file: `src/routes/flagging.rs`
236 + - [x] 6 integration tests
237 +
238 + ## Phase 18 — Tags
239 +
240 + - [x] Migration 014: `tags` table (community-scoped, UNIQUE slug) + `thread_tags` join table
241 + - [x] Community settings: create/delete tags (owner only)
242 + - [x] Thread creation: tag checkboxes, custom `deserialize_string_or_seq` for serde_urlencoded compatibility
243 + - [x] Tag badges on thread listing rows
244 + - [x] Category filter by tag (`?tag=slug`) with tag chip UI
245 + - [x] Batch-fetch tags per thread (HashMap pattern)
246 + - [x] 5 integration tests
247 +
248 + ## Phase 16 — Unread/New Tracking
249 +
250 + - [x] Tier 1 (JS localStorage): `mt_thread_state` map, `data-thread-id`/`data-reply-count` attrs, `.unread` class, LRU cap 1000
251 + - [x] Migration 015: `tracked_threads` table (user_id, thread_id PK, last_read_post_id, tracked_at)
252 + - [x] Track/untrack buttons on thread page (logged-in only)
253 + - [x] `POST /p/{slug}/{cat}/{thread_id}/track` + `.../untrack` + `POST /tracked/stop-all`
254 + - [x] Read position upsert on tracked thread view (last post on current page)
255 + - [x] `/tracked` page: tracked threads with unread counts, "Stop tracking all"
256 + - [x] New route file: `src/routes/tracking.rs`
257 + - [x] 6 integration tests
258 +
259 + ## Phase 19 — @Mentions
260 +
261 + - [x] Parse `@username` in post body during markdown rendering — render as link to community-scoped profile (`/p/{slug}/u/{username}`)
262 + - [x] Migration 016: `post_mentions` table — `(post_id UUID, mentioned_user_id UUID, created_at TIMESTAMPTZ)`
263 + - [x] On post creation: extract `@username` tokens, resolve to user IDs, insert into `post_mentions` (self-mentions excluded)
264 + - [x] Unknown usernames left as plain text (not linked)
265 + - [x] Mentions inside code spans/blocks skipped
266 + - [x] Unit tests: extraction, dedup, code-span skip, fenced code, resolve valid/invalid/mixed (9 tests in markdown.rs)
267 + - [x] Integration tests: mention renders as profile link, mention stored in DB, self-mention not stored, unknown username left as text (4 tests)
268 + - [x] Category listing: threads where logged-in user was mentioned get violet left border + `@` badge (batch query `get_threads_with_mentions_for_user`)
269 + - [x] `/tracked` page: tracked threads with mentions show `@` badge + `mentioned` class (via `has_mention` EXISTS subselect)
270 + - [x] CSS: `.badge-mention` (violet `@` text), `tr.mentioned` (3px violet left border). Fixed `var(--accent)` → `var(--highlight)` bug.
271 + - [x] Integration tests: mention indicator on category listing, mention indicator on tracked page (2 tests)
272 +
273 + ## Phase 20 — Link Previews
274 +
275 + - [x] Migration 017: `link_previews` table — `(id UUID, post_id UUID, url TEXT, title TEXT, description TEXT, fetched_at TIMESTAMPTZ)`
276 + - [x] On post creation: extract URLs from body, fetch OpenGraph `og:title` + `og:description` (5s timeout, 1MB body cap)
277 + - [x] Fetch happens once at post creation time, not on every render. Failures logged, don't block post creation.
278 + - [x] Render below post body: card with title + description + URL, linked with `rel="noopener noreferrer nofollow"`
279 + - [x] Batch-fetch previews in thread view (same pattern as footnotes/endorsements)
280 + - [x] Unit tests: URL extraction from markdown, OG meta parsing, cap at 3 URLs (10 tests in link_preview.rs)
281 + - [x] Integration tests: preview renders in thread, no previews for plain text, multiple previews render (3 tests)
282 +
283 + ## Phase 23 — Search
284 +
285 + - [x] Migration 018: `CREATE EXTENSION pg_trgm`, GIN trigram indexes on threads.title and posts.body_markdown, generated tsvector columns + GIN indexes
286 + - [x] Search query: CTE combining tsvector/ts_rank + pg_trgm similarity, title matches ranked 2x above body, recency tiebreak
287 + - [x] `GET /search?q=...&scope=...` endpoint returning HTMX fragment, optional community scope
288 + - [x] Search modal UI: overlay triggered by `/` key or header "Search" button, HTMX `hx-get` with `keyup changed delay:150ms`
289 + - [x] Keyboard navigation: arrow keys to move selection, Enter to navigate, Esc to close
290 + - [x] No inline event handlers (XSS test compatible)
291 + - [x] New files: src/routes/search.rs, templates/fragments/search_results.html, migrations/018_search_indexes.sql
292 + - [x] Integration tests: search by title, body content match, scoped vs global, empty query returns nothing, deleted thread excluded (5 tests)
293 +
294 + ## Remaining Items from Completed Phases
295 +
296 + - [x] Endorsement count as profile stat — added endorsement_count subquery to get_user_profile_in_community, displayed on user_profile.html (Phase 15 → Phase 22)
297 + - [x] Opt-out toggle UI for Tier 1 unread tracking — checkbox on /tracked page toggles `mt_tracking_enabled` localStorage key (Phase 16)
298 + - [x] Privacy transparency static page — GET /about/tracking, explains Tier 1/Tier 2 tracking, no third-party analytics, linked from footer + tracked page (Phase 16)
299 + - [x] Integration tests: profile shows endorsement count, tracking info page loads (2 tests)
300 +
301 + ## Phase 24 — Image Uploads
302 +
303 + - [x] S3 config (S3_ENDPOINT, S3_BUCKET, S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY), graceful degradation when unconfigured
304 + - [x] `src/storage.rs`: S3Storage client (aws-sdk-s3), validate_image(), strip_exif_jpeg(), generate_image_key()
305 + - [x] `src/routes/uploads.rs`: POST /p/{slug}/upload (multipart), GET /uploads/{id} (proxy from S3), POST /p/{slug}/uploads/{id}/remove (mod)
306 + - [x] Migration 019: images table (id, uploader_id, community_id, s3_key, filename, content_type, size_bytes, created_at, removed_at, removed_by)
307 + - [x] DB: insert_image(), remove_image() mutations; get_image(), count_recent_uploads_by_user() queries
308 + - [x] File validation: png/jpg/gif/webp only, max 5MB, extension-content_type cross-validation
309 + - [x] EXIF stripping: JPEG APP1 (EXIF) and APP13 (IPTC) segments removed without re-encoding
310 + - [x] JS: drag-and-drop + paste handler on textareas, placeholder text during upload, CSRF token injection
311 + - [x] CSS: .post-body img max-width + clickable, textarea.drag-over dashed border
312 + - [x] Image click opens full size in new tab
313 + - [x] Rate limit: 20 uploads per user per hour
314 + - [x] Unit tests: 10 (validation + EXIF strip + key generation)
315 + - [x] Integration tests: 4 (auth required, 503 without S3, nonexistent 404, invalid UUID 404)
316 +
317 + ---
318 +
319 + ## Remaining Items — Resolved (2026-03-16)
320 +
321 + ### Moderation integration tests
322 + - [x] mod_remove_post_directly — mod removes post via direct handler, verifies DB state
323 + - [x] member_cannot_remove_post — non-mod gets 403
324 + - [x] removed_post_shows_removed_in_thread — CSS class appears after removal
325 + - [x] mod_log_shows_actions — mod log page renders actions + actor username
326 + - [x] mod_log_forbidden_for_members — non-mod gets 403
327 + - [x] moderation_page_shows_bans_and_flags — page lists banned users + pending flags
328 +
329 + ### Forum directory pagination
330 + - [x] list_communities paginated (LIMIT/OFFSET), count_communities query
331 + - [x] Pagination nav on forum directory template
332 + - [x] Integration test: 30 communities → 2 pages with Next/Previous links
333 +
334 + ### Auto-moderation: auto_hide_threshold
335 + - [x] Migration 020: `auto_hide_threshold` column on communities (INTEGER, nullable)
336 + - [x] CommunityRow includes auto_hide_threshold
337 + - [x] Settings form + handler saves threshold (0 = disabled/NULL)
338 + - [x] flag_post_handler checks threshold after inserting flag, auto-removes post if met
339 + - [x] count_pending_flags_for_post query
340 + - [x] Integration tests: threshold triggers removal, NULL disables auto-hide, settings saves threshold (3 tests)
341 + - [x] CSS: .form-help + .input-narrow for settings form
342 +
343 + ### MNW Forums tab
344 + - [x] Already implemented in MNW — dashboard tab, HTMX partial, MT_BASE_URL config
@@ -11,6 +11,28 @@
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 @@
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 @@
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 }
M src/main.rs +41
@@ -4,6 +4,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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)]
@@ -162,6 +361,13 @@
162 361 assert!(!result.contains("<script>"));
163 362 }
164 363
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 +
165 371 #[test]
166 372 fn javascript_url_sanitized() {
167 373 let result = render("[click me](javascript:alert(1))");
@@ -211,9 +417,79 @@
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 }
@@ -336,6 +336,11 @@
336 336 resize: vertical;
337 337 }
338 338
339 + textarea.drag-over {
340 + border: 2px dashed var(--highlight);
341 + background: var(--surface-muted);
342 + }
343 +
339 344 select {
340 345 cursor: pointer;
341 346 }
@@ -374,6 +379,17 @@
374 379 margin-top: 0.5rem;
375 380 }
376 381
382 + .form-help {
383 + display: block;
384 + font-size: 0.8rem;
385 + color: var(--text-muted);
386 + margin-top: 0.25rem;
387 + }
388 +
389 + .input-narrow {
390 + width: 6rem;
391 + }
392 +
377 393 /* ===========================================
378 394 DIRECTORY TABLE (forum home)
379 395 =========================================== */
@@ -777,6 +793,14 @@
777 793 color: var(--text-muted);
778 794 }
779 795
796 + .post-body img {
797 + max-width: 100%;
798 + height: auto;
799 + border-radius: 4px;
800 + margin: 0.5rem 0;
801 + cursor: pointer;
802 + }
803 +
780 804 /* Post action links (edit/delete) */
781 805 .post-actions {
782 806 margin-left: 0.75rem;
@@ -1140,6 +1164,477 @@
1140 1164 margin: 0 0.4rem;
1141 1165 }
1142 1166
1167 + /* ===========================================
1168 + FOOTNOTES + IMMUTABLE POSTS
1169 + =========================================== */
1170 +
1171 + .post-footnotes {
1172 + margin-top: 0.75rem;
1173 + padding-top: 0.5rem;
1174 + border-top: 1px solid var(--border);
1175 + }
1176 +
1177 + .footnote {
1178 + font-size: 0.85rem;
1179 + color: var(--text-muted);
1180 + margin-bottom: 0.5rem;
1181 + }
1182 +
1183 + .footnote p {
1184 + margin: 0.15rem 0;
1185 + }
1186 +
1187 + .footnote-prefix {
1188 + font-family: "IBM Plex Mono", monospace;
1189 + font-size: 0.75rem;
1190 + }
1191 +
1192 + .footnote-form-toggle {
1193 + margin-top: 0.5rem;
1194 + }
1195 +
1196 + .footnote-form-toggle textarea {
1197 + width: 100%;
1198 + min-height: 60px;
1199 + margin-top: 0.5rem;
1200 + }
1201 +
1202 + .badge-unread {
1203 + background: var(--highlight);
1204 + color: var(--primary-light);
1205 + font-size: 0.75rem;
1206 + padding: 0.1rem 0.4rem;
1207 + border-radius: 3px;
1208 + font-family: "IBM Plex Mono", monospace;
1209 + }
1210 +
1211 + tr.unread .thread-title a {
1212 + font-weight: 700;
1213 + }
1214 +
1215 + tr.unread .thread-title::before {
1216 + content: "";
1217 + display: inline-block;
1218 + width: 6px;
1219 + height: 6px;
1220 + background: var(--highlight);
1221 + border-radius: 50%;
1222 + margin-right: 0.35rem;
1223 + vertical-align: middle;
1224 + }
1225 +
1226 + /* Mention indicator on thread listings */
1227 + .badge-mention {
1228 + color: var(--highlight);
1229 + font-size: 0.75rem;
1230 + font-weight: 700;
1231 + font-family: "IBM Plex Mono", monospace;
1232 + background: none;
1233 + border: none;
1234 + padding: 0;
1235 + }
1236 +
1237 + tr.mentioned {
1238 + border-left: 3px solid var(--highlight);
1239 + }
1240 +
1241 + /* Tracking settings */
1242 + .tracking-settings {
1243 + display: flex;
1244 + align-items: center;
1245 + gap: 0.75rem;
1246 + margin-bottom: 1rem;
1247 + font-size: 0.85rem;
1248 + }
1249 +
1250 + .toggle-label {
1251 + display: flex;
1252 + align-items: center;
1253 + gap: 0.4rem;
1254 + cursor: pointer;
1255 + }
1256 +
1257 + .toggle-checkbox {
1258 + accent-color: var(--highlight);
1259 + }
1260 +
1261 + /* Info/static page sections */
1262 + .info-section h3 {
1263 + margin-top: 1.5rem;
1264 + margin-bottom: 0.5rem;
1265 + }
1266 +
1267 + .info-section ul {
1268 + padding-left: 1.5rem;
1269 + margin-bottom: 0.75rem;
1270 + }
1271 +
1272 + .info-section li {
1273 + margin-bottom: 0.3rem;
1274 + line-height: 1.5;
1275 + }
1276 +
1277 + .tag-filter {
1278 + margin-bottom: 1rem;
1279 + display: flex;
1280 + flex-wrap: wrap;
1281 + align-items: center;
1282 + gap: 0.35rem;
1283 + }
1284 +
1285 + .tag-filter-label {
1286 + font-family: "IBM Plex Mono", monospace;
1287 + font-size: 0.8rem;
1288 + color: var(--text-muted);
1289 + margin-right: 0.25rem;
1290 + }
1291 +
1292 + .tag-chip {
1293 + display: inline-block;
1294 + padding: 0.15rem 0.5rem;
1295 + font-size: 0.8rem;
1296 + border: 1px solid var(--border);
1297 + border-radius: 3px;
1298 + text-decoration: none;
1299 + color: var(--text-primary);
1300 + }
1301 +
1302 + .tag-chip:hover {
1303 + background: var(--surface-muted);
1304 + }
1305 +
1306 + .tag-chip.tag-active {
1307 + background: var(--primary-dark);
1308 + color: var(--primary-light);
1309 + border-color: var(--primary-dark);
1310 + }
1311 +
1312 + .tag-badge {
1313 + display: inline-block;
1314 + font-size: 0.7rem;
1315 + padding: 0.05rem 0.35rem;
1316 + background: var(--surface-muted);
1317 + border-radius: 2px;
1318 + margin-left: 0.25rem;
1319 + vertical-align: middle;
1320 + font-family: "IBM Plex Mono", monospace;
1321 + color: var(--text-muted);
1322 + }
1323 +
1324 + .tag-fieldset {
1325 + border: none;
1326 + padding: 0;
1327 + }
1328 +
1329 + .tag-fieldset legend {
1330 + font-weight: 600;
1331 + margin-bottom: 0.35rem;
1332 + }
1333 +
1334 + .tag-checkbox {
1335 + display: inline-block;
1336 + margin-right: 1rem;
1337 + font-size: 0.9rem;
1338 + cursor: pointer;
1339 + }
1340 +
1341 + .flag-form-toggle {
1342 + margin-top: 0.5rem;
1343 + }
1344 +
1345 + .flag-form {
1346 + margin-top: 0.5rem;
1347 + display: flex;
1348 + flex-direction: column;
1349 + gap: 0.35rem;
1350 + }
1351 +
1352 + .flag-form label {
1353 + font-size: 0.85rem;
1354 + cursor: pointer;
1355 + }
1356 +
1357 + .flag-form textarea {
1358 + width: 100%;
1359 + min-height: 40px;
1360 + margin-top: 0.25rem;
1361 + }
1362 +
1363 + .flag-btn {
1364 + align-self: flex-start;
1365 + font-size: 0.8rem;
1366 + padding: 0.25rem 0.75rem;
1367 + }
1368 +
1369 + .post-removed .post-body {
1370 + color: var(--text-muted);
1371 + font-style: italic;
1372 + }
1373 +
1374 + .quote-attribution {
1375 + display: block;
1376 + font-size: 0.8rem;
1377 + font-family: "IBM Plex Mono", monospace;
1378 + color: var(--text-muted);
1379 + margin-top: 0.25rem;
1380 + font-style: normal;
1381 + }
1382 +
1383 + .quote-attribution a {
1384 + color: var(--text-muted);
1385 + text-decoration: none;
1386 + }
1387 +
1388 + .quote-attribution a:hover {
1389 + text-decoration: underline;
1390 + }
1391 +
1392 + .quote-btn {
1393 + position: absolute;
1394 + z-index: 100;
1395 + background: var(--surface);
1396 + border: 1px solid var(--border);
1397 + padding: 0.25rem 0.5rem;
1398 + font-size: 0.75rem;
1399 + font-family: "IBM Plex Mono", monospace;
1400 + cursor: pointer;
1401 + }
1402 +
1403 + .quote-btn:hover {
1404 + background: var(--light-background);
1405 + }
1406 +
1407 + /* ===========================================
1408 + UTILITY CLASSES
1409 + =========================================== */
1410 +
1411 + .form-inline { display: inline; }
1412 + .form-inline-row { display: inline; gap: 0.25rem; flex-direction: row; align-items: center; }
1413 + .btn-secondary { text-decoration: none; }
1414 + .section-heading { margin-bottom: 1.5rem; }
1415 + .section-heading-top { margin-top: 1.5rem; }
1416 + .textarea-short { min-height: 60px; }
1417 + .textarea-medium { min-height: 80px; }
1418 + .footnote-btn { margin-top: 0.25rem; }
1419 + .input-compact { width: 150px; padding: 0.3rem; }
1420 + .search-form { flex-direction: row; gap: 0.5rem; align-items: flex-end; }
1421 + .flex-1 { flex: 1; }
1422 +
1423 + .profile-header { display: flex; align-items: center; gap: 1.25rem; margin-bottom: 1.5rem; }
1424 + .profile-avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; }
1425 + .profile-avatar-placeholder {
1426 + width: 64px; height: 64px; border-radius: 50%; background: var(--border-color);
1427 + display: flex; align-items: center; justify-content: center;
1428 + font-size: 1.5rem; font-weight: bold; color: var(--text-muted); flex-shrink: 0;
1429 + }
1430 + .profile-name { margin: 0; }
1431 + .text-muted { color: var(--text-muted); }
1432 + .text-muted-sm { color: var(--text-muted); font-size: 0.85rem; }
1433 + .text-muted-md { color: var(--text-muted); font-size: 0.9rem; }
1434 + .community-desc { margin-bottom: 0.5rem; color: var(--text-muted); }
1435 + .activity-category { color: var(--text-muted); font-size: 0.85rem; }
1436 +
1437 + /* ===========================================
1438 + LINK PREVIEWS
1439 + =========================================== */
1440 +
1441 + .post-link-previews {
1442 + margin-top: 0.75rem;
1443 + display: flex;
1444 + flex-direction: column;
1445 + gap: 0.5rem;
1446 + }
1447 +
1448 + .link-preview-card {
1449 + display: block;
1450 + padding: 0.5rem 0.75rem;
1451 + border: 1px solid var(--border);
1452 + background: var(--light-background);
1453 + text-decoration: none;
1454 + color: var(--detail);
1455 + transition: background 0.2s ease;
1456 + }
1457 +
1458 + .link-preview-card:hover {
1459 + background: var(--surface-muted);
1460 + text-decoration: none;
1461 + }
1462 +
1463 + .lp-title {
1464 + display: block;
1465 + font-family: "IBM Plex Mono", monospace;
1466 + font-size: 0.85rem;
1467 + font-weight: bold;
1468 + }
1469 +
1470 + .lp-desc {
1471 + display: block;
1472 + font-size: 0.8rem;
1473 + color: var(--text-muted);
1474 + margin-top: 0.15rem;
1475 + }
1476 +
1477 + .lp-url {
1478 + display: block;
1479 + font-size: 0.7rem;
1480 + font-family: "IBM Plex Mono", monospace;
1481 + color: var(--text-muted);
1482 + margin-top: 0.15rem;
1483 + }
1484 +
1485 + /* ===========================================
1486 + SEARCH MODAL
1487 + =========================================== */
1488 +
1489 + .search-toggle {
1490 + font-family: "IBM Plex Mono", monospace;
1491 + font-size: 0.85rem;
1492 + color: var(--text-muted);
1493 + background: none;
1494 + border: 1px solid var(--border);
1495 + padding: 0.25rem 0.75rem;
1496 + cursor: pointer;
1497 + transition: opacity 0.2s ease;
1498 + }
1499 +
1500 + .search-toggle:hover {
1501 + opacity: 0.7;
1502 + }
1503 +
1504 + .search-modal {
1505 + position: fixed;
1506 + top: 0;
1507 + left: 0;
1508 + right: 0;
1509 + bottom: 0;
1510 + z-index: 200;
1511 + display: flex;
1512 + justify-content: center;
1513 + padding-top: 10vh;
1514 + }
1515 +
1516 + .search-modal[hidden] {
1517 + display: none;
1518 + }
1519 +
1520 + .search-modal-backdrop {
1521 + position: fixed;
1522 + top: 0;
1523 + left: 0;
1524 + right: 0;
1525 + bottom: 0;
1526 + background: rgba(0, 0, 0, 0.3);
1527 + }
1528 +
1529 + .search-modal-content {
1530 + position: relative;
1531 + width: 100%;
1532 + max-width: 600px;
1533 + max-height: 70vh;
1534 + display: flex;
1535 + flex-direction: column;
1536 + background: var(--light-background);
1537 + border: 1px solid var(--border);
1538 + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
1539 + z-index: 1;
1540 + }
1541 +
1542 + .search-input {
1543 + width: 100%;
1544 + padding: 0.75rem 1rem;
1545 + font-size: 1rem;
1546 + font-family: "IBM Plex Mono", monospace;
1547 + background: var(--background);
1548 + border: none;
1549 + border-bottom: 1px solid var(--border);
1550 + color: var(--detail);
1551 + outline: none;
1552 + }
1553 +
1554 + .search-input:focus {
1555 + border-bottom-color: var(--highlight);
1556 + }
1557 +
1558 + .search-results-container {
1559 + overflow-y: auto;
1560 + max-height: calc(70vh - 3rem);
1561 + }
1562 +
1563 + .search-results {
1564 + list-style: none;
1565 + margin: 0;
1566 + padding: 0;
1567 + }
1568 +
1569 + .search-result {
1570 + border-bottom: 1px solid var(--border);
1571 + }
1572 +
1573 + .search-result:last-child {
1574 + border-bottom: none;
1575 + }
1576 +
1577 + .search-result-link {
1578 + display: block;
1579 + padding: 0.6rem 1rem;
1580 + text-decoration: none;
1581 + color: var(--detail);
1582 + transition: background 0.1s ease;
1583 + }
1584 +
1585 + .search-result-link:hover,
1586 + .search-result.search-active .search-result-link {
1587 + background: var(--surface-muted);
1588 + text-decoration: none;
1589 + }
1590 +
1591 + .search-result-title {
1592 + display: block;
1593 + font-weight: bold;
1594 + font-size: 0.9rem;
1595 + }
1596 +
1597 + .search-result-meta {
1598 + display: block;
1599 + font-family: "IBM Plex Mono", monospace;
1600 + font-size: 0.7rem;
1601 + color: var(--text-muted);
1602 + margin-top: 0.15rem;
1603 + }
1604 +
1605 + .search-result-snippet {
1606 + display: block;
1607 + font-size: 0.8rem;
1608 + color: var(--text-muted);
1609 + margin-top: 0.15rem;
1610 + overflow: hidden;
1611 + text-overflow: ellipsis;
1612 + white-space: nowrap;
1613 + }
1614 +
1615 + .search-empty {
1616 + padding: 1.5rem 1rem;
1617 + text-align: center;
1618 + color: var(--text-muted);
1619 + font-family: "IBM Plex Mono", monospace;
1620 + font-size: 0.85rem;
1621 + }
Lines truncated
M templates/base.html +13 -203
@@ -7,20 +7,6 @@
7 7 <title>{% block title %}Multithreaded{% endblock %}</title>
8 8 <link rel="stylesheet" href="/static/style.css">
9 9 <script src="/static/htmx.min.js"></script>
10 - <script>
11 - function csrfHeaders() {
12 - var token = document.querySelector('meta[name="csrf-token"]')?.content;
13 - return token ? { 'X-CSRF-Token': token } : {};
14 - }
15 - document.addEventListener('DOMContentLoaded', function() {
16 - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
17 - if (csrfToken) {
18 - document.body.addEventListener('htmx:configRequest', function(evt) {
19 - evt.detail.headers['X-CSRF-Token'] = csrfToken;
20 - });
21 - }
22 - });
23 - </script>
24 10 {% block head %}{% endblock %}
25 11 </head>
26 12 <body data-mnw-url="{{ mnw_base_url }}"{% block body_attrs %}{% endblock %}>
@@ -39,199 +25,23 @@
39 25 </div>
40 26 <span>Powered by <a href="{{ mnw_base_url }}/">Makenot<span class="dot">.</span>work</a></span>
41 27 <span class="footer-sep">&middot;</span>
28 + <a href="/about/tracking">Tracking</a>
29 + <span class="footer-sep">&middot;</span>
42 30 <span>Report abuse: moderation@makenot.work</span>
43 31 </footer>
44 32
33 + <div id="search-modal" class="search-modal" hidden aria-modal="true" role="dialog" aria-label="Search forums">
34 + <div class="search-modal-backdrop" id="search-backdrop"></div>
35 + <div class="search-modal-content">
36 + <input type="text" id="search-input" class="search-input" placeholder="Search threads..."
37 + autocomplete="off" spellcheck="false"
38 + hx-get="/search" hx-trigger="keyup changed delay:150ms" hx-target="#search-results"
39 + hx-swap="innerHTML" name="q">
40 + <div id="search-results" class="search-results-container"></div>
41 + </div>
42 + </div>
45 43 <div id="notifications" class="toast-container" role="alert" aria-live="polite"></div>
46 -
47 - <script>
48 - document.body.addEventListener('showToast', function(evt) {
49 - const container = document.getElementById('notifications');
50 - const toast = document.createElement('div');
51 - toast.className = 'toast toast-' + (evt.detail.type || 'info');
52 - toast.textContent = evt.detail.message || 'Action completed';
53 - container.appendChild(toast);
54 - setTimeout(() => {
55 - toast.classList.add('fade-out');
56 - setTimeout(() => toast.remove(), 300);
57 - }, 3000);
58 - });
59 -
60 - function showToast(message, type) {
61 - document.body.dispatchEvent(new CustomEvent('showToast', {
62 - detail: { message: message, type: type || 'error' }
63 - }));
64 - }
65 -
66 - document.body.addEventListener('htmx:responseError', function(evt) {
67 - const container = document.getElementById('notifications');
68 - const toast = document.createElement('div');
69 - toast.className = 'toast toast-error';
70 - const msg = document.createElement('span');
71 - msg.textContent = 'An error occurred.';
72 - toast.appendChild(msg);
73 - const retryBtn = document.createElement('button');
74 - retryBtn.textContent = 'Retry';
75 - retryBtn.className = 'toast-retry-btn';
76 - retryBtn.onclick = function() {
77 - toast.remove();
78 - var elt = evt.detail.elt;
79 - if (elt) htmx.trigger(elt, htmx.closest(elt, '[hx-trigger]') ? 'htmx:trigger' : 'click');
80 - };
81 - toast.appendChild(retryBtn);
82 - container.appendChild(toast);
83 - setTimeout(() => {
84 - toast.classList.add('fade-out');
85 - setTimeout(() => toast.remove(), 300);
86 - }, 6000);
87 - });
88 -
89 - document.body.addEventListener('htmx:beforeRequest', function(evt) {
90 - const form = evt.detail.elt.closest('form');
91 - if (form) {
92 - const btn = form.querySelector('button[type="submit"], .primary');
93 - if (btn) { btn.dataset.origText = btn.textContent; btn.textContent = 'Saving...'; btn.disabled = true; }
94 - }
95 - });
96 - document.body.addEventListener('htmx:afterRequest', function(evt) {
97 - const form = evt.detail.elt.closest('form');
98 - if (form) {
99 - const btn = form.querySelector('button[type="submit"], .primary');
100 - if (btn && btn.dataset.origText) { btn.textContent = btn.dataset.origText; btn.disabled = false; }
101 - }
102 - });
103 -
104 - document.addEventListener('keydown', function(e) {
105 - if (e.key === 'Escape') {
106 - var overlay = document.querySelector('.modal-overlay');
107 - if (overlay) overlay.remove();
108 - }
109 - if ((e.metaKey || e.ctrlKey) && e.key === 's') {
110 - e.preventDefault();
111 - var form = document.activeElement?.closest('form');
112 - if (form) { var btn = form.querySelector('button[type="submit"]'); if (btn) btn.click(); }
113 - }
114 - });
115 -
116 - document.addEventListener('click', function(e) {
117 - var toggle = document.getElementById('nav-toggle');
118 - if (toggle && toggle.checked && e.target.closest('.nav-links a, .nav-links .link-button')) {
119 - toggle.checked = false;
120 - }
121 - });
122 -
123 - // Confirm before navigating to MNW (external) links
124 - (function() {
125 - var mnwUrl = document.body.dataset.mnwUrl;
126 - if (!mnwUrl) return;
127 - document.addEventListener('click', function(e) {
128 - var link = e.target.closest('a[href]');
129 - if (!link) return;
130 - var href = link.getAttribute('href');
131 - if (href && href.indexOf(mnwUrl) === 0) {
132 - e.preventDefault();
133 - if (confirm('You are about to leave Multithreaded for Makenot.work. Continue?')) {
134 - window.location.href = href;
135 - }
136 - }
137 - });
138 - })();
139 - </script>
140 - <script>
141 - document.addEventListener('submit', function(e) {
142 - var form = e.target;
143 - if (form.method && form.method.toUpperCase() === 'POST') {
144 - var token = document.querySelector('meta[name="csrf-token"]')?.content;
145 - if (!token) return;
146 - e.preventDefault();
147 - fetch(form.action || window.location.href, {
148 - method: 'POST',
149 - headers: { 'X-CSRF-Token': token, 'Content-Type': 'application/x-www-form-urlencoded' },
150 - body: new URLSearchParams(new FormData(form)),
151 - redirect: 'follow',
152 - }).then(function(resp) { window.location.href = resp.url; });
153 - }
154 - });
155 - </script>
156 - <script>
157 - (function() {
158 - var p = new URLSearchParams(window.location.search).get('toast');
159 - if (p) {
160 - showToast(decodeURIComponent(p), 'success');
161 - history.replaceState(null, '', window.location.pathname);
162 - }
163 - })();
164 - </script>
165 - <script>
166 - (function() {
167 - var body = document.getElementById('body');
168 - if (!body || body.tagName !== 'TEXTAREA') return;
169 - if (localStorage.getItem('mt_tracking_enabled') === 'false') return;
170 -
171 - var title = document.getElementById('title');
172 - if (title && title.tagName !== 'INPUT') title = null;
173 - var key = 'mt_draft:' + window.location.pathname;
174 - var form = body.closest('form');
175 - var timer = null;
176 - var MAX_DRAFTS = 20;
177 - var WEEK_MS = 7 * 24 * 60 * 60 * 1000;
178 -
179 - // Restore
180 - var raw = localStorage.getItem(key);
181 - if (raw && !body.value.trim()) {
182 - try {
183 - var draft = JSON.parse(raw);
184 - if (Date.now() - draft.ts > WEEK_MS) { localStorage.removeItem(key); }
185 - else {
186 - body.value = draft.body || '';
187 - if (title && !title.value.trim()) title.value = draft.title || '';
188 - var ind = document.createElement('div');
189 - ind.className = 'draft-indicator';
190 - ind.textContent = 'Draft restored. ';
191 - var discard = document.createElement('a');
192 - discard.textContent = 'Discard';
193 - discard.href = '#';
194 - discard.className = 'draft-discard';
195 - discard.onclick = function(e) {
196 - e.preventDefault();
197 - localStorage.removeItem(key);
198 - body.value = '';
199 - if (title) title.value = '';
200 - ind.remove();
201 - };
202 - ind.appendChild(discard);
203 - body.parentNode.insertBefore(ind, body);
204 - }
205 - } catch(e) { localStorage.removeItem(key); }
206 - }
207 -
208 - // Save (debounced)
209 - function save() {
210 - var b = body.value.trim();
211 - var t = title ? title.value.trim() : '';
212 - if (!b && !t) { localStorage.removeItem(key); return; }
213 - localStorage.setItem(key, JSON.stringify({ body: body.value, title: title ? title.value : '', ts: Date.now() }));
214 - // LRU cleanup
215 - var drafts = [];
216 - for (var i = 0; i < localStorage.length; i++) {
217 - var k = localStorage.key(i);
218 - if (k && k.indexOf('mt_draft:') === 0 && k !== key) {
219 - try { drafts.push({ k: k, ts: JSON.parse(localStorage.getItem(k)).ts }); } catch(e) {}
220 - }
221 - }
222 - if (drafts.length >= MAX_DRAFTS) {
223 - drafts.sort(function(a, b) { return a.ts - b.ts; });
224 - while (drafts.length >= MAX_DRAFTS) { localStorage.removeItem(drafts.shift().k); }
225 - }
226 - }
227 - function debounced() { clearTimeout(timer); timer = setTimeout(save, 1000); }
228 - body.addEventListener('input', debounced);
229 - if (title) title.addEventListener('input', debounced);
230 -
231 - // Clear on submit
232 - if (form) form.addEventListener('submit', function() { localStorage.removeItem(key); });
233 - })();
234 - </script>
44 + <script src="/static/mt.js"></script>
235 45 {% block scripts %}{% endblock %}
236 46 </body>
237 47 </html>
M src/routes/mod.rs +244 -69
A static/mt.js +530