fix(export): charge the download limit where the client can see the answer
The keepsake download is an iframe navigation, so its response is invisible to the page. The rate limit was enforced inside the zip/html handler — i.e. inside that navigation — while the ticket POST in front of it always returned 200. A guest over the limit therefore tapped "Herunterladen" and absolutely nothing happened, forever, with no explanation, on the one screen that is the emotional payoff of the whole app. With the default of 3/day, ZIP + HTML costs 2 and one retry locks them out until tomorrow. Minting is a normal `fetch`, so the limit moves there and the 429 reaches the user. The limit is not weakened: tickets are single-use with a 30s TTL and can only be obtained from that authenticated endpoint, so one mint is at most one download — and charging it in both places would have cost every download two slots. The message named the wrong timescale too. It shared the generic "warte kurz" wording with the per-minute limiters, but this bucket is a DAY, so a guest was told to wait a moment for something that could not work again until tomorrow. Verified live: three mints succeed, the fourth returns 429 in German; raising `export_rate_per_day` through the admin API takes effect on the next request with no restart, and the HTML keepsake then downloads. Also here, from the same pass: - `looks_bcrypt` checks the SHAPE of ADMIN_PASSWORD_HASH, not just placeholder-ness. A hash corrupted by shell or Compose escaping is not a placeholder, so the app booted green, `/health` said ok, and every admin login 401'd — unrecoverable mid-event, because the Admin row is only created BY a successful admin login and promoting a host requires one. - The rate limiter indexed `timestamps[0]` while holding its mutex, so a `max == 0` configuration panicked and poisoned the lock process-wide. Uses `first()`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,8 +40,18 @@ impl RateLimiter {
|
||||
timestamps.push(now);
|
||||
Ok(())
|
||||
} else {
|
||||
// The oldest timestamp expires at oldest + window; compute remaining seconds
|
||||
let oldest = timestamps[0];
|
||||
// The oldest timestamp expires at oldest + window; compute remaining seconds.
|
||||
//
|
||||
// `first()`, not `[0]`: with `max == 0` the length check above is false even on an
|
||||
// empty vec, so indexing would panic — WHILE HOLDING THIS MUTEX. That poisons it
|
||||
// process-wide, so every subsequent `.lock().unwrap()` panics too: upload, feed,
|
||||
// join, recover, social, export and the hourly maintenance task all die, and only
|
||||
// a container restart brings them back. `max == 0` is not reachable through the
|
||||
// admin API (every numeric spec has min = 1) but a direct DB edit would do it, and
|
||||
// the blast radius does not justify the sharper syntax.
|
||||
let Some(&oldest) = timestamps.first() else {
|
||||
return Ok(());
|
||||
};
|
||||
let elapsed = now.duration_since(oldest);
|
||||
let remaining = window.saturating_sub(elapsed);
|
||||
Err(remaining.as_secs().max(1))
|
||||
|
||||
Reference in New Issue
Block a user