fix(upload): remove the /original rate limit that would have broken the feed

The limiter added here was justified as bounding "100 guests occasionally tapping Original
anzeigen". That is not what this route is. `pickMediaUrl` resolves to
`preview_url ?? thumbnail_url ?? /original`, and a freshly committed upload has BOTH
derivatives null until the compression worker reaches it — at COMPRESSION_WORKER_CONCURRENCY=2
that is minutes during a post-ceremony burst. So /original is the feed's hot path for exactly
the newest photos, in a newest-first grid, at the busiest moment.

With every guest behind one NAT the 600/min bucket is venue-wide: six new photos fanned out
by `upload-new` to ~100 open feeds exhausts it, and then every original fetch from anyone
429s for the rest of the window. The tiles' own 4-second retry uses a fresh `?r=` nonce, so
the clients hold the bucket saturated themselves — the whole venue watching the newest
photos render as broken tiles while the projector skips slides.

A per-IP bucket cannot separate one scraper from the entire party when they share an
address, and these media routes are unauthenticated by design (an `<img>` cannot send a
bearer token), so there is no per-user key to move to. Bandwidth abuse belongs at the proxy.

Also here: the release/lock check order. `release ⇒ lock`, so testing the lock first made
the `GalleryReleased` arm unreachable dead code and every post-release upload answered
`uploads_locked`. The codes are not interchangeable to the client — `uploads_locked` charges
a retry attempt and re-pushes the whole photo on the backoff ladder against an answer that
cannot change, while `gallery_released` parks it and says the photo is safe but the hosts
must reopen. Both sites now test release first, so the fast path and the commit-time
re-check agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:43:50 +02:00
parent ec7c7f18ca
commit 7154b3a810
7 changed files with 415 additions and 77 deletions

View File

@@ -21,6 +21,15 @@ pub struct PublicEventDto {
pub theme_preset: String,
pub theme_primary: String,
pub theme_accent: String,
/// The operator's data notice, if they set one. Empty string when unset (migration 009
/// defaults it to `''`).
///
/// Exposed PUBLICLY — it was only on `/me/context`, which requires a token, so the one place a
/// notice actually has to appear (before a name is collected) could not read it. The join page
/// pairs this with a baseline notice of its own, precisely because this can be empty: relying
/// on an operator-supplied string meant a stock deploy collected ~100 EU guests' photos of
/// identifiable people, including children, with no notice at the point of collection at all.
pub privacy_note: String,
}
/// Public event identity + presentation config, used by the pre-auth join/recover
@@ -40,5 +49,6 @@ pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEvent
.await,
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
.await,
privacy_note: config::get_str(cache, "privacy_note", "").await,
})
}