Skip to main content

max / multithreaded

Initial commit — Multithreaded forum software v0.2.0 MNW-integrated forum with OAuth PKCE login, community moderation (ban/mute with expiry, role hierarchy, mod log), platform admin, CSRF protection, markdown rendering with XSS sanitization, pagination, and 97 tests. All audit findings resolved. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 18:23 UTC
Commit: c5d417ecf5adca16a7cbfb976f9ac52e5ea149b0
82 files changed, +10613 insertions, -0 deletions
A .env.example +14
@@ -0,0 +1,14 @@
1 + # Required
2 + DATABASE_URL=postgres:///multithreaded
3 + OAUTH_CLIENT_ID=your-oauth-client-id
4 +
5 + # Optional (defaults shown)
6 + MNW_BASE_URL=http://127.0.0.1:3000
7 + OAUTH_REDIRECT_URI=http://127.0.0.1:3400/auth/callback
8 + HOST=0.0.0.0
9 + PORT=3400
10 + COOKIE_SECURE=true
11 + RUST_LOG=info
12 +
13 + # Platform admin (UUID of the MNW account that can access /_admin)
14 + # PLATFORM_ADMIN_ID=00000000-0000-0000-0000-000000000000
A .gitignore +19
@@ -0,0 +1,19 @@
1 + # Build artifacts
2 + /target/
3 +
4 + # Environment
5 + .env
6 + .env.*
7 + !.env.example
8 +
9 + # IDE
10 + .idea/
11 + .vscode/
12 + *.swp
13 + *.swo
14 +
15 + # macOS
16 + .DS_Store
17 +
18 + # Release artifacts
19 + dist/
A Cargo.lock +500
@@ -0,0 +1,3407 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "aho-corasick"
7 + version = "1.1.4"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10 + dependencies = [
11 + "memchr",
12 + ]
13 +
14 + [[package]]
15 + name = "allocator-api2"
16 + version = "0.2.21"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
19 +
20 + [[package]]
21 + name = "android_system_properties"
22 + version = "0.1.5"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
25 + dependencies = [
26 + "libc",
27 + ]
28 +
29 + [[package]]
30 + name = "anyhow"
31 + version = "1.0.102"
32 + source = "registry+https://github.com/rust-lang/crates.io-index"
33 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
34 +
35 + [[package]]
36 + name = "askama"
37 + version = "0.13.1"
38 + source = "registry+https://github.com/rust-lang/crates.io-index"
39 + checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
40 + dependencies = [
41 + "askama_derive",
42 + "itoa",
43 + "percent-encoding",
44 + "serde",
45 + "serde_json",
46 + ]
47 +
48 + [[package]]
49 + name = "askama_derive"
50 + version = "0.13.1"
51 + source = "registry+https://github.com/rust-lang/crates.io-index"
52 + checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
53 + dependencies = [
54 + "askama_parser",
55 + "basic-toml",
56 + "memchr",
57 + "proc-macro2",
58 + "quote",
59 + "rustc-hash",
60 + "serde",
61 + "serde_derive",
62 + "syn",
63 + ]
64 +
65 + [[package]]
66 + name = "askama_parser"
67 + version = "0.13.0"
68 + source = "registry+https://github.com/rust-lang/crates.io-index"
69 + checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
70 + dependencies = [
71 + "memchr",
72 + "serde",
73 + "serde_derive",
74 + "winnow",
75 + ]
76 +
77 + [[package]]
78 + name = "async-trait"
79 + version = "0.1.89"
80 + source = "registry+https://github.com/rust-lang/crates.io-index"
81 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
82 + dependencies = [
83 + "proc-macro2",
84 + "quote",
85 + "syn",
86 + ]
87 +
88 + [[package]]
89 + name = "atoi"
90 + version = "2.0.0"
91 + source = "registry+https://github.com/rust-lang/crates.io-index"
92 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
93 + dependencies = [
94 + "num-traits",
95 + ]
96 +
97 + [[package]]
98 + name = "atomic-waker"
99 + version = "1.1.2"
100 + source = "registry+https://github.com/rust-lang/crates.io-index"
101 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
102 +
103 + [[package]]
104 + name = "autocfg"
105 + version = "1.5.0"
106 + source = "registry+https://github.com/rust-lang/crates.io-index"
107 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
108 +
109 + [[package]]
110 + name = "axum"
111 + version = "0.8.8"
112 + source = "registry+https://github.com/rust-lang/crates.io-index"
113 + checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
114 + dependencies = [
115 + "axum-core",
116 + "base64",
117 + "bytes",
118 + "form_urlencoded",
119 + "futures-util",
120 + "http",
121 + "http-body",
122 + "http-body-util",
123 + "hyper",
124 + "hyper-util",
125 + "itoa",
126 + "matchit",
127 + "memchr",
128 + "mime",
129 + "percent-encoding",
130 + "pin-project-lite",
131 + "serde_core",
132 + "serde_json",
133 + "serde_path_to_error",
134 + "serde_urlencoded",
135 + "sha1",
136 + "sync_wrapper",
137 + "tokio",
138 + "tokio-tungstenite",
139 + "tower",
140 + "tower-layer",
141 + "tower-service",
142 + "tracing",
143 + ]
144 +
145 + [[package]]
146 + name = "axum-core"
147 + version = "0.5.6"
148 + source = "registry+https://github.com/rust-lang/crates.io-index"
149 + checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
150 + dependencies = [
151 + "bytes",
152 + "futures-core",
153 + "http",
154 + "http-body",
155 + "http-body-util",
156 + "mime",
157 + "pin-project-lite",
158 + "sync_wrapper",
159 + "tower-layer",
160 + "tower-service",
161 + "tracing",
162 + ]
163 +
164 + [[package]]
165 + name = "base64"
166 + version = "0.22.1"
167 + source = "registry+https://github.com/rust-lang/crates.io-index"
168 + checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
169 +
170 + [[package]]
171 + name = "base64ct"
172 + version = "1.8.3"
173 + source = "registry+https://github.com/rust-lang/crates.io-index"
174 + checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
175 +
176 + [[package]]
177 + name = "basic-toml"
178 + version = "0.1.10"
179 + source = "registry+https://github.com/rust-lang/crates.io-index"
180 + checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
181 + dependencies = [
182 + "serde",
183 + ]
184 +
185 + [[package]]
186 + name = "bitflags"
187 + version = "2.11.0"
188 + source = "registry+https://github.com/rust-lang/crates.io-index"
189 + checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
190 + dependencies = [
191 + "serde_core",
192 + ]
193 +
194 + [[package]]
195 + name = "block-buffer"
196 + version = "0.10.4"
197 + source = "registry+https://github.com/rust-lang/crates.io-index"
198 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
199 + dependencies = [
200 + "generic-array",
201 + ]
202 +
203 + [[package]]
204 + name = "bumpalo"
205 + version = "3.20.2"
206 + source = "registry+https://github.com/rust-lang/crates.io-index"
207 + checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
208 +
209 + [[package]]
210 + name = "byteorder"
211 + version = "1.5.0"
212 + source = "registry+https://github.com/rust-lang/crates.io-index"
213 + checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
214 +
215 + [[package]]
216 + name = "bytes"
217 + version = "1.11.1"
218 + source = "registry+https://github.com/rust-lang/crates.io-index"
219 + checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
220 +
221 + [[package]]
222 + name = "cc"
223 + version = "1.2.56"
224 + source = "registry+https://github.com/rust-lang/crates.io-index"
225 + checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
226 + dependencies = [
227 + "find-msvc-tools",
228 + "shlex",
229 + ]
230 +
231 + [[package]]
232 + name = "cfg-if"
233 + version = "1.0.4"
234 + source = "registry+https://github.com/rust-lang/crates.io-index"
235 + checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
236 +
237 + [[package]]
238 + name = "chrono"
239 + version = "0.4.44"
240 + source = "registry+https://github.com/rust-lang/crates.io-index"
241 + checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
242 + dependencies = [
243 + "iana-time-zone",
244 + "js-sys",
245 + "num-traits",
246 + "serde",
247 + "wasm-bindgen",
248 + "windows-link",
249 + ]
250 +
251 + [[package]]
252 + name = "concurrent-queue"
253 + version = "2.5.0"
254 + source = "registry+https://github.com/rust-lang/crates.io-index"
255 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
256 + dependencies = [
257 + "crossbeam-utils",
258 + ]
259 +
260 + [[package]]
261 + name = "const-oid"
262 + version = "0.9.6"
263 + source = "registry+https://github.com/rust-lang/crates.io-index"
264 + checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
265 +
266 + [[package]]
267 + name = "cookie"
268 + version = "0.18.1"
269 + source = "registry+https://github.com/rust-lang/crates.io-index"
270 + checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
271 + dependencies = [
272 + "percent-encoding",
273 + "time",
274 + "version_check",
275 + ]
276 +
277 + [[package]]
278 + name = "core-foundation"
279 + version = "0.9.4"
280 + source = "registry+https://github.com/rust-lang/crates.io-index"
281 + checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
282 + dependencies = [
283 + "core-foundation-sys",
284 + "libc",
285 + ]
286 +
287 + [[package]]
288 + name = "core-foundation"
289 + version = "0.10.1"
290 + source = "registry+https://github.com/rust-lang/crates.io-index"
291 + checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
292 + dependencies = [
293 + "core-foundation-sys",
294 + "libc",
295 + ]
296 +
297 + [[package]]
298 + name = "core-foundation-sys"
299 + version = "0.8.7"
300 + source = "registry+https://github.com/rust-lang/crates.io-index"
301 + checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
302 +
303 + [[package]]
304 + name = "cpufeatures"
305 + version = "0.2.17"
306 + source = "registry+https://github.com/rust-lang/crates.io-index"
307 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
308 + dependencies = [
309 + "libc",
310 + ]
311 +
312 + [[package]]
313 + name = "crc"
314 + version = "3.4.0"
315 + source = "registry+https://github.com/rust-lang/crates.io-index"
316 + checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
317 + dependencies = [
318 + "crc-catalog",
319 + ]
320 +
321 + [[package]]
322 + name = "crc-catalog"
323 + version = "2.4.0"
324 + source = "registry+https://github.com/rust-lang/crates.io-index"
325 + checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
326 +
327 + [[package]]
328 + name = "crossbeam-queue"
329 + version = "0.3.12"
330 + source = "registry+https://github.com/rust-lang/crates.io-index"
331 + checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
332 + dependencies = [
333 + "crossbeam-utils",
334 + ]
335 +
336 + [[package]]
337 + name = "crossbeam-utils"
338 + version = "0.8.21"
339 + source = "registry+https://github.com/rust-lang/crates.io-index"
340 + checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
341 +
342 + [[package]]
343 + name = "crypto-common"
344 + version = "0.1.7"
345 + source = "registry+https://github.com/rust-lang/crates.io-index"
346 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
347 + dependencies = [
348 + "generic-array",
349 + "typenum",
350 + ]
351 +
352 + [[package]]
353 + name = "data-encoding"
354 + version = "2.10.0"
355 + source = "registry+https://github.com/rust-lang/crates.io-index"
356 + checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
357 +
358 + [[package]]
359 + name = "der"
360 + version = "0.7.10"
361 + source = "registry+https://github.com/rust-lang/crates.io-index"
362 + checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
363 + dependencies = [
364 + "const-oid",
365 + "pem-rfc7468",
366 + "zeroize",
367 + ]
368 +
369 + [[package]]
370 + name = "deranged"
371 + version = "0.5.8"
372 + source = "registry+https://github.com/rust-lang/crates.io-index"
373 + checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
374 + dependencies = [
375 + "powerfmt",
376 + "serde_core",
377 + ]
378 +
379 + [[package]]
380 + name = "digest"
381 + version = "0.10.7"
382 + source = "registry+https://github.com/rust-lang/crates.io-index"
383 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
384 + dependencies = [
385 + "block-buffer",
386 + "const-oid",
387 + "crypto-common",
388 + "subtle",
389 + ]
390 +
391 + [[package]]
392 + name = "displaydoc"
393 + version = "0.2.5"
394 + source = "registry+https://github.com/rust-lang/crates.io-index"
395 + checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
396 + dependencies = [
397 + "proc-macro2",
398 + "quote",
399 + "syn",
400 + ]
401 +
402 + [[package]]
403 + name = "dotenvy"
404 + version = "0.15.7"
405 + source = "registry+https://github.com/rust-lang/crates.io-index"
406 + checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
407 +
408 + [[package]]
409 + name = "either"
410 + version = "1.15.0"
411 + source = "registry+https://github.com/rust-lang/crates.io-index"
412 + checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
413 + dependencies = [
414 + "serde",
415 + ]
416 +
417 + [[package]]
418 + name = "encoding_rs"
419 + version = "0.8.35"
420 + source = "registry+https://github.com/rust-lang/crates.io-index"
421 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
422 + dependencies = [
423 + "cfg-if",
424 + ]
425 +
426 + [[package]]
427 + name = "equivalent"
428 + version = "1.0.2"
429 + source = "registry+https://github.com/rust-lang/crates.io-index"
430 + checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
431 +
432 + [[package]]
433 + name = "errno"
434 + version = "0.3.14"
435 + source = "registry+https://github.com/rust-lang/crates.io-index"
436 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
437 + dependencies = [
438 + "libc",
439 + "windows-sys 0.61.2",
440 + ]
441 +
442 + [[package]]
443 + name = "etcetera"
444 + version = "0.8.0"
445 + source = "registry+https://github.com/rust-lang/crates.io-index"
446 + checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
447 + dependencies = [
448 + "cfg-if",
449 + "home",
450 + "windows-sys 0.48.0",
451 + ]
452 +
453 + [[package]]
454 + name = "event-listener"
455 + version = "5.4.1"
456 + source = "registry+https://github.com/rust-lang/crates.io-index"
457 + checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
458 + dependencies = [
459 + "concurrent-queue",
460 + "parking",
461 + "pin-project-lite",
462 + ]
463 +
464 + [[package]]
465 + name = "fastrand"
466 + version = "2.3.0"
467 + source = "registry+https://github.com/rust-lang/crates.io-index"
468 + checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
469 +
470 + [[package]]
471 + name = "find-msvc-tools"
472 + version = "0.1.9"
473 + source = "registry+https://github.com/rust-lang/crates.io-index"
474 + checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
475 +
476 + [[package]]
477 + name = "flume"
478 + version = "0.11.1"
479 + source = "registry+https://github.com/rust-lang/crates.io-index"
480 + checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
481 + dependencies = [
482 + "futures-core",
483 + "futures-sink",
484 + "spin",
485 + ]
486 +
487 + [[package]]
488 + name = "fnv"
489 + version = "1.0.7"
490 + source = "registry+https://github.com/rust-lang/crates.io-index"
491 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
492 +
493 + [[package]]
494 + name = "foldhash"
495 + version = "0.1.5"
496 + source = "registry+https://github.com/rust-lang/crates.io-index"
497 + checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
498 +
499 + [[package]]
500 + name = "foreign-types"
Lines truncated
A Cargo.toml +82
@@ -0,0 +1,82 @@
1 + [workspace]
2 + resolver = "2"
3 + members = [
4 + "crates/mt-core",
5 + "crates/mt-db",
6 + ]
7 + default-members = ["."]
8 +
9 + [workspace.package]
10 + version = "0.2.0"
11 + edition = "2024"
12 + license-file = "LICENSE"
13 +
14 + [workspace.dependencies]
15 + # Core
16 + tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros", "signal"] }
17 + thiserror = "2"
18 + serde = { version = "1", features = ["derive"] }
19 + serde_json = "1"
20 + tracing = "0.1"
21 + tracing-subscriber = { version = "0.3", features = ["env-filter"] }
22 +
23 + # Web
24 + axum = { version = "0.8", features = ["ws"] }
25 + tower = "0.5"
26 + tower-http = { version = "0.6", features = ["fs", "cors", "trace"] }
27 + tower-sessions = "0.14"
28 + tower-sessions-sqlx-store = { version = "0.15", features = ["postgres"] }
29 +
30 + # HTTP client / crypto
31 + reqwest = { version = "0.12", features = ["json"] }
32 + sha2 = "0.10"
33 + base64 = "0.22"
34 + rand = "0.8"
35 +
36 + # Database
37 + sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
38 +
39 + # Utilities
40 + chrono = { version = "0.4", features = ["serde"] }
41 + uuid = { version = "1", features = ["v4", "serde"] }
42 + pulldown-cmark = "0.12"
43 + askama = "0.13"
44 +
45 + # Internal crates
46 + mt-core = { path = "crates/mt-core" }
47 + mt-db = { path = "crates/mt-db" }
48 +
49 + [package]
50 + name = "multithreaded"
51 + version.workspace = true
52 + edition.workspace = true
53 +
54 + [dependencies]
55 + mt-core = { workspace = true }
56 + mt-db = { workspace = true }
57 + tokio = { workspace = true }
58 + axum = { workspace = true }
59 + tower = { workspace = true }
60 + tower-http = { workspace = true }
61 + tracing = { workspace = true }
62 + tracing-subscriber = { workspace = true }
63 + serde = { workspace = true }
64 + serde_json = { workspace = true }
65 + askama = { workspace = true }
66 + chrono = { workspace = true }
67 + uuid = { workspace = true }
68 + sqlx = { workspace = true }
69 + tower-sessions = { workspace = true }
70 + tower-sessions-sqlx-store = { workspace = true }
71 + reqwest = { workspace = true }
72 + sha2 = { workspace = true }
73 + base64 = { workspace = true }
74 + rand = { workspace = true }
75 + pulldown-cmark = { workspace = true }
76 + dotenvy = "0.15"
77 + hex = "0.4"
78 + urlencoding = "2"
79 + time = "0.3"
80 +
81 + [dev-dependencies]
82 + http-body-util = "0.1"
A LICENSE +118
@@ -0,0 +1,118 @@
1 + PolyForm Noncommercial License 1.0.0
2 +
3 + <https://polyformproject.org/licenses/noncommercial/1.0.0>
4 +
5 + Acceptance
6 +
7 + In order to get any license under these terms, you must agree to them as
8 + both strict obligations and conditions to all your licenses.
9 +
10 + Copyright License
11 +
12 + The licensor grants you a copyright license for the software to do
13 + everything you might do with the software that would otherwise infringe
14 + the licensor's copyright in it for any permitted purpose. However, you
15 + may only distribute the software according to Distribution License and
16 + make changes or new works based on the software according to Changes and
17 + New Works License.
18 +
19 + Distribution License
20 +
21 + The licensor grants you an additional copyright license to distribute
22 + copies of the software. Your license to distribute covers distributing
23 + the software with changes and new works permitted by Changes and New
24 + Works License.
25 +
26 + Notices
27 +
28 + You must ensure that anyone who gets a copy of any part of the software
29 + from you also gets a copy of these terms or the URL for them above, as
30 + well as copies of any plain-text lines beginning with "Required Notice:"
31 + that the licensor provided with the software. For example:
32 +
33 + Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
34 +
35 + Changes and New Works License
36 +
37 + The licensor grants you an additional copyright license to make changes
38 + and new works based on the software for any permitted purpose.
39 +
40 + Patent License
41 +
42 + The licensor grants you a patent license for the software that covers
43 + patent claims the licensor can license, or becomes able to license, that
44 + you would infringe by using the software.
45 +
46 + Noncommercial Purposes
47 +
48 + Any noncommercial purpose is a permitted purpose.
49 +
50 + Personal Uses
51 +
52 + Personal use for research, experiment, and testing for the benefit of
53 + public knowledge, personal study, private entertainment, hobby projects,
54 + amateur pursuits, or religious observance, without any anticipated
55 + commercial application, is use for a permitted purpose.
56 +
57 + Noncommercial Organizations
58 +
59 + Use by any charitable organization, educational institution, public
60 + research organization, public safety or health organization,
61 + environmental protection organization, or government institution is use
62 + for a permitted purpose regardless of the source of funding or
63 + obligations resulting from the funding.
64 +
65 + Fair Use
66 +
67 + You may have "fair use" rights for the software under the law. These
68 + terms do not limit them.
69 +
70 + No Other Rights
71 +
72 + These terms do not allow you to sublicense or transfer any of your
73 + licenses to anyone else, or prevent the licensor from granting licenses
74 + to anyone else. These terms do not imply any other licenses.
75 +
76 + Patent Defense
77 +
78 + If you make any written claim that the software infringes or contributes
79 + to infringement of any patent, your patent license for the software
80 + granted under these terms ends immediately. If your company makes such a
81 + claim, your patent license ends immediately for work on behalf of your
82 + company.
83 +
84 + Violations
85 +
86 + The first time you are notified in writing that you have violated any of
87 + these terms, or done anything with the software not covered by your
88 + licenses, your licenses can nonetheless continue if you come into full
89 + compliance with these terms, and take practical steps to correct past
90 + violations, within 32 days of receiving notice. Otherwise, all your
91 + licenses end immediately.
92 +
93 + No Liability
94 +
95 + As far as the law allows, the software comes as is, without any warranty
96 + or condition, and the licensor will not be liable to you for any damages
97 + arising out of these terms or the use or nature of the software, under
98 + any kind of legal claim.
99 +
100 + Definitions
101 +
102 + The licensor is the individual or entity offering these terms, and the
103 + software is the software the licensor makes available under these terms.
104 +
105 + You refers to the individual or entity agreeing to these terms.
106 +
107 + Your company is any legal entity, sole proprietorship, or other kind of
108 + organization that you work for, plus all organizations that have control
109 + over, are under the control of, or are under common control with that
110 + organization. Control means ownership of substantially all the assets of
111 + an entity, or the power to direct its management and policies by vote,
112 + contract, or otherwise. Control can be direct or indirect.
113 +
114 + Your licenses are all the licenses granted to you for the software under
115 + these terms.
116 +
117 + Use means anything you do with the software requiring one of your
118 + licenses.
A README.md +28
@@ -0,0 +1,28 @@
1 + # Multithreaded
2 +
3 + Forum-first community software with integrated E2E encrypted live chat.
4 +
5 + ## Prerequisites
6 +
7 + - Rust (stable)
8 + - PostgreSQL
9 + - Redis / Valkey
10 +
11 + ## Build & Run
12 +
13 + ```sh
14 + cargo build
15 + cargo run
16 + ```
17 +
18 + ## Workspace Architecture
19 +
20 + | Crate | Role |
21 + |-------|------|
22 + | `multithreaded` | Axum HTTP server, WebSocket gateway, binary entry point |
23 + | `mt-core` | Domain models, traits, business logic |
24 + | `mt-db` | PostgreSQL queries and migrations |
25 +
26 + ## License
27 +
28 + PolyForm Noncommercial 1.0.0
@@ -0,0 +1,7 @@
1 + [package]
2 + name = "mt-core"
3 + version.workspace = true
4 + edition.workspace = true
5 +
6 + [dependencies]
7 + chrono = { workspace = true }
@@ -0,0 +1,3 @@
1 + //! Core domain types and utilities for Multithreaded.
2 +
3 + pub mod time_format;
@@ -0,0 +1,140 @@
1 + //! Human-readable timestamp formatting for forum display.
2 +
3 + use chrono::{DateTime, Utc};
4 +
5 + /// Relative timestamp for thread listings: "just now", "3m ago", "2h ago", etc.
6 + /// Falls back to "Jan 15" format for dates older than 7 days.
7 + pub fn relative_timestamp(dt: DateTime<Utc>) -> String {
8 + let now = Utc::now();
9 + let delta = now.signed_duration_since(dt);
10 +
11 + if delta.num_seconds() < 60 {
12 + "just now".into()
13 + } else if delta.num_minutes() < 60 {
14 + format!("{}m ago", delta.num_minutes())
15 + } else if delta.num_hours() < 24 {
16 + format!("{}h ago", delta.num_hours())
17 + } else if delta.num_days() < 7 {
18 + format!("{}d ago", delta.num_days())
19 + } else {
20 + dt.format("%b %-d").to_string()
21 + }
22 + }
23 +
24 + /// Absolute timestamp for post display: "2026-03-13 10:30".
25 + pub fn post_timestamp(dt: DateTime<Utc>) -> String {
26 + dt.format("%Y-%m-%d %H:%M").to_string()
27 + }
28 +
29 + #[cfg(test)]
30 + mod tests {
31 + use super::*;
32 + use chrono::Duration;
33 +
34 + #[test]
35 + fn just_now() {
36 + let dt = Utc::now() - Duration::seconds(30);
37 + assert_eq!(relative_timestamp(dt), "just now");
38 + }
39 +
40 + #[test]
41 + fn minutes_ago() {
42 + let dt = Utc::now() - Duration::minutes(5);
43 + assert_eq!(relative_timestamp(dt), "5m ago");
44 + }
45 +
46 + #[test]
47 + fn hours_ago() {
48 + let dt = Utc::now() - Duration::hours(3);
49 + assert_eq!(relative_timestamp(dt), "3h ago");
50 + }
51 +
52 + #[test]
53 + fn days_ago() {
54 + let dt = Utc::now() - Duration::days(2);
55 + assert_eq!(relative_timestamp(dt), "2d ago");
56 + }
57 +
58 + #[test]
59 + fn older_than_week() {
60 + let dt = Utc::now() - Duration::days(10);
61 + let result = relative_timestamp(dt);
62 + // Should be in "Jan 15" format
63 + assert!(!result.contains("ago"), "expected date format, got: {result}");
64 + }
65 +
66 + #[test]
67 + fn post_timestamp_format() {
68 + let dt = chrono::NaiveDate::from_ymd_opt(2026, 3, 13)
69 + .unwrap()
70 + .and_hms_opt(10, 30, 0)
71 + .unwrap()
72 + .and_utc();
73 + assert_eq!(post_timestamp(dt), "2026-03-13 10:30");
74 + }
75 +
76 + #[test]
77 + fn boundary_59_seconds_is_just_now() {
78 + let dt = Utc::now() - Duration::seconds(59);
79 + assert_eq!(relative_timestamp(dt), "just now");
80 + }
81 +
82 + #[test]
83 + fn boundary_60_seconds_is_1m() {
84 + let dt = Utc::now() - Duration::seconds(60);
85 + assert_eq!(relative_timestamp(dt), "1m ago");
86 + }
87 +
88 + #[test]
89 + fn boundary_59_minutes_is_minutes() {
90 + let dt = Utc::now() - Duration::minutes(59);
91 + assert_eq!(relative_timestamp(dt), "59m ago");
92 + }
93 +
94 + #[test]
95 + fn boundary_60_minutes_is_1h() {
96 + let dt = Utc::now() - Duration::minutes(60);
97 + assert_eq!(relative_timestamp(dt), "1h ago");
98 + }
99 +
100 + #[test]
101 + fn boundary_23_hours_is_hours() {
102 + let dt = Utc::now() - Duration::hours(23);
103 + assert_eq!(relative_timestamp(dt), "23h ago");
104 + }
105 +
106 + #[test]
107 + fn boundary_24_hours_is_1d() {
108 + let dt = Utc::now() - Duration::hours(24);
109 + assert_eq!(relative_timestamp(dt), "1d ago");
110 + }
111 +
112 + #[test]
113 + fn boundary_6_days_is_days() {
114 + let dt = Utc::now() - Duration::days(6);
115 + assert_eq!(relative_timestamp(dt), "6d ago");
116 + }
117 +
118 + #[test]
119 + fn boundary_7_days_is_date() {
120 + let dt = Utc::now() - Duration::days(7);
121 + let result = relative_timestamp(dt);
122 + assert!(!result.contains("ago"), "expected date format, got: {result}");
123 + }
124 +
125 + #[test]
126 + fn post_timestamp_midnight() {
127 + let dt = chrono::NaiveDate::from_ymd_opt(2026, 1, 1)
128 + .unwrap()
129 + .and_hms_opt(0, 0, 0)
130 + .unwrap()
131 + .and_utc();
132 + assert_eq!(post_timestamp(dt), "2026-01-01 00:00");
133 + }
134 +
135 + #[test]
136 + fn zero_seconds_ago_is_just_now() {
137 + let dt = Utc::now();
138 + assert_eq!(relative_timestamp(dt), "just now");
139 + }
140 + }
@@ -0,0 +1,10 @@
1 + [package]
2 + name = "mt-db"
3 + version.workspace = true
4 + edition.workspace = true
5 +
6 + [dependencies]
7 + sqlx = { workspace = true }
8 + chrono = { workspace = true }
9 + uuid = { workspace = true }
10 + tracing = { workspace = true }
@@ -0,0 +1,4 @@
1 + //! Database access layer — queries and mutations for Multithreaded.
2 +
3 + pub mod mutations;
4 + pub mod queries;
@@ -0,0 +1,393 @@
1 + //! Database write mutations — inserts, updates, deletes.
2 +
3 + use chrono::{DateTime, Utc};
4 + use sqlx::PgPool;
5 + use uuid::Uuid;
6 +
7 + /// Insert a new thread and return its ID.
8 + #[tracing::instrument(skip_all)]
9 + pub async fn create_thread(
10 + pool: &PgPool,
11 + category_id: Uuid,
12 + author_id: Uuid,
13 + title: &str,
14 + ) -> Result<Uuid, sqlx::Error> {
15 + let row: (Uuid,) = sqlx::query_as(
16 + "INSERT INTO threads (category_id, author_id, title)
17 + VALUES ($1, $2, $3)
18 + RETURNING id",
19 + )
20 + .bind(category_id)
21 + .bind(author_id)
22 + .bind(title)
23 + .fetch_one(pool)
24 + .await?;
25 + Ok(row.0)
26 + }
27 +
28 + /// Insert a new post and bump the thread's last_activity_at.
29 + #[tracing::instrument(skip_all)]
30 + pub async fn create_post(
31 + pool: &PgPool,
32 + thread_id: Uuid,
33 + author_id: Uuid,
34 + body_markdown: &str,
35 + body_html: &str,
36 + ) -> Result<Uuid, sqlx::Error> {
37 + let row: (Uuid,) = sqlx::query_as(
38 + "INSERT INTO posts (thread_id, author_id, body_markdown, body_html)
39 + VALUES ($1, $2, $3, $4)
40 + RETURNING id",
41 + )
42 + .bind(thread_id)
43 + .bind(author_id)
44 + .bind(body_markdown)
45 + .bind(body_html)
46 + .fetch_one(pool)
47 + .await?;
48 +
49 + sqlx::query("UPDATE threads SET last_activity_at = now() WHERE id = $1")
50 + .bind(thread_id)
51 + .execute(pool)
52 + .await?;
53 +
54 + Ok(row.0)
55 + }
56 +
57 + /// Update a post's body (markdown + html) and set edited_at.
58 + #[tracing::instrument(skip_all)]
59 + pub async fn update_post_body(
60 + pool: &PgPool,
61 + post_id: Uuid,
62 + body_markdown: &str,
63 + 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",
68 + )
69 + .bind(post_id)
70 + .bind(body_markdown)
71 + .bind(body_html)
72 + .execute(pool)
73 + .await?;
74 + Ok(())
75 + }
76 +
77 + /// Soft-delete a post: destroy content, set deleted_at.
78 + #[tracing::instrument(skip_all)]
79 + pub async fn soft_delete_post(pool: &PgPool, post_id: Uuid) -> Result<(), sqlx::Error> {
80 + sqlx::query(
81 + "UPDATE posts SET body_markdown = '', body_html = '<p>[deleted]</p>', deleted_at = now()
82 + WHERE id = $1",
83 + )
84 + .bind(post_id)
85 + .execute(pool)
86 + .await?;
87 + Ok(())
88 + }
89 +
90 + /// Update a thread's title.
91 + #[tracing::instrument(skip_all)]
92 + pub async fn update_thread_title(
93 + pool: &PgPool,
94 + thread_id: Uuid,
95 + title: &str,
96 + ) -> Result<(), sqlx::Error> {
97 + sqlx::query("UPDATE threads SET title = $2 WHERE id = $1")
98 + .bind(thread_id)
99 + .bind(title)
100 + .execute(pool)
101 + .await?;
102 + Ok(())
103 + }
104 +
105 + /// Soft-delete a thread: set deleted_at (hides from listings).
106 + #[tracing::instrument(skip_all)]
107 + pub async fn soft_delete_thread(pool: &PgPool, thread_id: Uuid) -> Result<(), sqlx::Error> {
108 + sqlx::query("UPDATE threads SET deleted_at = now() WHERE id = $1")
109 + .bind(thread_id)
110 + .execute(pool)
111 + .await?;
112 + Ok(())
113 + }
114 +
115 + /// Set or unset the pinned flag on a thread.
116 + #[tracing::instrument(skip_all)]
117 + pub async fn set_thread_pinned(
118 + pool: &PgPool,
119 + thread_id: Uuid,
120 + pinned: bool,
121 + ) -> Result<(), sqlx::Error> {
122 + sqlx::query("UPDATE threads SET pinned = $2 WHERE id = $1")
123 + .bind(thread_id)
124 + .bind(pinned)
125 + .execute(pool)
126 + .await?;
127 + Ok(())
128 + }
129 +
130 + /// Set or unset the locked flag on a thread.
131 + #[tracing::instrument(skip_all)]
132 + pub async fn set_thread_locked(
133 + pool: &PgPool,
134 + thread_id: Uuid,
135 + locked: bool,
136 + ) -> Result<(), sqlx::Error> {
137 + sqlx::query("UPDATE threads SET locked = $2 WHERE id = $1")
138 + .bind(thread_id)
139 + .bind(locked)
140 + .execute(pool)
141 + .await?;
142 + Ok(())
143 + }
144 +
145 + /// Update a community's name and description.
146 + #[tracing::instrument(skip_all)]
147 + pub async fn update_community(
148 + pool: &PgPool,
149 + community_id: Uuid,
150 + name: &str,
151 + description: Option<&str>,
152 + ) -> 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?;
159 + Ok(())
160 + }
161 +
162 + /// Create a new category in a community.
163 + #[tracing::instrument(skip_all)]
164 + pub async fn create_category(
165 + pool: &PgPool,
166 + community_id: Uuid,
167 + name: &str,
168 + slug: &str,
169 + description: Option<&str>,
170 + sort_order: i32,
171 + ) -> Result<Uuid, sqlx::Error> {
172 + let row: (Uuid,) = sqlx::query_as(
173 + "INSERT INTO categories (community_id, name, slug, description, sort_order)
174 + VALUES ($1, $2, $3, $4, $5)
175 + RETURNING id",
176 + )
177 + .bind(community_id)
178 + .bind(name)
179 + .bind(slug)
180 + .bind(description)
181 + .bind(sort_order)
182 + .fetch_one(pool)
183 + .await?;
184 + Ok(row.0)
185 + }
186 +
187 + /// Update a category's name and description.
188 + #[tracing::instrument(skip_all)]
189 + pub async fn update_category(
190 + pool: &PgPool,
191 + category_id: Uuid,
192 + name: &str,
193 + description: Option<&str>,
194 + ) -> Result<(), sqlx::Error> {
195 + sqlx::query("UPDATE categories SET name = $2, description = $3 WHERE id = $1")
196 + .bind(category_id)
197 + .bind(name)
198 + .bind(description)
199 + .execute(pool)
200 + .await?;
201 + Ok(())
202 + }
203 +
204 + /// Swap the sort_order of two categories atomically.
205 + #[tracing::instrument(skip_all)]
206 + pub async fn swap_category_order(
207 + pool: &PgPool,
208 + id_a: Uuid,
209 + order_a: i32,
210 + id_b: Uuid,
211 + order_b: i32,
212 + ) -> Result<(), sqlx::Error> {
213 + let mut tx = pool.begin().await?;
214 + sqlx::query("UPDATE categories SET sort_order = $2 WHERE id = $1")
215 + .bind(id_a)
216 + .bind(order_b)
217 + .execute(&mut *tx)
218 + .await?;
219 + sqlx::query("UPDATE categories SET sort_order = $2 WHERE id = $1")
220 + .bind(id_b)
221 + .bind(order_a)
222 + .execute(&mut *tx)
223 + .await?;
224 + tx.commit().await?;
225 + Ok(())
226 + }
227 +
228 + /// Look up a category ID by community slug + category slug.
229 + #[tracing::instrument(skip_all)]
230 + pub async fn get_category_id_by_slugs(
231 + pool: &PgPool,
232 + community_slug: &str,
233 + category_slug: &str,
234 + ) -> Result<Option<Uuid>, sqlx::Error> {
235 + let row: Option<(Uuid,)> = sqlx::query_as(
236 + "SELECT c.id
237 + FROM categories c
238 + JOIN communities co ON co.id = c.community_id
239 + WHERE co.slug = $1 AND c.slug = $2",
240 + )
241 + .bind(community_slug)
242 + .bind(category_slug)
243 + .fetch_optional(pool)
244 + .await?;
245 + Ok(row.map(|r| r.0))
246 + }
247 +
248 + // ============================================================================
249 + // Ban / mute mutations
250 + // ============================================================================
251 +
252 + /// Create or update a ban/mute. Returns the ban ID.
253 + #[tracing::instrument(skip_all)]
254 + pub async fn create_community_ban(
255 + pool: &PgPool,
256 + community_id: Uuid,
257 + user_id: Uuid,
258 + banned_by: Uuid,
259 + ban_type: &str,
260 + reason: Option<&str>,
261 + expires_at: Option<DateTime<Utc>>,
262 + ) -> Result<Uuid, sqlx::Error> {
263 + let row: (Uuid,) = sqlx::query_as(
264 + "INSERT INTO community_bans (community_id, user_id, banned_by, ban_type, reason, expires_at)
265 + VALUES ($1, $2, $3, $4, $5, $6)
266 + ON CONFLICT (community_id, user_id, ban_type) DO UPDATE
267 + SET banned_by = $3, reason = $5, expires_at = $6, created_at = now()
268 + RETURNING id",
269 + )
270 + .bind(community_id)
271 + .bind(user_id)
272 + .bind(banned_by)
273 + .bind(ban_type)
274 + .bind(reason)
275 + .bind(expires_at)
276 + .fetch_one(pool)
277 + .await?;
278 + Ok(row.0)
279 + }
280 +
281 + /// Remove a ban or mute.
282 + #[tracing::instrument(skip_all)]
283 + pub async fn remove_community_ban(
284 + pool: &PgPool,
285 + community_id: Uuid,
286 + user_id: Uuid,
287 + ban_type: &str,
288 + ) -> Result<(), sqlx::Error> {
289 + sqlx::query(
290 + "DELETE FROM community_bans
291 + WHERE community_id = $1 AND user_id = $2 AND ban_type = $3",
292 + )
293 + .bind(community_id)
294 + .bind(user_id)
295 + .bind(ban_type)
296 + .execute(pool)
297 + .await?;
298 + Ok(())
299 + }
300 +
301 + /// Insert a mod log entry.
302 + #[tracing::instrument(skip_all)]
303 + pub async fn insert_mod_log(
304 + pool: &PgPool,
305 + community_id: Option<Uuid>,
306 + actor_id: Uuid,
307 + action: &str,
308 + target_user: Option<Uuid>,
309 + target_id: Option<Uuid>,
310 + reason: Option<&str>,
311 + ) -> Result<(), sqlx::Error> {
312 + sqlx::query(
313 + "INSERT INTO mod_log (community_id, actor_id, action, target_user, target_id, reason)
314 + VALUES ($1, $2, $3, $4, $5, $6)",
315 + )
316 + .bind(community_id)
317 + .bind(actor_id)
318 + .bind(action)
319 + .bind(target_user)
320 + .bind(target_id)
321 + .bind(reason)
322 + .execute(pool)
323 + .await?;
324 + Ok(())
325 + }
326 +
327 + // ============================================================================
328 + // Suspension mutations
329 + // ============================================================================
330 +
331 + /// Suspend a community.
332 + #[tracing::instrument(skip_all)]
333 + pub async fn suspend_community(
334 + pool: &PgPool,
335 + community_id: Uuid,
336 + reason: Option<&str>,
337 + ) -> Result<(), sqlx::Error> {
338 + sqlx::query(
339 + "UPDATE communities SET suspended_at = now(), suspension_reason = $2 WHERE id = $1",
340 + )
341 + .bind(community_id)
342 + .bind(reason)
343 + .execute(pool)
344 + .await?;
345 + Ok(())
346 + }
347 +
348 + /// Unsuspend a community.
349 + #[tracing::instrument(skip_all)]
350 + pub async fn unsuspend_community(
351 + pool: &PgPool,
352 + community_id: Uuid,
353 + ) -> Result<(), sqlx::Error> {
354 + sqlx::query(
355 + "UPDATE communities SET suspended_at = NULL, suspension_reason = NULL WHERE id = $1",
356 + )
357 + .bind(community_id)
358 + .execute(pool)
359 + .await?;
360 + Ok(())
361 + }
362 +
363 + /// Suspend a user.
364 + #[tracing::instrument(skip_all)]
365 + pub async fn suspend_user(
366 + pool: &PgPool,
367 + user_id: Uuid,
368 + reason: Option<&str>,
369 + ) -> Result<(), sqlx::Error> {
370 + sqlx::query(
371 + "UPDATE users SET suspended_at = now(), suspension_reason = $2 WHERE mnw_account_id = $1",
372 + )
373 + .bind(user_id)
374 + .bind(reason)
375 + .execute(pool)
376 + .await?;
377 + Ok(())
378 + }
379 +
380 + /// Unsuspend a user.
381 + #[tracing::instrument(skip_all)]
382 + pub async fn unsuspend_user(
383 + pool: &PgPool,
384 + user_id: Uuid,
385 + ) -> Result<(), sqlx::Error> {
386 + sqlx::query(
387 + "UPDATE users SET suspended_at = NULL, suspension_reason = NULL WHERE mnw_account_id = $1",
388 + )
389 + .bind(user_id)
390 + .execute(pool)
391 + .await?;
392 + Ok(())
393 + }
@@ -0,0 +1,658 @@
1 + //! Database read queries — projection structs and SQL.
2 +
3 + use chrono::{DateTime, Utc};
4 + use sqlx::PgPool;
5 + use uuid::Uuid;
6 +
7 + // ============================================================================
8 + // Projection structs — shaped for templates, not domain models
9 + // ============================================================================
10 +
11 + #[derive(sqlx::FromRow)]
12 + pub struct CommunityRow {
13 + pub id: Uuid,
14 + pub name: String,
15 + pub slug: String,
16 + pub description: Option<String>,
17 + pub suspended_at: Option<DateTime<Utc>>,
18 + }
19 +
20 + #[derive(sqlx::FromRow)]
21 + pub struct CategoryWithCount {
22 + pub name: String,
23 + pub slug: String,
24 + pub description: Option<String>,
25 + pub thread_count: i64,
26 + }
27 +
28 + #[derive(sqlx::FromRow)]
29 + pub struct CategoryRow {
30 + pub name: String,
31 + pub slug: String,
32 + }
33 +
34 + #[derive(sqlx::FromRow)]
35 + pub struct ThreadWithMeta {
36 + pub id: Uuid,
37 + pub title: String,
38 + pub author_name: String,
39 + pub author_username: String,
40 + pub reply_count: i64,
41 + pub last_activity_at: DateTime<Utc>,
42 + pub pinned: bool,
43 + pub locked: bool,
44 + }
45 +
46 + #[derive(sqlx::FromRow)]
47 + pub struct ThreadWithBreadcrumb {
48 + pub id: Uuid,
49 + pub title: String,
50 + pub locked: bool,
51 + pub pinned: bool,
52 + pub author_id: Uuid,
53 + pub community_id: Uuid,
54 + pub community_name: String,
55 + pub community_slug: String,
56 + pub category_name: String,
57 + pub category_slug: String,
58 + }
59 +
60 + #[derive(sqlx::FromRow)]
61 + pub struct PostWithAuthor {
62 + pub id: Uuid,
63 + pub author_id: Uuid,
64 + pub author_name: String,
65 + pub author_username: String,
66 + pub body_html: String,
67 + pub created_at: DateTime<Utc>,
68 + pub edited_at: Option<DateTime<Utc>>,
69 + pub deleted_at: Option<DateTime<Utc>>,
70 + }
71 +
72 + #[derive(sqlx::FromRow)]
73 + pub struct PostForEdit {
74 + pub id: Uuid,
75 + pub author_id: Uuid,
76 + pub body_markdown: String,
77 + pub created_at: DateTime<Utc>,
78 + pub deleted_at: Option<DateTime<Utc>>,
79 + pub thread_id: Uuid,
80 + pub thread_title: String,
81 + pub community_name: String,
82 + pub community_slug: String,
83 + pub community_id: Uuid,
84 + pub category_name: String,
85 + pub category_slug: String,
86 + }
87 +
88 + // ============================================================================
89 + // Queries
90 + // ============================================================================
91 +
92 + #[derive(sqlx::FromRow)]
93 + pub struct CommunityListRow {
94 + pub name: String,
95 + pub slug: String,
96 + pub description: Option<String>,
97 + pub category_count: i64,
98 + pub thread_count: i64,
99 + }
100 +
101 + /// List all non-suspended communities with category and thread counts.
102 + #[tracing::instrument(skip_all)]
103 + pub async fn list_communities(pool: &PgPool) -> Result<Vec<CommunityListRow>, sqlx::Error> {
104 + sqlx::query_as::<_, CommunityListRow>(
105 + "SELECT co.name, co.slug, co.description,
106 + COUNT(DISTINCT c.id) AS category_count,
107 + COUNT(DISTINCT t.id) AS thread_count
108 + FROM communities co
109 + LEFT JOIN categories c ON c.community_id = co.id
110 + LEFT JOIN threads t ON t.category_id = c.id
111 + WHERE co.suspended_at IS NULL
112 + GROUP BY co.id
113 + ORDER BY co.name",
114 + )
115 + .fetch_all(pool)
116 + .await
117 + }
118 +
119 + #[tracing::instrument(skip_all)]
120 + pub async fn get_community_by_slug(
121 + pool: &PgPool,
122 + slug: &str,
123 + ) -> Result<Option<CommunityRow>, sqlx::Error> {
124 + sqlx::query_as::<_, CommunityRow>(
125 + "SELECT id, name, slug, description, suspended_at FROM communities WHERE slug = $1",
126 + )
127 + .bind(slug)
128 + .fetch_optional(pool)
129 + .await
130 + }
131 +
132 + #[tracing::instrument(skip_all)]
133 + pub async fn list_categories_with_counts(
134 + pool: &PgPool,
135 + community_slug: &str,
136 + ) -> Result<Vec<CategoryWithCount>, sqlx::Error> {
137 + sqlx::query_as::<_, CategoryWithCount>(
138 + "SELECT c.name, c.slug, c.description,
139 + COUNT(t.id) AS thread_count
140 + FROM categories c
141 + JOIN communities co ON co.id = c.community_id
142 + LEFT JOIN threads t ON t.category_id = c.id AND t.deleted_at IS NULL
143 + WHERE co.slug = $1
144 + GROUP BY c.id, c.name, c.slug, c.description, c.sort_order
145 + ORDER BY c.sort_order",
146 + )
147 + .bind(community_slug)
148 + .fetch_all(pool)
149 + .await
150 + }
151 +
152 + #[tracing::instrument(skip_all)]
153 + pub async fn get_category_by_slugs(
154 + pool: &PgPool,
155 + community_slug: &str,
156 + category_slug: &str,
157 + ) -> Result<Option<CategoryRow>, sqlx::Error> {
158 + sqlx::query_as::<_, CategoryRow>(
159 + "SELECT c.name, c.slug
160 + FROM categories c
161 + JOIN communities co ON co.id = c.community_id
162 + WHERE co.slug = $1 AND c.slug = $2",
163 + )
164 + .bind(community_slug)
165 + .bind(category_slug)
166 + .fetch_optional(pool)
167 + .await
168 + }
169 +
170 + #[tracing::instrument(skip_all)]
171 + pub async fn list_threads_in_category_paginated(
172 + pool: &PgPool,
173 + community_slug: &str,
174 + category_slug: &str,
175 + limit: i64,
176 + offset: i64,
177 + ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> {
178 + sqlx::query_as::<_, ThreadWithMeta>(
179 + "SELECT t.id, t.title,
180 + COALESCE(u.display_name, u.username) AS author_name,
181 + u.username AS author_username,
182 + (COUNT(p.id) - 1) AS reply_count,
183 + t.last_activity_at,
184 + t.pinned, t.locked
185 + FROM threads t
186 + JOIN categories c ON c.id = t.category_id
187 + JOIN communities co ON co.id = c.community_id
188 + JOIN users u ON u.mnw_account_id = t.author_id
189 + LEFT JOIN posts p ON p.thread_id = t.id
190 + WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL
191 + GROUP BY t.id, t.title, u.display_name, u.username,
192 + t.last_activity_at, t.pinned, t.locked
193 + ORDER BY t.pinned DESC, t.last_activity_at DESC
194 + LIMIT $3 OFFSET $4",
195 + )
196 + .bind(community_slug)
197 + .bind(category_slug)
198 + .bind(limit)
199 + .bind(offset)
200 + .fetch_all(pool)
201 + .await
202 + }
203 +
204 + /// List threads with sorting. `sort` must be "replies" or "activity".
205 + /// `order` must be "asc" or "desc". Pinned threads always sort first.
206 + #[tracing::instrument(skip_all)]
207 + pub async fn list_threads_in_category_sorted(
208 + pool: &PgPool,
209 + community_slug: &str,
210 + category_slug: &str,
211 + sort: &str,
212 + order: &str,
213 + limit: i64,
214 + offset: i64,
215 + ) -> Result<Vec<ThreadWithMeta>, sqlx::Error> {
216 + let order_clause = match (sort, order) {
217 + ("replies", "asc") => "ORDER BY t.pinned DESC, reply_count ASC, t.last_activity_at DESC",
218 + ("replies", _) => "ORDER BY t.pinned DESC, reply_count DESC, t.last_activity_at DESC",
219 + (_, "asc") => "ORDER BY t.pinned DESC, t.last_activity_at ASC",
220 + _ => "ORDER BY t.pinned DESC, t.last_activity_at DESC",
221 + };
222 +
223 + let query = format!(
224 + "SELECT t.id, t.title,
225 + COALESCE(u.display_name, u.username) AS author_name,
226 + u.username AS author_username,
227 + (COUNT(p.id) - 1) AS reply_count,
228 + t.last_activity_at,
229 + t.pinned, t.locked
230 + FROM threads t
231 + JOIN categories c ON c.id = t.category_id
232 + JOIN communities co ON co.id = c.community_id
233 + JOIN users u ON u.mnw_account_id = t.author_id
234 + LEFT JOIN posts p ON p.thread_id = t.id
235 + WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL
236 + GROUP BY t.id, t.title, u.display_name, u.username,
237 + t.last_activity_at, t.pinned, t.locked
238 + {order_clause}
239 + LIMIT $3 OFFSET $4"
240 + );
241 +
242 + sqlx::query_as::<_, ThreadWithMeta>(&query)
243 + .bind(community_slug)
244 + .bind(category_slug)
245 + .bind(limit)
246 + .bind(offset)
247 + .fetch_all(pool)
248 + .await
249 + }
250 +
251 + #[tracing::instrument(skip_all)]
252 + pub async fn count_threads_in_category(
253 + pool: &PgPool,
254 + community_slug: &str,
255 + category_slug: &str,
256 + ) -> Result<i64, sqlx::Error> {
257 + sqlx::query_scalar(
258 + "SELECT COUNT(*)
259 + FROM threads t
260 + JOIN categories c ON c.id = t.category_id
261 + JOIN communities co ON co.id = c.community_id
262 + WHERE co.slug = $1 AND c.slug = $2 AND t.deleted_at IS NULL",
263 + )
264 + .bind(community_slug)
265 + .bind(category_slug)
266 + .fetch_one(pool)
267 + .await
268 + }
269 +
270 + #[tracing::instrument(skip_all)]
271 + pub async fn get_thread_with_breadcrumb(
272 + pool: &PgPool,
273 + thread_id: Uuid,
274 + ) -> Result<Option<ThreadWithBreadcrumb>, sqlx::Error> {
275 + sqlx::query_as::<_, ThreadWithBreadcrumb>(
276 + "SELECT t.id, t.title, t.locked, t.pinned, t.author_id,
277 + co.id AS community_id,
278 + co.name AS community_name, co.slug AS community_slug,
279 + c.name AS category_name, c.slug AS category_slug
280 + FROM threads t
281 + JOIN categories c ON c.id = t.category_id
282 + JOIN communities co ON co.id = c.community_id
283 + WHERE t.id = $1",
284 + )
285 + .bind(thread_id)
286 + .fetch_optional(pool)
287 + .await
288 + }
289 +
290 + #[tracing::instrument(skip_all)]
291 + pub async fn list_posts_in_thread(
292 + pool: &PgPool,
293 + thread_id: Uuid,
294 + ) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
295 + sqlx::query_as::<_, PostWithAuthor>(
296 + "SELECT p.id, p.author_id,
297 + COALESCE(u.display_name, u.username) AS author_name,
298 + u.username AS author_username,
299 + p.body_html, p.created_at, p.edited_at, p.deleted_at
300 + FROM posts p
301 + JOIN users u ON u.mnw_account_id = p.author_id
302 + WHERE p.thread_id = $1
303 + ORDER BY p.created_at",
304 + )
305 + .bind(thread_id)
306 + .fetch_all(pool)
307 + .await
308 + }
309 +
310 + #[tracing::instrument(skip_all)]
311 + pub async fn list_posts_in_thread_paginated(
312 + pool: &PgPool,
313 + thread_id: Uuid,
314 + limit: i64,
315 + offset: i64,
316 + ) -> Result<Vec<PostWithAuthor>, sqlx::Error> {
317 + sqlx::query_as::<_, PostWithAuthor>(
318 + "SELECT p.id, p.author_id,
319 + COALESCE(u.display_name, u.username) AS author_name,
320 + u.username AS author_username,
321 + p.body_html, p.created_at, p.edited_at, p.deleted_at
322 + FROM posts p
323 + JOIN users u ON u.mnw_account_id = p.author_id
324 + WHERE p.thread_id = $1
325 + ORDER BY p.created_at
326 + LIMIT $2 OFFSET $3",
327 + )
328 + .bind(thread_id)
329 + .bind(limit)
330 + .bind(offset)
331 + .fetch_all(pool)
332 + .await
333 + }
334 +
335 + #[tracing::instrument(skip_all)]
336 + pub async fn count_posts_in_thread(
337 + pool: &PgPool,
338 + thread_id: Uuid,
339 + ) -> Result<i64, sqlx::Error> {
340 + sqlx::query_scalar(
341 + "SELECT COUNT(*) FROM posts WHERE thread_id = $1",
342 + )
343 + .bind(thread_id)
344 + .fetch_one(pool)
345 + .await
346 + }
347 +
348 + #[tracing::instrument(skip_all)]
349 + pub async fn get_post_for_edit(
350 + pool: &PgPool,
351 + post_id: Uuid,
352 + ) -> Result<Option<PostForEdit>, sqlx::Error> {
353 + sqlx::query_as::<_, PostForEdit>(
354 + "SELECT p.id, p.author_id, p.body_markdown, p.created_at, p.deleted_at,
355 + p.thread_id, t.title AS thread_title,
356 + co.name AS community_name, co.slug AS community_slug,
357 + co.id AS community_id,
358 + c.name AS category_name, c.slug AS category_slug
359 + FROM posts p
360 + JOIN threads t ON t.id = p.thread_id
361 + JOIN categories c ON c.id = t.category_id
362 + JOIN communities co ON co.id = c.community_id
363 + WHERE p.id = $1",
364 + )
365 + .bind(post_id)
366 + .fetch_optional(pool)
367 + .await
368 + }
369 +
370 + #[tracing::instrument(skip_all)]
371 + pub async fn get_user_role(
372 + pool: &PgPool,
373 + user_id: Uuid,
374 + community_id: Uuid,
375 + ) -> Result<Option<String>, sqlx::Error> {
376 + let row: Option<(String,)> = sqlx::query_as(
377 + "SELECT role FROM memberships WHERE user_id = $1 AND community_id = $2",
378 + )
379 + .bind(user_id)
380 + .bind(community_id)
381 + .fetch_optional(pool)
382 + .await?;
383 + Ok(row.map(|r| r.0))
384 + }
385 +
386 + #[derive(sqlx::FromRow)]
387 + pub struct CategoryForSettings {
388 + pub id: Uuid,
389 + pub name: String,
390 + pub slug: String,
391 + pub description: Option<String>,
392 + pub sort_order: i32,
393 + }
394 +
395 + #[tracing::instrument(skip_all)]
396 + pub async fn list_categories_for_settings(
397 + pool: &PgPool,
398 + community_id: Uuid,
399 + ) -> Result<Vec<CategoryForSettings>, sqlx::Error> {
400 + sqlx::query_as::<_, CategoryForSettings>(
401 + "SELECT id, name, slug, description, sort_order
402 + FROM categories
403 + WHERE community_id = $1
404 + ORDER BY sort_order",
405 + )
406 + .bind(community_id)
407 + .fetch_all(pool)
408 + .await
409 + }
410 +
411 + #[derive(sqlx::FromRow)]
412 + pub struct MemberRow {
413 + pub username: String,
414 + pub display_name: Option<String>,
415 + pub role: String,
416 + pub joined_at: DateTime<Utc>,
417 + }
418 +
419 + #[tracing::instrument(skip_all)]
420 + pub async fn list_community_members(
421 + pool: &PgPool,
422 + community_id: Uuid,
423 + ) -> Result<Vec<MemberRow>, sqlx::Error> {
424 + sqlx::query_as::<_, MemberRow>(
425 + "SELECT u.username,
426 + u.display_name,
427 + m.role,
428 + m.joined_at
429 + FROM memberships m
430 + JOIN users u ON u.mnw_account_id = m.user_id
431 + WHERE m.community_id = $1
432 + ORDER BY
433 + CASE m.role
434 + WHEN 'owner' THEN 0
435 + WHEN 'moderator' THEN 1
436 + WHEN 'member' THEN 2
437 + ELSE 3
438 + END,
439 + m.joined_at",
440 + )
441 + .bind(community_id)
442 + .fetch_all(pool)
443 + .await
444 + }
445 +
446 + #[tracing::instrument(skip_all)]
447 + pub async fn get_category_by_id(
448 + pool: &PgPool,
449 + category_id: Uuid,
450 + ) -> Result<Option<CategoryForSettings>, sqlx::Error> {
451 + sqlx::query_as::<_, CategoryForSettings>(
452 + "SELECT id, name, slug, description, sort_order
453 + FROM categories
454 + WHERE id = $1",
455 + )
456 + .bind(category_id)
457 + .fetch_optional(pool)
458 + .await
459 + }
460 +
461 + // ============================================================================
462 + // Ban / mute queries
463 + // ============================================================================
464 +
465 + /// Check if user has an active ban in a community.
466 + #[tracing::instrument(skip_all)]
467 + pub async fn is_user_banned(
468 + pool: &PgPool,
469 + community_id: Uuid,
470 + user_id: Uuid,
471 + ) -> Result<bool, sqlx::Error> {
472 + let count: i64 = sqlx::query_scalar(
473 + "SELECT COUNT(*) FROM community_bans
474 + WHERE community_id = $1 AND user_id = $2 AND ban_type = 'ban'
475 + AND (expires_at IS NULL OR expires_at > now())",
476 + )
477 + .bind(community_id)
478 + .bind(user_id)
479 + .fetch_one(pool)
480 + .await?;
481 + Ok(count > 0)
482 + }
483 +
484 + /// Check if user has an active mute in a community.
485 + #[tracing::instrument(skip_all)]
486 + pub async fn is_user_muted(
487 + pool: &PgPool,
488 + community_id: Uuid,
489 + user_id: Uuid,
490 + ) -> Result<bool, sqlx::Error> {
491 + let count: i64 = sqlx::query_scalar(
492 + "SELECT COUNT(*) FROM community_bans
493 + WHERE community_id = $1 AND user_id = $2 AND ban_type = 'mute'
494 + AND (expires_at IS NULL OR expires_at > now())",
495 + )
496 + .bind(community_id)
497 + .bind(user_id)
498 + .fetch_one(pool)
499 + .await?;
500 + Ok(count > 0)
Lines truncated
@@ -0,0 +1,143 @@
1 + #!/bin/bash
2 + # Multithreaded Deployment Script
3 + # Builds natively on astra (aarch64), deploys to /opt/multithreaded.
4 + # Run from the multithreaded project root.
5 + #
6 + # Usage:
7 + # ./deploy/deploy.sh # Build + deploy + restart
8 + # ./deploy/deploy.sh --setup # First-time: create user, dirs, db, build, install, seed
9 +
10 + set -e
11 +
12 + # Configuration
13 + SERVER="max@100.106.221.39"
14 + REMOTE_DIR="/opt/multithreaded"
15 + SRC_DIR="src/multithreaded"
16 + BINARY_NAME="multithreaded"
17 + DEPLOY_DIR="deploy"
18 +
19 + # Check we're in the right directory
20 + if [ ! -f "Cargo.toml" ] || ! grep -q 'name = "multithreaded"' Cargo.toml; then
21 + echo "Error: Run this script from active/multithreaded/"
22 + exit 1
23 + fi
24 +
25 + rsync_source() {
26 + echo "[rsync] Uploading source to $SERVER:~/$SRC_DIR/..."
27 + ssh $SERVER "mkdir -p ~/$SRC_DIR"
28 + rsync -az --delete \
29 + --exclude target/ \
30 + --exclude .env \
31 + --exclude .git/ \
32 + ./ $SERVER:~/$SRC_DIR/
33 + echo "[rsync] Done"
34 + }
35 +
36 + build_remote() {
37 + echo "[build] Building release on astra..."
38 + ssh $SERVER "cd ~/$SRC_DIR && ~/.cargo/bin/cargo build --release 2>&1"
39 + echo "[build] Done"
40 + }
41 +
42 + deploy_files() {
43 + echo "[deploy] Stopping service + copying binary + assets to $REMOTE_DIR..."
44 + ssh $SERVER "
45 + sudo systemctl stop multithreaded || true
46 + sudo cp ~/$SRC_DIR/target/release/$BINARY_NAME $REMOTE_DIR/$BINARY_NAME
47 + sudo chmod +x $REMOTE_DIR/$BINARY_NAME
48 + sudo rsync -a --delete ~/$SRC_DIR/static/ $REMOTE_DIR/static/
49 + sudo rsync -a --delete ~/$SRC_DIR/migrations/ $REMOTE_DIR/migrations/
50 + sudo chown -R multithreaded:multithreaded $REMOTE_DIR
51 + "
52 + echo "[deploy] Done"
53 + }
54 +
55 + restart_app() {
56 + echo "[restart] Restarting multithreaded..."
57 + ssh $SERVER "sudo systemctl restart multithreaded"
58 + sleep 2
59 + echo ""
60 + ssh $SERVER "sudo systemctl status multithreaded --no-pager"
61 + echo ""
62 + echo "[health] Checking..."
63 + ssh $SERVER "curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:3400/"
64 + }
65 +
66 + first_time_setup() {
67 + echo "=== First-Time Setup ==="
68 +
69 + echo "[setup] Creating system user..."
70 + ssh $SERVER "
71 + sudo useradd --system --shell /usr/sbin/nologin --home-dir $REMOTE_DIR multithreaded 2>/dev/null || echo 'User already exists'
72 + "
73 +
74 + echo "[setup] Creating directories..."
75 + ssh $SERVER "
76 + sudo mkdir -p $REMOTE_DIR
77 + sudo chown multithreaded:multithreaded $REMOTE_DIR
78 + "
79 +
80 + echo "[setup] Creating database role + database..."
81 + ssh $SERVER "
82 + sudo -u postgres createuser multithreaded 2>/dev/null || echo 'Role already exists'
83 + createdb multithreaded 2>/dev/null || echo 'Database already exists'
84 + sudo -u postgres psql -c 'GRANT ALL PRIVILEGES ON DATABASE multithreaded TO multithreaded;' 2>/dev/null
85 + sudo -u postgres psql -d multithreaded -c 'GRANT ALL ON SCHEMA public TO multithreaded;' 2>/dev/null
86 + "
87 +
88 + rsync_source
89 + build_remote
90 +
91 + echo "[setup] Installing files..."
92 + deploy_files
93 +
94 + echo "[setup] Installing env file..."
95 + ssh $SERVER "
96 + sudo cp ~/$SRC_DIR/$DEPLOY_DIR/env.production $REMOTE_DIR/.env
97 + sudo chmod 600 $REMOTE_DIR/.env
98 + sudo chown multithreaded:multithreaded $REMOTE_DIR/.env
99 + "
100 +
101 + echo "[setup] Installing systemd service..."
102 + ssh $SERVER "
103 + sudo cp ~/$SRC_DIR/$DEPLOY_DIR/multithreaded.service /etc/systemd/system/multithreaded.service
104 + sudo systemctl daemon-reload
105 + sudo systemctl enable multithreaded
106 + "
107 +
108 + echo "[setup] Starting service..."
109 + ssh $SERVER "sudo systemctl start multithreaded"
110 + sleep 2
111 +
112 + echo "[setup] Running seed..."
113 + ssh $SERVER "
114 + cd $REMOTE_DIR
115 + sudo -u multithreaded $REMOTE_DIR/$BINARY_NAME --seed
116 + "
117 +
118 + echo ""
119 + ssh $SERVER "sudo systemctl status multithreaded --no-pager"
120 + echo ""
121 + echo "[health] Checking..."
122 + ssh $SERVER "curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:3400/"
123 + }
124 +
125 + case "${1:-deploy}" in
126 + --setup)
127 + first_time_setup
128 + ;;
129 + deploy|"")
130 + echo "=== Deploy ==="
131 + rsync_source
132 + build_remote
133 + deploy_files
134 + restart_app
135 + ;;
136 + *)
137 + echo "Usage: $0 [--setup]"
138 + exit 1
139 + ;;
140 + esac
141 +
142 + echo ""
143 + echo "=== Done ==="
@@ -0,0 +1,7 @@
1 + HOST=0.0.0.0
2 + PORT=3400
3 + DATABASE_URL=postgres:///multithreaded
4 + MNW_BASE_URL=http://127.0.0.1:3000
5 + OAUTH_CLIENT_ID=PLACEHOLDER_SET_AFTER_REGISTERING_APP
6 + OAUTH_REDIRECT_URI=http://100.106.221.39:3400/auth/callback
7 + PLATFORM_ADMIN_ID=
@@ -0,0 +1,55 @@
1 + # Multithreaded forum systemd service
2 + # Place in /etc/systemd/system/multithreaded.service
3 + #
4 + # Commands:
5 + # sudo systemctl daemon-reload
6 + # sudo systemctl enable multithreaded
7 + # sudo systemctl start multithreaded
8 + # sudo systemctl status multithreaded
9 + # journalctl -u multithreaded -f
10 +
11 + [Unit]
12 + Description=Multithreaded - Forum-first community software
13 + After=network.target postgresql.service
14 + Requires=postgresql.service
15 +
16 + [Service]
17 + Type=simple
18 + User=multithreaded
19 + Group=multithreaded
20 + WorkingDirectory=/opt/multithreaded
21 + ExecStart=/opt/multithreaded/multithreaded
22 + Restart=always
23 + RestartSec=5
24 +
25 + # Environment file with secrets
26 + EnvironmentFile=/opt/multithreaded/.env
27 + Environment=HOME=/opt/multithreaded
28 +
29 + # Security hardening
30 + NoNewPrivileges=true
31 + ProtectSystem=strict
32 + ProtectHome=true
33 + PrivateTmp=true
34 + ReadWritePaths=/opt/multithreaded
35 + RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
36 + RestrictNamespaces=true
37 + RestrictRealtime=true
38 + RestrictSUIDSGID=true
39 + LockPersonality=true
40 + ProtectKernelTunables=true
41 + ProtectKernelModules=true
42 + ProtectControlGroups=true
43 + SystemCallArchitectures=native
44 +
45 + # Resource limits
46 + LimitNOFILE=65535
47 + MemoryMax=512M
48 +
49 + # Logging (goes to journald)
50 + StandardOutput=journal
51 + StandardError=journal
52 + SyslogIdentifier=multithreaded
53 +
54 + [Install]
55 + WantedBy=multi-user.target
@@ -0,0 +1,10 @@
1 + CREATE TABLE users (
2 + mnw_account_id UUID PRIMARY KEY,
3 + username TEXT NOT NULL UNIQUE,
4 + display_name TEXT,
5 + avatar_url TEXT,
6 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
7 + updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
8 + );
9 +
10 + CREATE INDEX idx_users_username ON users (username);
@@ -0,0 +1,9 @@
1 + CREATE TABLE communities (
2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 + name TEXT NOT NULL,
4 + slug TEXT NOT NULL UNIQUE,
5 + description TEXT,
6 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
7 + );
8 +
9 + CREATE INDEX idx_communities_slug ON communities (slug);
@@ -0,0 +1,13 @@
1 + CREATE TABLE categories (
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 + description TEXT,
7 + sort_order INT NOT NULL DEFAULT 0,
8 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9 +
10 + UNIQUE (community_id, slug)
11 + );
12 +
13 + CREATE INDEX idx_categories_community ON categories (community_id, sort_order);
@@ -0,0 +1,16 @@
1 + CREATE TABLE threads (
2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 + category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
4 + author_id UUID NOT NULL REFERENCES users(mnw_account_id),
5 + title TEXT NOT NULL,
6 + pinned BOOLEAN NOT NULL DEFAULT false,
7 + locked BOOLEAN NOT NULL DEFAULT false,
8 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9 + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now()
10 + );
11 +
12 + -- Category thread listing: pinned first, then by last activity
13 + CREATE INDEX idx_threads_category_listing
14 + ON threads (category_id, pinned DESC, last_activity_at DESC);
15 +
16 + CREATE INDEX idx_threads_author ON threads (author_id);
A src/auth.rs +325
A src/csrf.rs +128
A src/lib.rs +20
A src/main.rs +101
A src/seed.rs +257
A todo.md +100
A todo_done.md +178