fix: close four confirmed defects an adversarial review found

Findings from a multi-angle review, most of them in code I wrote in the last
few commits. Each was verified against the code before being acted on.

## Backend

**The export daily limit was bypassable ~60x/minute.** `SseTicketStore` is
untyped, and the export download quietly started reusing it. `POST
/stream/ticket` is free and rate-limited at 60/min per user; `POST
/export/ticket` charges one of three PER-DAY downloads. So a guest could mint
at the cheap endpoint and redeem at the expensive one, each redemption
streaming the whole multi-GB keepsake, `no-store`, off the same filesystem
Postgres writes WAL to. Tickets now carry a `TicketKind` and `consume` requires
it to match, asserted in both directions. The comment claiming "one mint is at
most one download" was simply false.

**`export_ticket` answered 200 `{"ticket": null}` when the store was full** —
after charging a daily slot. `issue` returns `Option`; `sse.rs` handles the
None with a 503 and this call site unwrapped it into the JSON body. The page
toasted success, the iframe navigated to `?ticket=null`, and one of three
downloads was gone. That is the phantom-success failure the pre-validation in
5b70531 exists to prevent, arriving through the other door.

**`finalize_job` collapsed a DB error into "we lost the epoch race."** At that
point the archive is built, fsynced and renamed, so the caller deleted the
finished multi-GB file and returned the Superseded sentinel — which
`abandon_if_superseded` swallows into Ok, so `mark_failed` never ran either.
The row stayed `running` at 99% at the LIVE epoch: "Wird erstellt (99 %)",
download disabled, forever. No sweep re-examines `running` rows and
`recover_exports` runs only at boot. `claim_job`'s own doc comment says errors
are distinguished there precisely because of this failure shape. A pool timeout
is not exotic: max_connections 10, acquire_timeout 5s, firing at the end of a
full-gallery export while 100 guests upload.

**`PATCH {"hashtags": []}` was a free keepsake-retire loop.** The no-op guard
only compared captions, and my comment defended the gap by claiming an
identical hashtag list "is not a free loop". It is exactly one. Each request
bumped the epoch, retiring the HTML keepsake; REGEN_DEBOUNCE throttles when a
rebuild may start, not the bump, so at 30/min no rebuild ever gets a quiet
window and /export/html 404s all event. Now compares against the stored tags.

Also: four config keys migration 025 inserts (and the handlers read) were
missing from `patch_config`'s allowlist, so `GET /admin/config` listed them
while `PATCH` answered "Unbekannter Konfigurationsschlüssel" — the rate limits
an operator reaches for while abuse is happening.

## Client upload queue

**The ✕ was cosmetic.** A cancel deliberately charges no attempt and sets no
backoff — so `requeueRetriable` matched it on both counts and restarted the
upload from byte zero within ~120s (an `online` event, or the SSE backstop's
`feed-delta` poll). It then restarted forever, because a path that never
charges an attempt can never exhaust the budget that would stop it. The row
read "Abgebrochen. Tippe auf „Erneut“." throughout. Cancels are now explicitly
terminal until the guest taps Erneut.

**The retry budget was a lifetime quota, not a rate.** Five attempts on a
5/10/20/40s ladder is ~75 seconds, so any outage longer than that — a venue AP
brownout, a captive portal re-arming, an `app` restart, all with
`navigator.onLine` still true — permanently parked every in-flight photo
behind a per-row button three taps deep. It now refills after 10 quiet minutes,
which still forbids a hot loop re-sending a 200 MB video over a shared uplink.

**A test asserted a property the code does not have.** The suspension test
omitted the MAX_SUSPEND_CREDIT_MS clamp the production tick applies, so it
could not fail. Replaced with a helper that replays the real tick loop, and the
true bound is now asserted: a 60s lock survives, a 3-minute lock aborts.

152/152 backend, 59/59 vitest, clippy clean, svelte-check 0 errors, eslint
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-09 14:39:27 +02:00
parent 253878e027
commit 214f9e3062
8 changed files with 289 additions and 34 deletions

View File

