//! 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, pub active_uploaders: i64, pub free_disk_bytes: i64, } pub async fn get_quota( State(state): State, auth: AuthUser, ) -> Result, 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, auth: AuthUser, ) -> Result, 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, Option, Option); /// 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, auth: AuthUser, ) -> Result { // 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 = 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 = 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) }