Files
EventSnap/backend/src/handlers/me.rs
fabi 55b57fc037 fix(me): two hosts deleting at once can no longer leave the event with no operator
The last-operator guard ran on the pool, before the transaction opened. Two
hosts deleting themselves at the same moment each saw the other, both passed,
and the event was left with nobody who can moderate, nobody who can release
the gallery, and no way to appoint anyone — because appointing requires a
host. Not recoverable from inside the app.

The fix is a transaction-scoped ADVISORY lock, and the two obvious
alternatives are both worse:

* `FOR UPDATE` on the other operators' rows DEADLOCKS. Each deleter locks the
  other's row and then tries to delete its own, so Postgres resolves it by
  killing one. The invariant survives; the loser gets a 500 instead of the
  sentence explaining what to do. My first attempt did exactly this, and the
  test caught it.
* Locking the `event` row serialises cleanly but inverts the lock order every
  moderation path takes (upload/user rows first, event last). That is an ABBA
  against a path that runs constantly during the event, traded for one that
  runs approximately never.

An advisory lock is a separate lock space, so it cannot interact with the
row-lock graph at all, and it is released when the transaction ends. The loser
waits, counts zero once the winner's row is gone, and is refused with the
sentence it should have got.

The test is a genuine concurrency test — it spawns the second deleter and
asserts it unblocks to see no remaining operator. It fails against the
pre-check-outside-the-transaction version and against the FOR UPDATE version.
2026-08-12 21:40:29 +02:00

315 lines
14 KiB
Rust