@@ -10,6 +10,7 @@ use uuid::Uuid;
use crate::auth::middleware::RequireAdmin;
use crate::error::AppError;
use crate::services::config;
use crate::services::sse_tickets::TicketKind;
use crate::state::AppState;
// ── DTOs ─────────────────────────────────────────────────────────────────────
@@ -132,6 +133,16 @@ pub async fn patch_config(
("social_rate_per_min", true, 1.0, 100_000.0),
("quota_tolerance", false, 0.0, 1.0),
("estimated_guest_count", true, 1.0, 1_000_000.0),
// The three limiters migration 025 introduced. All are READ at runtime
// (`upload.rs` for the edit limiter, `auth/handlers.rs` for the other two) and 025
// INSERTs all of them into `config`, so `GET /admin/config` listed them while
// `PATCH /admin/config` answered "Unbekannter Konfigurationsschlüssel" — the same
// dead-key defect the comment under BOOL_KEYS says was fixed for the two login
// toggles. These are precisely the knobs an operator reaches for while abuse is
// happening, which is the one moment a restart to change them is unaffordable.
("upload_edit_rate_per_min", true, 1.0, 100_000.0),
("recover_name_rate_per_15min", true, 1.0, 100_000.0),
("pin_reset_ip_rate_per_min", true, 1.0, 100_000.0),
];
const BOOL_KEYS: &[&str] = &[
"rate_limits_enabled",
@@ -145,6 +156,9 @@ pub async fn patch_config(
"admin_login_rate_enabled",
"recover_rate_enabled",
"social_rate_enabled",
// Read by `upload::edit_upload`, inserted by migration 025, and until now unreachable
// from this endpoint — see the note in NUMERIC_SPECS.
"upload_edit_rate_enabled",
"quota_enabled",
"storage_quota_enabled",
"upload_count_quota_enabled",
@@ -397,7 +411,21 @@ pub async fn export_ticket(
enforce_export_rate(&state, auth.user_id).await?;
let ticket = state.sse_tickets.issue(auth.token_hash);
// `issue` returns None when the ticket store is at capacity. Unwrapping it into the JSON body
// serialized `{"ticket": null}` with a 200 — so `api.post` resolved happily, the page toasted
// success, the iframe navigated to `?ticket=null`, and one of the guest's three DAILY
// downloads had already been charged above. That is precisely the phantom-success failure
// this endpoint's pre-validation was added to eliminate, arriving through the other door.
// 503 + Retry-After, matching how `sse::issue_ticket` answers the identical condition.
let ticket = state
.sse_tickets
.issue(auth.token_hash, TicketKind::Download)
.ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Bitte versuch es in einem Moment erneut.".into(),
Some(30),
)
})?;
Ok(Json(serde_json::json!({ "ticket": ticket })))
}
@@ -407,7 +435,7 @@ pub async fn export_ticket(
async fn authenticate_download_ticket(state: &AppState, ticket: &str) -> Result<Uuid, AppError> {
let token_hash = state
.sse_tickets
.consume(ticket)
.consume(ticket, TicketKind::Download)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
let session = crate::models::session::Session::find_by_token_hash(&state.pool, &token_hash)
.await

View File

@@ -13,6 +13,7 @@ use tokio_stream::wrappers::errors::BroadcastStreamRecvError;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::models::session::Session;
use crate::services::sse_tickets::TicketKind;
use crate::state::AppState;
#[derive(Deserialize)]
@@ -52,7 +53,7 @@ pub async fn issue_ticket(
));
}
let ticket = state.sse_tickets.issue(auth.token_hash).ok_or_else(|| {
let ticket = state.sse_tickets.issue(auth.token_hash, TicketKind::Sse).ok_or_else(|| {
AppError::ServiceUnavailable(
"Server ist gerade ausgelastet. Live-Updates folgen in Kürze.".into(),
Some(30),
@@ -75,7 +76,7 @@ pub async fn stream(
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, AppError> {
let token_hash = state
.sse_tickets
.consume(&q.ticket)
.consume(&q.ticket, TicketKind::Sse)
.ok_or_else(|| AppError::Unauthorized("Ticket ungültig oder abgelaufen.".into()))?;
// NOTE: this authenticates via ticket→session, not the `AuthUser` extractor. The

View File

@@ -774,13 +774,37 @@ pub async fn edit_upload(
// REGEN_DEBOUNCE bounds the rate of that, not the total work, so the keepsake could be kept
// permanently un-downloadable.
//
// Residual, deliberately not fixed: re-sending an IDENTICAL hashtag list still counts as a
// change. Comparing would need another query, and unlike `PATCH {}` it is not a free loop.
// The hashtag half of that guard was wrong, and the comment here used to defend it: it said
// re-sending an identical list "is not a free loop". It is exactly a free loop. `PATCH
// {"hashtags": []}` carries no photo, no bytes and no client-side cost, yet it made
// `normalized_tags` `Some`, sailed past the caption-only check, and bumped `export_epoch` on
// every request — retiring the HTML keepsake instantly. REGEN_DEBOUNCE (20s) throttles when a
// rebuild may START, not the epoch bump, so at the 30/min this endpoint allows no rebuild ever
// gets a quiet window to finish in and `GET /export/html` 404s for the whole event. The ZIP is
// carried forward, so this denied exactly half the product.
//
// So compare properly. One indexed lookup against `upload_hashtag` is cheap next to the
// full-gallery rebuild a false positive arms.
let caption_changed = match (&body.caption, &upload.caption) {
(Some(new), existing) => Some(new.as_str()) != existing.as_deref(),
(None, _) => false,
};
if !caption_changed && normalized_tags.is_none() {
let tags_changed = match &normalized_tags {
None => false,
Some(incoming) => {
// Compare on the same normalised form `upsert` keys on, so "#Party", "party" and
// " #PARTY " are all the same tag and none of them counts as an edit.
let mut want: Vec<String> = incoming
.iter()
.map(|t| t.trim().trim_start_matches('#').to_lowercase())
.collect();
want.sort();
want.dedup();
let have = Hashtag::normalized_for_upload(&state.pool, upload_id).await?;
want != have
}
};
if !caption_changed && !tags_changed {
return Ok(StatusCode::OK);
}