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