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>
55 lines
2.6 KiB
Rust
55 lines
2.6 KiB
Rust
//! Unauthenticated, read-only endpoints safe to expose before a user has joined.
|
|
|
|
use axum::Json;
|
|
use axum::extract::State;
|
|
use serde::Serialize;
|
|
|
|
use crate::services::config;
|
|
use crate::state::AppState;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct PublicEventDto {
|
|
pub name: String,
|
|
pub slug: String,
|
|
/// Whether the comment feature is on (env `COMMENTS_ENABLED`). The frontend hides
|
|
/// the whole comment UI when false; exposed here so even the pre-auth shell knows.
|
|
pub comments_enabled: bool,
|
|
/// Active colour theme. `preset` is an id the frontend maps to a palette (or
|
|
/// "custom"); `primary`/`accent` are the `#rrggbb` seeds the ramps derive from.
|
|
/// Resolved as DB-config override → env default. Public so the theme applies on
|
|
/// the very first (pre-auth) paint without a flash.
|
|
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
|
|
/// screens (which event am I joining, what does it look like). Only non-user-scoped
|
|
/// fields are exposed, so this is safe without a token. Identity comes straight from
|
|
/// instance config; the theme is resolved from the runtime `config` table (admin UI)
|
|
/// falling back to the env-seeded default.
|
|
pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
|
|
let cache = &state.config_cache;
|
|
Json(PublicEventDto {
|
|
name: state.config.event_name.clone(),
|
|
slug: state.config.event_slug.clone(),
|
|
comments_enabled: state.config.comments_enabled,
|
|
theme_preset: config::get_str(cache, "theme_preset", &state.config.default_theme_preset)
|
|
.await,
|
|
theme_primary: config::get_str(cache, "theme_primary", &state.config.default_theme_primary)
|
|
.await,
|
|
theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent)
|
|
.await,
|
|
privacy_note: config::get_str(cache, "privacy_note", "").await,
|
|
})
|
|
}
|