**1. Releasing the gallery could arm the keepsake with no worker.** `release_gallery` ran `tx.commit()` -> SSE `event-closed` -> `audit::record().await` -> `spawn_export_jobs`. The audit write is two pool round-trips, each able to wait the full 5s acquire timeout, and it runs in the same instant `event-closed` fans out to ~100 phones whose upload queues all hit the API at once. Axum drops the handler future when the client disconnects — the host taps "Freigeben" and pockets the phone. The release has COMMITTED: event closed, uploads locked, epoch bumped, both `export_job` rows pending, and no worker. `/export/*` 404s, the page sits on "Wird vorbereitet…", `recover_exports` only runs at boot, and a second release is refused. Every other regen call site spawns first; `me.rs` says so in a comment. This was the sole violator, and the only path that arms the FIRST build of the keepsake. Spawn moved immediately after the commit. **2. The event could be left with no operator.** `remaining_operators` was an unlocked pool COUNT followed by a separate UPDATE, so `ban_user` and `set_role` raced each other and `DELETE /me`: an admin demotes host B while host A deletes themselves, each check sees the other still present, both commit, and nobody can moderate, release the gallery, or appoint anyone — appointing requires being an operator. The count now runs inside the writing transaction behind the same advisory lock `delete_account` uses, via one shared helper so the key cannot drift between copies. The lock is taken FIRST in all three, and the order is load-bearing: `delete_account` previously took it last, after row locks on `upload` and `event`, while the two new call sites take it before locking those same rows — an ABBA that Postgres would resolve by killing one transaction with a 500. The ordering rule is documented on the helper. **3. The keepsake could become unbuildable the moment uploads stopped.** The upload gate and the export preflight computed the IDENTICAL threshold (`required_free_bytes(media, 2) + DISK_RESERVE_BYTES`), leaving zero margin between them. Once the gate refused its first upload the preflight was already at its own limit, so anything written afterwards decided the keepsake's fate: WAL up to `max_wal_size`, 30 MB x 4 of container logs, and the compression backlog draining at exactly that hour. The release commits before the workers bail, so the failure lands at 01:00 with no second release possible. The gate now demands `UPLOAD_GATE_HEADROOM_BYTES` more than the preflight, costing ~0.5 GB of media ceiling — the trade README already argues for. The dashboard banner mirrors the new threshold so its lead is unchanged, and a new test pins gate-before-preflight at six gallery sizes. Also: the global disk gate fails OPEN when the mount cannot be read, which is deliberate, but did it SILENTLY — no log line at all, while the export preflight warns on the identical condition. Inside a container `/` is an overlay rather than a `/dev` device, so this is reachable, and when it happens the only global disk bound is gone and the box fills until Postgres cannot write WAL. README's sizing table was also arithmetically self-contradictory (it showed ~27 GB free against a ~27.6 GB requirement); recomputed for the new gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
14 KiB
Rust
321 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?;
|
|
|
|
// The last-host guard, AUTHORITATIVELY — inside the transaction, holding a lock.
|
|
//
|
|
// The pre-check further up 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. 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). An advisory lock is a separate lock space, so it cannot join the
|
|
// row-lock graph at all, and it is released automatically when this transaction ends.
|
|
//
|
|
// FIRST STATEMENT IN THE TRANSACTION, before any row lock — the ORDER matters as much as the
|
|
// lock. `ban_user` and `set_role` take this same lock and then go on to lock `user` and
|
|
// `event` rows. If this path grabbed those rows first and reached for the advisory lock
|
|
// afterwards, the two would deadlock, each holding what the other needs, and Postgres would
|
|
// kill one with a 500: the invariant would survive, but a host deleting their account would
|
|
// get an error page instead of the sentence below.
|
|
//
|
|
// Taking it up front also means the refusal path does no work at all before answering.
|
|
if matches!(user.role, UserRole::Host | UserRole::Admin) {
|
|
// Shared with `host::ban_user` and `host::set_role` — the same key, by construction rather
|
|
// than by two copies agreeing. All three remove an operator, so all three must serialise
|
|
// against each other or the floor is enforceable only against its own kind of caller.
|
|
crate::handlers::host::lock_operator_floor(&mut tx, auth.event_id).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(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// 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?;
|
|
|
|
// 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)
|
|
}
|