max / multithreaded
82 files changed,
+10613 insertions,
-0 deletions
| @@ -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 |
| @@ -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/ |
| @@ -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
| @@ -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" |
| @@ -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. |
| @@ -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); |