From 86c883fb919116b31f313f1c23e8ee2c49fafff8 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 12 Oct 2026 23:50:49 +0530 Subject: [PATCH] Per-user autosave rate limit (30/min, 429 past it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exam screen autosaves on every answer; a stuck client retrying in a tight loop could hammer SQLite's single writer. Ctx carries a small in-memory per-user windowed counter with opportunistic cleanup — no Redis needed. Verified live: 30x200 then 429s. --- src/auth.rs | 26 ++++++++++++++++++++++++++ src/main.rs | 7 +++++++ 2 files changed, 33 insertions(+) diff --git a/src/auth.rs b/src/auth.rs index b942f35..7a50101 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -20,9 +20,35 @@ pub struct Ctx { /// change on import, so there is no reason to run five aggregate /// subselects on every homepage hit. pub stats_cache: std::sync::Mutex<(std::time::Instant, (i64, i64, i64, i64, i64))>, + /// Autosave rate limiter: user id -> (window start, hits in window). + /// The exam screen autosaves on every answer, so a stuck client retrying + /// in a tight loop could otherwise hammer SQLite's single writer. + pub save_hits: std::sync::Mutex>, } impl Ctx { + /// True when this user may autosave now (30 saves per rolling minute). + pub fn save_allowed(&self, uid: &str) -> bool { + const LIMIT: u32 = 30; + const WINDOW: std::time::Duration = std::time::Duration::from_secs(60); + let mut guard = self.save_hits.lock().unwrap(); + // Opportunistic cleanup so the map cannot grow without bound. + if guard.len() > 4096 { + let now = std::time::Instant::now(); + guard.retain(|_, (t, _)| now.duration_since(*t) < WINDOW * 2); + } + let entry = guard.entry(uid.to_string()).or_insert((std::time::Instant::now(), 0)); + if entry.0.elapsed() >= WINDOW { + *entry = (std::time::Instant::now(), 1); + return true; + } + if entry.1 >= LIMIT { + return false; + } + entry.1 += 1; + true + } + pub async fn bank_stats(&self) -> (i64, i64, i64, i64, i64) { { let guard = self.stats_cache.lock().unwrap(); diff --git a/src/main.rs b/src/main.rs index 993b540..4c2ac3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,7 @@ async fn main() -> anyhow::Result<()> { std::time::Instant::now() - std::time::Duration::from_secs(61), (0, 0, 0, 0, 0), )), + save_hits: std::sync::Mutex::new(std::collections::HashMap::new()), }); let static_dir = format!("{}/static", std::env::current_dir()?.display()); @@ -383,6 +384,12 @@ async fn save_answers(State(ctx): State, h: HeaderMap, Path(attempt): Pa Some(u) => u, None => return hx("{\"error\":\"sign in first\"}".into(), StatusCode::UNAUTHORIZED), }; + if !ctx.save_allowed(&uid) { + return hx( + "{\"error\":\"saving too fast, retry shortly\"}".into(), + StatusCode::TOO_MANY_REQUESTS, + ); + } match exam::save_answers(&ctx.pool, &attempt, &uid, &body).await { Ok(_) => hx("{\"ok\":true}".into(), StatusCode::OK), Err(e) => {