//! Endpoints scoped to the *current user*. Kept separate from `auth::handlers` because
//! these aren't about acquiring / refreshing a session — they're about reading my own
//! state once I'm already signed in.
//!
//! Current routes:
//! - `GET /api/v1/me/context` — bundled profile + feature flags + privacy note. The
//! account page loads this once on mount instead of issuing several round trips.
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
use axum::Json;
use axum::extract::State;
use serde::Serialize;
use crate::auth::middleware::AuthUser;
use crate::error::AppError;
use crate::handlers::upload::compute_storage_quota;
use crate::models::user::{User, UserRole};
use crate::services::config;
use crate::state::AppState;
#[derive(Serialize)]
pub struct QuotaDto {
pub enabled: bool,
pub used_bytes: i64,
pub limit_bytes: Option<i64>,
pub active_uploaders: i64,
pub free_disk_bytes: i64,
}
pub async fn get_quota(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<QuotaDto>, AppError> {
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
let estimate = compute_storage_quota(&state).await;
// Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
// must never reach a guest, even though the guest upload UI no longer renders it.
// A guest still gets their own `used`/`limit` so enforcement stays transparent to
// the code paths that consume it; only the server-wide fields are zeroed.
let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);
Ok(Json(QuotaDto {
enabled: estimate.limit_bytes.is_some(),
used_bytes: user.total_upload_bytes,
limit_bytes: estimate.limit_bytes,
active_uploaders: if is_staff {
estimate.active_uploaders
} else {
0
},
free_disk_bytes: if is_staff {
estimate.free_disk_bytes
} else {
0
},
}))
}
#[derive(Serialize)]
pub struct MeContextDto {
pub user_id: uuid::Uuid,
pub display_name: String,
pub role: String,
/// Plain-text Datenschutzhinweis set by the admin. Empty string when not configured.
pub privacy_note: String,
pub quota_enabled: bool,
pub storage_quota_enabled: bool,
/// Uploads are locked (event closed) — the composer should show a locked state live
/// instead of letting a guest compose an upload only to eat a 403.
pub uploads_locked: bool,
/// The gallery has been released and the export snapshotted — uploads are permanently
/// closed for this run (release ⇒ lock, and reopening regenerates).
pub gallery_released: bool,
/// This guest is banned: a deliberately READ-ONLY ban (see `handlers/host.rs`) — they keep
/// the feed and the keepsake, but every write is refused.
///
/// Exposed so the UI can SAY so. Without it the client had no idea, so the upload button,
/// the like button and "Löschen" all rendered enabled and returned 403 "Du bist gesperrt."
/// on every tap — a guest tapping upload repeatedly with nobody to ask. The lock case
/// (`uploads_locked`) has always been surfaced for exactly this reason; a ban was not.
pub is_banned: bool,
}
pub async fn get_context(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<MeContextDto>, AppError> {
let user = User::find_by_id(&state.pool, auth.user_id)
.await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
let privacy_note = config::get_str(&state.config_cache, "privacy_note", "").await;
let quota_enabled = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_enabled =
config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
let event =
crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug).await?;
let uploads_locked = event
.as_ref()
.map(|e| e.uploads_locked_at.is_some())
.unwrap_or(false);
let gallery_released = event
.as_ref()
.map(|e| e.export_released_at.is_some())
.unwrap_or(false);
Ok(Json(MeContextDto {
user_id: user.id,
display_name: user.display_name,
role: user.role.as_str().to_string(),
privacy_note,
quota_enabled,
storage_quota_enabled,
uploads_locked,
gallery_released,
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?;
// The last-host guard again, now AUTHORITATIVELY — inside the transaction, holding a lock.
//
// The check above runs on the pool before this transaction opens, so two hosts deleting
// themselves at the same moment each saw the other and both proceeded, leaving the event with
// NO operator: nobody to moderate, nobody to release the gallery, and no way to appoint anyone
// because appointing requires a host. That is not recoverable from inside the app.
//
// Serialised with a transaction-scoped ADVISORY lock, not a row lock.
//
// `FOR UPDATE` on the other operators' rows looks like the obvious answer and is the wrong one:
// each deleter would lock the OTHER's row and then try to delete its own, so the two block on
// each other and Postgres resolves it by killing one with a deadlock error — the invariant
// holds, but the loser gets a 500 instead of the sentence below. Locking the `event` row
// instead would serialise cleanly, but it inverts the lock order every moderation path uses
// (upload/user rows first, event last), which is an ABBA waiting to happen.
//
// An advisory lock has neither problem: it is a separate lock space, so it cannot interact with
// the row-lock graph at all, and it is released automatically when this transaction ends.
// 4242 is an arbitrary namespace to keep this key from colliding with any future advisory use.
if matches!(user.role, UserRole::Host | UserRole::Admin) {
sqlx::query("SELECT pg_advisory_xact_lock(4242, hashtext($1::text))")
.bind(auth.event_id)
.execute(&mut *tx)
.await?;
let others: Vec<uuid::Uuid> = sqlx::query_scalar(
"SELECT id 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_all(&mut *tx)
.await?;
if others.is_empty() {
return Err(AppError::BadRequest(
"Du bist der letzte Gastgeber. Ernenne zuerst einen anderen Gastgeber, bevor du \
dein Konto löschst."
.into(),
));
}
}
// 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?;
// IMMEDIATELY after the commit, before any other `.await`. Every other `invalidate_and_arm`
// call site does this; this one used to spawn the workers *after* the file-removal loop below,
// and axum drops a handler future the moment the client disconnects. Drop it inside that loop
// and the keepsake is left with the epoch bumped, both `export_job` rows armed `pending` at
// that epoch, and NO WORKER: `/export/zip` and `/export/html` 404, the UI sits on
// "Wird vorbereitet…" forever, and `recover_exports` only runs at boot. Deleting your account
// from a phone that walks out of wifi range is enough to do it.
if let Some(r) = regen {
crate::handlers::host::start_regen(&state, r);
}
// 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");
}
}
}
// 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.
//
// The names are passed EXPLICITLY here, unlike every other call site. `audit::record` resolves
// a missing name by looking the id up in `"user"` — and this handler has just hard-deleted that
// row, so the lookup would find nothing and write the NULL that makes the record unreadable.
// This is the row most likely to be read later ("whose photos disappeared?"), and migration 029
// made these columns non-FK precisely so it would survive the deletion.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
Some(&user.display_name),
user.role.clone(),
"delete_account",
Some(auth.user_id),
Some(&user.display_name),
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)
}