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