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:
Fabian Hamm (Privat)
2026-08-03 18:35:39 +02:00
parent 496dba5a1f
commit 87d01a8a26
3 changed files with 102 additions and 10 deletions

View File

@@ -338,13 +338,25 @@ pub struct DownloadQuery {
pub async fn export_ticket(
State(state): State<AppState>,
auth: crate::auth::middleware::AuthUser,
) -> Json<serde_json::Value> {
) -> Result<Json<serde_json::Value>, AppError> {
// NOTE: intentionally NOT gated on `is_banned`. A banned user keeps *read* access
// by design (USER_JOURNEYS §10.3, FEATURES: "Can still download the export once
// released — Spec design choice"). The export is read-only, so it stays available
// to them, consistent with the read-only-ban model.
// The rate limit is enforced HERE rather than on the download itself, and that placement is
// the whole point: the download is an iframe navigation, so its response is invisible to the
// page. Limiting it there meant a guest over the limit tapped "Herunterladen", the ticket
// POST returned 200, the iframe silently received a 429, and absolutely nothing happened —
// forever, with no explanation, on the one screen that is the emotional payoff of the app.
// Minting is a normal `fetch`, so a 429 here reaches the user as a German message.
//
// Moving it does not weaken the limit: tickets are single-use with a 30s TTL and can only be
// obtained from this authenticated endpoint, so one mint is at most one download.
enforce_export_rate(&state, auth.user_id).await?;
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(serde_json::json!({ "ticket": ticket }))
Ok(Json(serde_json::json!({ "ticket": ticket })))
}
/// Validate a download ticket (single-use) and confirm its session still exists.
@@ -366,8 +378,9 @@ pub async fn download_zip(
State(state): State<AppState>,
Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, user_id).await?;
// Ticket validation only — the rate limit was charged at mint time, where a 429 is visible
// to the page. Charging it again here would cost every download two slots.
authenticate_download_ticket(&state, &q.ticket).await?;
let path =
resolve_export_file(&state, "zip", "Der ZIP-Export ist noch nicht verfügbar.").await?;
@@ -419,8 +432,8 @@ pub async fn download_html(
State(state): State<AppState>,
Query(q): Query<DownloadQuery>,
) -> Result<axum::response::Response, AppError> {
let user_id = authenticate_download_ticket(&state, &q.ticket).await?;
enforce_export_rate(&state, user_id).await?;
// See `download_zip`: the limit is charged at ticket mint, where the client can see it.
authenticate_download_ticket(&state, &q.ticket).await?;
let path =
resolve_export_file(&state, "html", "Der HTML-Export ist noch nicht verfügbar.").await?;
@@ -536,8 +549,13 @@ async fn enforce_export_rate(state: &AppState, user_id: Uuid) -> Result<(), AppE
limit,
Duration::from_secs(86400),
) {
// Names the real window. The generic "warte kurz" wording this used to share with the
// per-minute limiters is actively wrong here — the bucket is a DAY, so a guest told to
// wait a moment would keep tapping a button that cannot work again until tomorrow.
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
"Du hast das Tageslimit für Downloads erreicht. Versuch es später noch einmal — \
deine Galerie bleibt gespeichert."
.into(),
Some(retry_after_secs),
));
}