Skip to main content

max / multithreaded

Add per-IP rate limiting on write endpoints tower-governor with SmartIpKeyExtractor: burst 10, 2/sec on all POST routes. GET routes remain unrestricted. Includes 2 integration tests verifying 429 on burst overflow and no rate limiting on reads. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 18:32 UTC
Commit: 65ddffc38fcd3eb289368b5b480e483597dff70b
Parent: c5d417e
8 files changed, +313 insertions, -26 deletions
M Cargo.lock +188
@@ -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"
M Cargo.toml +6
@@ -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"
M src/main.rs +7 -4
@@ -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() {
M src/routes/mod.rs +48 -15
@@ -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 + }
M todo.md +3 -5
@@ -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 ---