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:
@@ -121,3 +121,139 @@ pub async fn get_context(
|
||||
is_banned: user.is_banned,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `(original_path, preview_path, thumbnail_path, display_path)` for one upload.
|
||||
type UploadFilePaths = (String, Option<String>, Option<String>, Option<String>);
|
||||
|
||||
/// Delete the caller's own account and everything attached to it.
|
||||
///
|
||||
/// The erasure path (H18). There was no user-deletion route at ANY role, so honouring a "please
|
||||
/// remove my photos and my name" request meant hand-written SQL against production — during or
|
||||
/// after a wedding, by whoever happened to have psql access. Deletion also never removed text:
|
||||
/// captions, comment bodies and hashtag links survived indefinitely by design, so even the
|
||||
/// existing per-photo delete left the guest's words in the database and in the keepsake.
|
||||
///
|
||||
/// Self-service on purpose. The alternative (host-initiated only) puts a guest's erasure request
|
||||
/// through a third party who is at a party, and the join page's data notice now promises this.
|
||||
///
|
||||
/// ORDER MATTERS. `upload.user_id` and `comment.user_id` are plain FKs with NO `ON DELETE CASCADE`
|
||||
/// (migration 002), so deleting the user first fails on a constraint violation. Children first,
|
||||
/// then the row itself — at which point `session`, `like` and `pin_reset_request` do cascade.
|
||||
pub async fn delete_account(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> Result<axum::http::StatusCode, AppError> {
|
||||
// The last host/admin may not erase themselves: it would leave the event with no operator and
|
||||
// no way to appoint one. Mirrors the floor `set_role` and `ban_user` already enforce.
|
||||
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
if matches!(user.role, UserRole::Host | UserRole::Admin) {
|
||||
let others = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM \"user\"
|
||||
WHERE event_id = $1 AND id != $2
|
||||
AND role IN ('host', 'admin') AND is_banned = FALSE",
|
||||
)
|
||||
.bind(auth.event_id)
|
||||
.bind(auth.user_id)
|
||||
.fetch_one(&state.pool)
|
||||
.await?;
|
||||
if others == 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
|
||||
dein Konto löschst."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Collect the file paths BEFORE the rows go, or they are unrecoverable. Every derivative, not
|
||||
// just the original: a preview left behind is still the guest's photo.
|
||||
let files: Vec<UploadFilePaths> = sqlx::query_as(
|
||||
"SELECT original_path, preview_path, thumbnail_path, display_path
|
||||
FROM upload WHERE user_id = $1",
|
||||
)
|
||||
.bind(auth.user_id)
|
||||
.fetch_all(&state.pool)
|
||||
.await?;
|
||||
|
||||
let mut tx = state.pool.begin().await?;
|
||||
// Comments the guest wrote on OTHER people's photos. Hard delete, not `deleted_at`: this is
|
||||
// erasure, and a soft delete leaves the body in the table and in the keepsake's data.json.
|
||||
sqlx::query("DELETE FROM comment WHERE user_id = $1")
|
||||
.bind(auth.user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Their uploads. Cascades comments and likes ON those uploads, plus upload_hashtag links.
|
||||
sqlx::query("DELETE FROM upload WHERE user_id = $1")
|
||||
.bind(auth.user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
// Invalidate the keepsake inside the same transaction — an already-released archive still
|
||||
// contains this guest's photos and captions, and erasure that leaves them in the downloadable
|
||||
// ZIP has not happened. Returns None when the event isn't released, in which case there is
|
||||
// nothing to rebuild.
|
||||
let regen = crate::services::export::invalidate_and_arm(
|
||||
&mut tx,
|
||||
&state.config.event_slug,
|
||||
crate::services::export::Affects::Both,
|
||||
)
|
||||
.await?;
|
||||
// And the account. `session`, `like` and `pin_reset_request` cascade from here.
|
||||
sqlx::query("DELETE FROM \"user\" WHERE id = $1")
|
||||
.bind(auth.user_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Best effort, after the commit. Anything missed here is an orphan with no row pointing at it,
|
||||
// which `sweep_orphan_originals` reclaims on its next pass — so a failure delays reclamation
|
||||
// rather than leaving the file referenced.
|
||||
for (original, preview, thumbnail, display) in &files {
|
||||
for rel in [
|
||||
Some(original),
|
||||
preview.as_ref(),
|
||||
thumbnail.as_ref(),
|
||||
display.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let abs = state.config.media_path.join(rel);
|
||||
if let Err(e) = tokio::fs::remove_file(&abs).await
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
tracing::warn!(error = ?e, path = %abs.display(), "account deletion: could not remove media file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(r) = regen {
|
||||
crate::handlers::host::start_regen(&state, r);
|
||||
}
|
||||
|
||||
// Evict their content from every open feed and the projector. `user-hidden` is exactly the
|
||||
// right signal — it already means "this user's cards must go" — and reusing it means every
|
||||
// client already handles this with no new event type.
|
||||
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||
"user-hidden",
|
||||
serde_json::json!({ "user_id": auth.user_id }).to_string(),
|
||||
));
|
||||
|
||||
// Audited like the host actions it resembles, with the actor and target being the same person.
|
||||
crate::services::audit::record(
|
||||
&state.pool,
|
||||
auth.event_id,
|
||||
auth.user_id,
|
||||
None,
|
||||
user.role.clone(),
|
||||
"delete_account",
|
||||
Some(auth.user_id),
|
||||
None,
|
||||
Some(serde_json::json!({ "uploads_removed": files.len() })),
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!(user_id = %auth.user_id, uploads = files.len(), "account deleted by its owner");
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user