max / multithreaded
8 files changed,
+313 insertions,
-26 deletions
| @@ -350,6 +350,20 @@ dependencies = [ | |||
| 350 | 350 | ] | |
| 351 | 351 | ||
| 352 | 352 | [[package]] | |
| 353 | + | name = "dashmap" | |
| 354 | + | version = "6.1.0" | |
| 355 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 356 | + | checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" | |
| 357 | + | dependencies = [ | |
| 358 | + | "cfg-if", | |
| 359 | + | "crossbeam-utils", | |
| 360 | + | "hashbrown 0.14.5", | |
| 361 | + | "lock_api", | |
| 362 | + | "once_cell", | |
| 363 | + | "parking_lot_core", | |
| 364 | + | ] | |
| 365 | + | ||
| 366 | + | [[package]] | |
| 353 | 367 | name = "data-encoding" | |
| 354 | 368 | version = "2.10.0" | |
| 355 | 369 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -521,6 +535,16 @@ dependencies = [ | |||
| 521 | 535 | ] | |
| 522 | 536 | ||
| 523 | 537 | [[package]] | |
| 538 | + | name = "forwarded-header-value" | |
| 539 | + | version = "0.1.1" | |
| 540 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 541 | + | checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" | |
| 542 | + | dependencies = [ | |
| 543 | + | "nonempty", | |
| 544 | + | "thiserror 1.0.69", | |
| 545 | + | ] | |
| 546 | + | ||
| 547 | + | [[package]] | |
| 524 | 548 | name = "futures" | |
| 525 | 549 | version = "0.3.32" | |
| 526 | 550 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -602,6 +626,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 602 | 626 | checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" | |
| 603 | 627 | ||
| 604 | 628 | [[package]] | |
| 629 | + | name = "futures-timer" | |
| 630 | + | version = "3.0.3" | |
| 631 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 632 | + | checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" | |
| 633 | + | ||
| 634 | + | [[package]] | |
| 605 | 635 | name = "futures-util" | |
| 606 | 636 | version = "0.3.32" | |
| 607 | 637 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -654,9 +684,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 654 | 684 | checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" | |
| 655 | 685 | dependencies = [ | |
| 656 | 686 | "cfg-if", | |
| 687 | + | "js-sys", | |
| 657 | 688 | "libc", | |
| 658 | 689 | "r-efi 5.3.0", | |
| 659 | 690 | "wasip2", | |
| 691 | + | "wasm-bindgen", | |
| 660 | 692 | ] | |
| 661 | 693 | ||
| 662 | 694 | [[package]] | |
| @@ -673,6 +705,29 @@ dependencies = [ | |||
| 673 | 705 | ] | |
| 674 | 706 | ||
| 675 | 707 | [[package]] | |
| 708 | + | name = "governor" | |
| 709 | + | version = "0.8.1" | |
| 710 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 711 | + | checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" | |
| 712 | + | dependencies = [ | |
| 713 | + | "cfg-if", | |
| 714 | + | "dashmap", | |
| 715 | + | "futures-sink", | |
| 716 | + | "futures-timer", | |
| 717 | + | "futures-util", | |
| 718 | + | "getrandom 0.3.4", | |
| 719 | + | "no-std-compat", | |
| 720 | + | "nonzero_ext", | |
| 721 | + | "parking_lot", | |
| 722 | + | "portable-atomic", | |
| 723 | + | "quanta", | |
| 724 | + | "rand 0.9.2", | |
| 725 | + | "smallvec", | |
| 726 | + | "spinning_top", | |
| 727 | + | "web-time", | |
| 728 | + | ] | |
| 729 | + | ||
| 730 | + | [[package]] | |
| 676 | 731 | name = "h2" | |
| 677 | 732 | version = "0.4.13" | |
| 678 | 733 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -693,6 +748,12 @@ dependencies = [ | |||
| 693 | 748 | ||
| 694 | 749 | [[package]] | |
| 695 | 750 | name = "hashbrown" | |
| 751 | + | version = "0.14.5" | |
| 752 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 753 | + | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" | |
| 754 | + | ||
| 755 | + | [[package]] | |
| 756 | + | name = "hashbrown" | |
| 696 | 757 | version = "0.15.5" | |
| 697 | 758 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 698 | 759 | checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" | |
| @@ -1224,6 +1285,7 @@ dependencies = [ | |||
| 1224 | 1285 | "base64", | |
| 1225 | 1286 | "chrono", | |
| 1226 | 1287 | "dotenvy", | |
| 1288 | + | "governor", | |
| 1227 | 1289 | "hex", | |
| 1228 | 1290 | "http-body-util", | |
| 1229 | 1291 | "mt-core", | |
| @@ -1241,6 +1303,7 @@ dependencies = [ | |||
| 1241 | 1303 | "tower-http", | |
| 1242 | 1304 | "tower-sessions", | |
| 1243 | 1305 | "tower-sessions-sqlx-store", | |
| 1306 | + | "tower_governor", | |
| 1244 | 1307 | "tracing", | |
| 1245 | 1308 | "tracing-subscriber", | |
| 1246 | 1309 | "urlencoding", | |
| @@ -1265,6 +1328,24 @@ dependencies = [ | |||
| 1265 | 1328 | ] | |
| 1266 | 1329 | ||
| 1267 | 1330 | [[package]] | |
| 1331 | + | name = "no-std-compat" | |
| 1332 | + | version = "0.4.1" | |
| 1333 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1334 | + | checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" | |
| 1335 | + | ||
| 1336 | + | [[package]] | |
| 1337 | + | name = "nonempty" | |
| 1338 | + | version = "0.7.0" | |
| 1339 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1340 | + | checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" | |
| 1341 | + | ||
| 1342 | + | [[package]] | |
| 1343 | + | name = "nonzero_ext" | |
| 1344 | + | version = "0.3.0" | |
| 1345 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1346 | + | checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" | |
| 1347 | + | ||
| 1348 | + | [[package]] | |
| 1268 | 1349 | name = "nu-ansi-term" | |
| 1269 | 1350 | version = "0.50.3" | |
| 1270 | 1351 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1420,6 +1501,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1420 | 1501 | checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" | |
| 1421 | 1502 | ||
| 1422 | 1503 | [[package]] | |
| 1504 | + | name = "pin-project" | |
| 1505 | + | version = "1.1.11" | |
| 1506 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1507 | + | checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" | |
| 1508 | + | dependencies = [ | |
| 1509 | + | "pin-project-internal", | |
| 1510 | + | ] | |
| 1511 | + | ||
| 1512 | + | [[package]] | |
| 1513 | + | name = "pin-project-internal" | |
| 1514 | + | version = "1.1.11" | |
| 1515 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1516 | + | checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" | |
| 1517 | + | dependencies = [ | |
| 1518 | + | "proc-macro2", | |
| 1519 | + | "quote", | |
| 1520 | + | "syn", | |
| 1521 | + | ] | |
| 1522 | + | ||
| 1523 | + | [[package]] | |
| 1423 | 1524 | name = "pin-project-lite" | |
| 1424 | 1525 | version = "0.2.17" | |
| 1425 | 1526 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1465,6 +1566,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1465 | 1566 | checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" | |
| 1466 | 1567 | ||
| 1467 | 1568 | [[package]] | |
| 1569 | + | name = "portable-atomic" | |
| 1570 | + | version = "1.13.1" | |
| 1571 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1572 | + | checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" | |
| 1573 | + | ||
| 1574 | + | [[package]] | |
| 1468 | 1575 | name = "potential_utf" | |
| 1469 | 1576 | version = "0.1.4" | |
| 1470 | 1577 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1527,6 +1634,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1527 | 1634 | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" | |
| 1528 | 1635 | ||
| 1529 | 1636 | [[package]] | |
| 1637 | + | name = "quanta" | |
| 1638 | + | version = "0.12.6" | |
| 1639 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1640 | + | checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" | |
| 1641 | + | dependencies = [ | |
| 1642 | + | "crossbeam-utils", | |
| 1643 | + | "libc", | |
| 1644 | + | "once_cell", | |
| 1645 | + | "raw-cpuid", | |
| 1646 | + | "wasi", | |
| 1647 | + | "web-sys", | |
| 1648 | + | "winapi", | |
| 1649 | + | ] | |
| 1650 | + | ||
| 1651 | + | [[package]] | |
| 1530 | 1652 | name = "quote" | |
| 1531 | 1653 | version = "1.0.45" | |
| 1532 | 1654 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1607,6 +1729,15 @@ dependencies = [ | |||
| 1607 | 1729 | ] | |
| 1608 | 1730 | ||
| 1609 | 1731 | [[package]] | |
| 1732 | + | name = "raw-cpuid" | |
| 1733 | + | version = "11.6.0" | |
| 1734 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1735 | + | checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" | |
| 1736 | + | dependencies = [ | |
| 1737 | + | "bitflags", | |
| 1738 | + | ] | |
| 1739 | + | ||
| 1740 | + | [[package]] | |
| 1610 | 1741 | name = "redox_syscall" | |
| 1611 | 1742 | version = "0.5.18" | |
| 1612 | 1743 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2000,6 +2131,15 @@ dependencies = [ | |||
| 2000 | 2131 | ] | |
| 2001 | 2132 | ||
| 2002 | 2133 | [[package]] | |
| 2134 | + | name = "spinning_top" | |
| 2135 | + | version = "0.3.0" | |
| 2136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2137 | + | checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" | |
| 2138 | + | dependencies = [ | |
| 2139 | + | "lock_api", | |
| 2140 | + | ] | |
| 2141 | + | ||
| 2142 | + | [[package]] | |
| 2003 | 2143 | name = "spki" | |
| 2004 | 2144 | version = "0.7.3" | |
| 2005 | 2145 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2623,6 +2763,22 @@ dependencies = [ | |||
| 2623 | 2763 | ] | |
| 2624 | 2764 | ||
| 2625 | 2765 | [[package]] | |
| 2766 | + | name = "tower_governor" | |
| 2767 | + | version = "0.6.0" | |
| 2768 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2769 | + | checksum = "57a2ccff6830fa835371af7541e561a90e4c07b84f72991ebac4b3cb6790dc0d" | |
| 2770 | + | dependencies = [ | |
| 2771 | + | "axum", | |
| 2772 | + | "forwarded-header-value", | |
| 2773 | + | "governor", | |
| 2774 | + | "http", | |
| 2775 | + | "pin-project", | |
| 2776 | + | "thiserror 2.0.18", | |
| 2777 | + | "tower", | |
| 2778 | + | "tracing", | |
| 2779 | + | ] | |
| 2780 | + | ||
| 2781 | + | [[package]] | |
| 2626 | 2782 | name = "tracing" | |
| 2627 | 2783 | version = "0.1.44" | |
| 2628 | 2784 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2967,6 +3123,16 @@ dependencies = [ | |||
| 2967 | 3123 | ] | |
| 2968 | 3124 | ||
| 2969 | 3125 | [[package]] | |
| 3126 | + | name = "web-time" | |
| 3127 | + | version = "1.1.0" | |
| 3128 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3129 | + | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" | |
| 3130 | + | dependencies = [ | |
| 3131 | + | "js-sys", | |
| 3132 | + | "wasm-bindgen", | |
| 3133 | + | ] | |
| 3134 | + | ||
| 3135 | + | [[package]] | |
| 2970 | 3136 | name = "whoami" | |
| 2971 | 3137 | version = "1.6.1" | |
| 2972 | 3138 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2977,6 +3143,28 @@ dependencies = [ | |||
| 2977 | 3143 | ] | |
| 2978 | 3144 | ||
| 2979 | 3145 | [[package]] | |
| 3146 | + | name = "winapi" | |
| 3147 | + | version = "0.3.9" | |
| 3148 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3149 | + | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" | |
| 3150 | + | dependencies = [ | |
| 3151 | + | "winapi-i686-pc-windows-gnu", | |
| 3152 | + | "winapi-x86_64-pc-windows-gnu", | |
| 3153 | + | ] | |
| 3154 | + | ||
| 3155 | + | [[package]] | |
| 3156 | + | name = "winapi-i686-pc-windows-gnu" | |
| 3157 | + | version = "0.4.0" | |
| 3158 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3159 | + | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" | |
| 3160 | + | ||
| 3161 | + | [[package]] | |
| 3162 | + | name = "winapi-x86_64-pc-windows-gnu" | |
| 3163 | + | version = "0.4.0" | |
| 3164 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3165 | + | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" | |
| 3166 | + | ||
| 3167 | + | [[package]] | |
| 2980 | 3168 | name = "windows-core" | |
| 2981 | 3169 | version = "0.62.2" | |
| 2982 | 3170 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -36,6 +36,10 @@ rand = "0.8" | |||
| 36 | 36 | # Database | |
| 37 | 37 | sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono", "uuid"] } | |
| 38 | 38 | ||
| 39 | + | # Rate limiting | |
| 40 | + | tower_governor = "0.6" | |
| 41 | + | governor = "0.8" | |
| 42 | + | ||
| 39 | 43 | # Utilities | |
| 40 | 44 | chrono = { version = "0.4", features = ["serde"] } | |
| 41 | 45 | uuid = { version = "1", features = ["v4", "serde"] } | |
| @@ -73,6 +77,8 @@ sha2 = { workspace = true } | |||
| 73 | 77 | base64 = { workspace = true } | |
| 74 | 78 | rand = { workspace = true } | |
| 75 | 79 | pulldown-cmark = { workspace = true } | |
| 80 | + | tower_governor = { workspace = true } | |
| 81 | + | governor = { workspace = true } | |
| 76 | 82 | dotenvy = "0.15" | |
| 77 | 83 | hex = "0.4" | |
| 78 | 84 | urlencoding = "2" |
| @@ -75,10 +75,13 @@ async fn main() { | |||
| 75 | 75 | ||
| 76 | 76 | tracing::info!("listening on {}", listener.local_addr().unwrap()); | |
| 77 | 77 | ||
| 78 | - | axum::serve(listener, app) | |
| 79 | - | .with_graceful_shutdown(shutdown_signal()) | |
| 80 | - | .await | |
| 81 | - | .expect("server error"); | |
| 78 | + | axum::serve( | |
| 79 | + | listener, | |
| 80 | + | app.into_make_service_with_connect_info::<std::net::SocketAddr>(), | |
| 81 | + | ) | |
| 82 | + | .with_graceful_shutdown(shutdown_signal()) | |
| 83 | + | .await | |
| 84 | + | .expect("server error"); | |
| 82 | 85 | } | |
| 83 | 86 | ||
| 84 | 87 | async fn shutdown_signal() { |
| @@ -13,6 +13,7 @@ use axum::{ | |||
| 13 | 13 | }; | |
| 14 | 14 | use chrono::{DateTime, Duration, Utc}; | |
| 15 | 15 | use serde::Deserialize; | |
| 16 | + | use tower_governor::{GovernorLayer, governor::GovernorConfigBuilder, key_extractor::SmartIpKeyExtractor}; | |
| 16 | 17 | use tower_sessions::Session; | |
| 17 | 18 | use uuid::Uuid; | |
| 18 | 19 | ||
| @@ -21,41 +22,73 @@ use crate::csrf; | |||
| 21 | 22 | use crate::templates::*; | |
| 22 | 23 | use crate::AppState; | |
| 23 | 24 | ||
| 25 | + | // ============================================================================ | |
| 26 | + | // Rate limiting — per-IP on write endpoints | |
| 27 | + | // ============================================================================ | |
| 28 | + | ||
| 29 | + | /// Write endpoints: burst 10, then 2/sec (one token per 500ms). | |
| 30 | + | const WRITE_RATE_LIMIT_MS: u64 = 500; | |
| 31 | + | const WRITE_RATE_LIMIT_BURST: u32 = 10; | |
| 32 | + | ||
| 24 | 33 | /// Build the forum route tree. | |
| 25 | 34 | pub fn forum_routes(state: AppState) -> Router { | |
| 26 | - | Router::new() | |
| 27 | - | .route("/", get(forum::forum_directory)) | |
| 28 | - | .route("/p/{slug}", get(forum::project_forum)) | |
| 29 | - | .route("/p/{slug}/members", get(forum::community_members)) | |
| 30 | - | .route("/p/{slug}/settings", get(settings::community_settings).post(settings::update_community_handler)) | |
| 35 | + | let write_rate_limit = std::sync::Arc::new( | |
| 36 | + | GovernorConfigBuilder::default() | |
| 37 | + | .key_extractor(SmartIpKeyExtractor) | |
| 38 | + | .per_millisecond(WRITE_RATE_LIMIT_MS) | |
| 39 | + | .burst_size(WRITE_RATE_LIMIT_BURST) | |
| 40 | + | .finish() | |
| 41 | + | .expect("rate limiter config"), | |
| 42 | + | ); | |
| 43 | + | ||
| 44 | + | // POST-only routes — rate limited per IP | |
| 45 | + | let write_routes = Router::new() | |
| 46 | + | .route("/p/{slug}/settings", post(settings::update_community_handler)) | |
| 31 | 47 | .route("/p/{slug}/settings/categories/new", post(settings::create_category_handler)) | |
| 32 | - | .route("/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form).post(settings::edit_category_handler)) | |
| 48 | + | .route("/p/{slug}/settings/categories/{cat_id}/edit", post(settings::edit_category_handler)) | |
| 33 | 49 | .route("/p/{slug}/settings/categories/{cat_id}/move", post(settings::move_category_handler)) | |
| 34 | - | .route("/p/{slug}/moderation", get(moderation::moderation_page)) | |
| 35 | 50 | .route("/p/{slug}/moderation/ban", post(moderation::ban_user_handler)) | |
| 36 | 51 | .route("/p/{slug}/moderation/unban", post(moderation::unban_user_handler)) | |
| 37 | 52 | .route("/p/{slug}/moderation/mute", post(moderation::mute_user_handler)) | |
| 38 | 53 | .route("/p/{slug}/moderation/unmute", post(moderation::unmute_user_handler)) | |
| 39 | - | .route("/p/{slug}/moderation/log", get(moderation::mod_log_page)) | |
| 40 | - | .route("/p/{slug}/{category}", get(forum::category)) | |
| 41 | - | .route("/p/{slug}/{category}/new", get(forum::new_thread).post(forum::create_thread_handler)) | |
| 42 | - | .route("/p/{slug}/{category}/{thread_id}", get(forum::thread)) | |
| 54 | + | .route("/p/{slug}/{category}/new", post(forum::create_thread_handler)) | |
| 43 | 55 | .route("/p/{slug}/{category}/{thread_id}/reply", post(forum::create_reply_handler)) | |
| 44 | - | .route("/p/{slug}/{category}/{thread_id}/edit", get(forum::edit_thread_form).post(forum::edit_thread_handler)) | |
| 56 | + | .route("/p/{slug}/{category}/{thread_id}/edit", post(forum::edit_thread_handler)) | |
| 45 | 57 | .route("/p/{slug}/{category}/{thread_id}/delete", post(forum::delete_thread_handler)) | |
| 46 | 58 | .route("/p/{slug}/{category}/{thread_id}/pin", post(moderation::pin_thread_handler)) | |
| 47 | 59 | .route("/p/{slug}/{category}/{thread_id}/lock", post(moderation::lock_thread_handler)) | |
| 48 | - | .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/edit", get(forum::edit_post_form).post(forum::edit_post_handler)) | |
| 60 | + | .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/edit", post(forum::edit_post_handler)) | |
| 49 | 61 | .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/delete", post(forum::delete_post_handler)) | |
| 50 | - | .route("/_admin", get(admin::admin_dashboard)) | |
| 51 | 62 | .route("/_admin/communities/{id}/suspend", post(admin::suspend_community_handler)) | |
| 52 | 63 | .route("/_admin/communities/{id}/unsuspend", post(admin::unsuspend_community_handler)) | |
| 53 | 64 | .route("/_admin/users/{id}/suspend", post(admin::suspend_user_handler)) | |
| 54 | 65 | .route("/_admin/users/{id}/unsuspend", post(admin::unsuspend_user_handler)) | |
| 66 | + | .route_layer(GovernorLayer { | |
| 67 | + | config: write_rate_limit, | |
| 68 | + | }); | |
| 69 | + | ||
| 70 | + | // GET routes + auth + health — no rate limiting | |
| 71 | + | let read_routes = Router::new() | |
| 72 | + | .route("/", get(forum::forum_directory)) | |
| 73 | + | .route("/p/{slug}", get(forum::project_forum)) | |
| 74 | + | .route("/p/{slug}/members", get(forum::community_members)) | |
| 75 | + | .route("/p/{slug}/settings", get(settings::community_settings)) | |
| 76 | + | .route("/p/{slug}/settings/categories/{cat_id}/edit", get(settings::edit_category_form)) | |
| 77 | + | .route("/p/{slug}/moderation", get(moderation::moderation_page)) | |
| 78 | + | .route("/p/{slug}/moderation/log", get(moderation::mod_log_page)) | |
| 79 | + | .route("/p/{slug}/{category}", get(forum::category)) | |
| 80 | + | .route("/p/{slug}/{category}/new", get(forum::new_thread)) | |
| 81 | + | .route("/p/{slug}/{category}/{thread_id}", get(forum::thread)) | |
| 82 | + | .route("/p/{slug}/{category}/{thread_id}/edit", get(forum::edit_thread_form)) | |
| 83 | + | .route("/p/{slug}/{category}/{thread_id}/posts/{post_id}/edit", get(forum::edit_post_form)) | |
| 84 | + | .route("/_admin", get(admin::admin_dashboard)) | |
| 55 | 85 | .route("/auth/login", get(auth::login)) | |
| 56 | 86 | .route("/auth/callback", get(auth::callback)) | |
| 57 | 87 | .route("/auth/logout", get(auth::logout)) | |
| 58 | - | .route("/api/health", get(health)) | |
| 88 | + | .route("/api/health", get(health)); | |
| 89 | + | ||
| 90 | + | read_routes | |
| 91 | + | .merge(write_routes) | |
| 59 | 92 | .fallback(not_found_handler) | |
| 60 | 93 | .with_state(state) | |
| 61 | 94 | } |
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | //! Cookie-aware in-process HTTP client for integration tests. | |
| 2 | 2 | ||
| 3 | 3 | use axum::body::Body; | |
| 4 | + | use axum::extract::ConnectInfo; | |
| 4 | 5 | use axum::http::{header, Method, Request, StatusCode}; | |
| 5 | 6 | use axum::Router; | |
| 6 | 7 | use http_body_util::BodyExt; | |
| 7 | 8 | use std::collections::HashMap; | |
| 9 | + | use std::net::SocketAddr; | |
| 8 | 10 | use tower::ServiceExt; | |
| 9 | 11 | ||
| 10 | 12 | pub struct TestClient { | |
| @@ -121,7 +123,9 @@ impl TestClient { | |||
| 121 | 123 | builder = builder.header(header::COOKIE, cookie_header); | |
| 122 | 124 | } | |
| 123 | 125 | ||
| 124 | - | let request = builder.body(Body::from(body_data)).expect("Failed to build request"); | |
| 126 | + | let mut request = builder.body(Body::from(body_data)).expect("Failed to build request"); | |
| 127 | + | // Provide ConnectInfo so SmartIpKeyExtractor works in tests | |
| 128 | + | request.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); | |
| 125 | 129 | ||
| 126 | 130 | let response = self | |
| 127 | 131 | .app | |
| @@ -189,7 +193,8 @@ impl TestClient { | |||
| 189 | 193 | builder = builder.header(header::COOKIE, cookie_header); | |
| 190 | 194 | } | |
| 191 | 195 | ||
| 192 | - | let request = builder.body(Body::from(body_data)).expect("Failed to build request"); | |
| 196 | + | let mut request = builder.body(Body::from(body_data)).expect("Failed to build request"); | |
| 197 | + | request.extensions_mut().insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 0)))); | |
| 193 | 198 | ||
| 194 | 199 | let response = self | |
| 195 | 200 | .app |
| @@ -6,3 +6,4 @@ mod csrf; | |||
| 6 | 6 | mod moderation; | |
| 7 | 7 | mod pagination; | |
| 8 | 8 | mod permissions; | |
| 9 | + | mod rate_limit; |
| @@ -0,0 +1,53 @@ | |||
| 1 | + | use axum::http::StatusCode; | |
| 2 | + | ||
| 3 | + | use crate::harness::TestHarness; | |
| 4 | + | ||
| 5 | + | #[tokio::test] | |
| 6 | + | async fn write_endpoints_rate_limited() { | |
| 7 | + | let mut h = TestHarness::new().await; | |
| 8 | + | let user_id = h.login_as("ratelimituser").await; | |
| 9 | + | let comm_id = h.create_community("RL Test", "rl-test").await; | |
| 10 | + | let _cat_id = h.create_category(comm_id, "General", "general").await; | |
| 11 | + | h.add_membership(user_id, comm_id, "member").await; | |
| 12 | + | ||
| 13 | + | // GET new thread form to establish CSRF token | |
| 14 | + | h.client.get("/p/rl-test/general/new").await; | |
| 15 | + | ||
| 16 | + | // Send burst_size (10) requests — all should succeed | |
| 17 | + | for i in 0..10 { | |
| 18 | + | let body = format!("title=Thread+{i}&body=Body+{i}"); | |
| 19 | + | let resp = h.client.post_form("/p/rl-test/general/new", &body).await; | |
| 20 | + | assert_ne!( | |
| 21 | + | resp.status, | |
| 22 | + | StatusCode::TOO_MANY_REQUESTS, | |
| 23 | + | "Request {i} should not be rate limited" | |
| 24 | + | ); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // 11th request should be rate limited | |
| 28 | + | let resp = h | |
| 29 | + | .client | |
| 30 | + | .post_form("/p/rl-test/general/new", "title=Overflow&body=Nope") | |
| 31 | + | .await; | |
| 32 | + | assert_eq!( | |
| 33 | + | resp.status, | |
| 34 | + | StatusCode::TOO_MANY_REQUESTS, | |
| 35 | + | "Request 11 should be rate limited (429)" | |
| 36 | + | ); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | #[tokio::test] | |
| 40 | + | async fn get_endpoints_not_rate_limited() { | |
| 41 | + | let mut h = TestHarness::new().await; | |
| 42 | + | let _comm_id = h.create_community("ReadTest", "read-test").await; | |
| 43 | + | ||
| 44 | + | // Send 15 GET requests — none should be rate limited | |
| 45 | + | for i in 0..15 { | |
| 46 | + | let resp = h.client.get("/p/read-test").await; | |
| 47 | + | assert_ne!( | |
| 48 | + | resp.status, | |
| 49 | + | StatusCode::TOO_MANY_REQUESTS, | |
| 50 | + | "GET request {i} should never be rate limited" | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | } |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Multithreaded — Todo | |
| 2 | 2 | ||
| 3 | - | Done: Phases 0-11. 97 tests (56 integration + 25 unit lib + 16 unit mt-core). v0.2.0 deployed to astra (PLATFORM_ADMIN_ID set). 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). | |
| 3 | + | Done: Phases 0-11. 99 tests (58 integration + 25 unit lib + 16 unit mt-core). v0.2.0 deployed to astra (PLATFORM_ADMIN_ID set). 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 added (tower-governor, per-IP on write endpoints). Initial git commit done. | |
| 4 | 4 | ||
| 5 | 5 | Completed work archived in [todo_done.md](todo_done.md). | |
| 6 | 6 | ||
| @@ -40,9 +40,7 @@ Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse proj | |||
| 40 | 40 | - [x] **[SMALL]** Log mod log insert failures with `tracing::error!` instead of `let _ =` (15 locations across 4 files) | |
| 41 | 41 | - [x] **[SMALL]** Expand `.env.example` with all required environment variables (DATABASE_URL, OAUTH_CLIENT_ID, MNW_BASE_URL, OAUTH_REDIRECT_URI, HOST, PORT, COOKIE_SECURE, RUST_LOG, PLATFORM_ADMIN_ID) | |
| 42 | 42 | ||
| 43 | - | ### Remaining | |
| 44 | - | ||
| 45 | - | - [ ] **[SMALL]** Initial git commit + configure remotes (git.makenot.work, sr.ht) | |
| 43 | + | - [x] **[SMALL]** Initial git commit + configure remotes (git.makenot.work, sr.ht) | |
| 46 | 44 | ||
| 47 | 45 | --- | |
| 48 | 46 | ||
| @@ -54,7 +52,7 @@ Alpha = forum is usable end-to-end: users can sign in via MNW OAuth, browse proj | |||
| 54 | 52 | - [x] Set PLATFORM_ADMIN_ID in production .env | |
| 55 | 53 | - [ ] Manual moderation testing (ban a user, verify 403; mute, verify read-only; unban/unmute; mod log entries; admin dashboard) | |
| 56 | 54 | - [ ] Caddy config (reverse proxy, public domain when ready) | |
| 57 | - | - [ ] Rate limiting (per-IP on write endpoints) | |
| 55 | + | - [x] Rate limiting (per-IP on write endpoints, tower-governor, burst 10 / 2/sec) | |
| 58 | 56 | - [ ] Expired ban cleanup (scheduled task or on-read expiry check) | |
| 59 | 57 | ||
| 60 | 58 | --- |