fix(user-flow): close offline-queue, export, session, ban & realtime gaps
Some checks failed
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m52s
E2E / Cross-UA smoke matrix (push) Failing after 3m50s

Comprehensive user-flow review across guest/host/admin roles, then fixes with
e2e regression guards. Highlights:

- Offline upload queue auto-resumes on reconnect: network errors keep items
  pending (not error), 4xx are terminal (no infinite retry), quota exhaustion
  returns a distinct 413; queue cap + dedup.
- Export lifecycle: release atomically locks uploads; reopen invalidates and
  re-release regenerates the keepsake; workers claim their job atomically so a
  reopen->re-release can't corrupt the ZIP; startup re-spawns interrupted
  exports.
- Sessions slide on activity (no 30-day cliff); JWT expiry deferred to the
  revocable session row; sign-out-everywhere + revoke-on-PIN-reset.
- Ban always hides content (v_feed / find_visible_media / export filter
  is_banned) but stays a read-only ban per USER_JOURNEYS §10 — sessions are
  not revoked, read access + keepsake download preserved.
- Realtime: server-clock SSE delta cursor; event-closed/opened drive the UI
  live; feed_delta rate-limited; like returns {liked, like_count} to fix
  multi-device drift; lightbox live comments; diashow delta backfill.
- Forgotten-PIN in-app request flow; simultaneous same-name join returns 409;
  quota increment is transactional; operator floor.

Adds e2e/specs/10-flow-review/ (offline resume, export integrity, deterministic
anti-race guard) and updates existing specs for the new contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-10 23:05:37 +02:00
parent 16d1f356be
commit 641174717c
38 changed files with 1400 additions and 153 deletions

View File

@@ -83,7 +83,19 @@ pub async fn join(
let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
let user = User::create(&state.pool, event.id, display_name, &pin_hash).await?;
// The pre-check above is racy: two simultaneous joins with the same name can both
// pass it, and the DB's unique index then rejects the loser. Map that unique
// violation to the same clean 409 the pre-check returns, not a generic 500.
let user = match User::create(&state.pool, event.id, display_name, &pin_hash).await {
Ok(u) => u,
Err(sqlx::Error::Database(db)) if db.is_unique_violation() => {
return Err(AppError::Conflict(format!(
"Der Name \"{}\" ist bereits vergeben.",
display_name
)));
}
Err(e) => return Err(e.into()),
};
let token = jwt::create_token(
user.id,
@@ -359,3 +371,77 @@ pub async fn logout(
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
Ok(StatusCode::NO_CONTENT)
}
/// "Sign out everywhere" — revoke every session for the caller, not just the current one.
/// A single leaked/kept-alive device (a shared phone, a laptop recovered via PIN) can then
/// be cut from any of the user's devices.
pub async fn logout_all(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<StatusCode, AppError> {
Session::delete_all_for_user(&state.pool, auth.user_id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct PinResetRequestBody {
pub display_name: String,
}
/// A guest who forgot their PIN asks a host to reset it in-app. Unauthenticated (they
/// can't log in without the PIN) and rate-limited. Always returns 204 regardless of
/// whether the name exists, so it can't enumerate display names beyond what the public
/// feed already exposes.
pub async fn request_pin_reset(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PinResetRequestBody>,
) -> Result<StatusCode, AppError> {
let display_name = body.display_name.trim();
let ip = client_ip(&headers, "unknown");
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
if rate_limits_on {
let name_key = display_name.to_lowercase();
if !state.rate_limiter.check(
format!("pin_reset_req:{ip}:{name_key}"),
3,
Duration::from_secs(15 * 60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
if display_name.is_empty() {
return Ok(StatusCode::NO_CONTENT);
}
// Single statement so the existing-name and unknown-name paths do IDENTICAL work
// (same event+user index scans, an INSERT that matches 0 rows for an unknown name) —
// no timing oracle despite the always-204 contract. Admins recover via password, so
// they're excluded from the join and never get a reset request queued.
let _ = sqlx::query(
"INSERT INTO pin_reset_request (event_id, user_id)
SELECT e.id, u.id
FROM event e
JOIN \"user\" u
ON u.event_id = e.id
AND lower(u.display_name) = lower($2)
AND u.role <> 'admin'
WHERE e.slug = $1
ON CONFLICT (user_id) DO NOTHING",
)
.bind(&state.config.event_slug)
.bind(display_name)
.execute(&state.pool)
.await;
// Broadcast unconditionally (carries no per-name info) so an online host's request
// badge updates without revealing whether the name existed.
let _ = state
.sse_tx
.send(crate::state::SseEvent::new("pin-reset-requested", "{}"));
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -46,10 +46,20 @@ pub fn create_token(
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
// We deliberately do NOT enforce the JWT's own `exp`. The authoritative session
// lifetime lives in the `session` row (`expires_at > NOW()`), which SLIDES forward on
// every authenticated request (see `Session::touch_and_renew`). Enforcing the JWT's
// fixed +Nd `exp` here would hard-log-out an actively-used client on day N+1 even
// though its session was renewed — the "30-day cliff" from the review. With server-
// side sliding sessions, the token is a signature-checked bearer credential and its
// lifetime is revocable (logout / expiry / ban all delete the row), which is strictly
// stronger than a stateless non-revocable exp.
let mut validation = Validation::default();
validation.validate_exp = false;
let data = jsonwebtoken::decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
&validation,
)?;
Ok(data.claims)
}

View File

@@ -37,9 +37,10 @@ impl FromRequestParts<AppState> for AuthUser {
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("Ungültiges Token-Format.".into()))?;
// Verify the JWT (signature + expiry). We deliberately do NOT trust its role/ban
// claims — the live user row below is authoritative — so the decoded claims
// themselves aren't needed beyond this check.
// Verify the JWT's signature. Expiry is deliberately NOT enforced here (see
// `jwt::verify_token`) — the authoritative, sliding session lifetime lives in the
// `session` row read below. We also don't trust the token's role/ban claims; the
// live user row is authoritative, so the decoded claims aren't needed beyond this.
jwt::verify_token(token, &state.config.jwt_secret)
.map_err(|_| AppError::Unauthorized("Token ungültig oder abgelaufen.".into()))?;
@@ -55,14 +56,22 @@ impl FromRequestParts<AppState> for AuthUser {
.map_err(|e| AppError::Internal(e.into()))?
.ok_or_else(|| AppError::Unauthorized("Sitzung nicht gefunden oder abgelaufen.".into()))?;
// Update last_seen_at in the background (fire-and-forget). Failures are
// non-fatal but worth surfacing — silent swallowing hides DB connection
// pressure that would otherwise be the first symptom of a real problem.
// Touch last_seen_at AND slide the session's expiry forward (fire-and-forget), so
// an active client's session renews instead of hitting the fixed 30-day cliff.
// Admin sessions keep their tighter 1-day window (they renew on activity but still
// lapse a day after the admin goes idle). Failures are non-fatal but worth
// surfacing — silent swallowing hides DB connection pressure that would otherwise
// be the first symptom of a real problem.
let pool = state.pool.clone();
let touch_hash = token_hash.clone();
let expiry_days = if user.role == UserRole::Admin {
1
} else {
state.config.session_expiry_days
};
tokio::spawn(async move {
if let Err(e) = Session::touch_by_token_hash(&pool, &touch_hash).await {
tracing::warn!(error = ?e, "session touch failed");
if let Err(e) = Session::touch_and_renew(&pool, &touch_hash, expiry_days).await {
tracing::warn!(error = ?e, "session touch/renew failed");
}
});

View File

@@ -10,6 +10,10 @@ pub enum AppError {
Conflict(String),
/// Second field: optional retry-after seconds to include in the response.
TooManyRequests(String, Option<u64>),
/// Per-user storage quota exhausted. Distinct from `TooManyRequests` (rate limit) so
/// the client can treat it as *terminal* (413, no retry) instead of backing off and
/// retrying a permanently-failing upload forever.
QuotaExceeded(String),
Internal(anyhow::Error),
}
@@ -22,6 +26,7 @@ impl AppError {
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"),
Self::QuotaExceeded(_) => (StatusCode::PAYLOAD_TOO_LARGE, "quota_exceeded"),
Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
}
}
@@ -34,6 +39,7 @@ impl AppError {
| Self::NotFound(msg)
| Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(),
Self::QuotaExceeded(msg) => msg.clone(),
Self::Internal(err) => {
tracing::error!("internal error: {err:#}");
"Ein interner Fehler ist aufgetreten.".to_string()

View File

@@ -186,6 +186,11 @@ pub struct DeltaResponse {
/// rather than merging (the older missed uploads are absent and unrecoverable
/// via a later delta, which advances `since` past them).
pub truncated: bool,
/// The server's clock at the moment this delta was computed. The client advances its
/// reconnect cursor from THIS, never `new Date()` — a browser clock even seconds fast
/// would otherwise silently skip uploads whose server `created_at` falls in the skew
/// window (they'd never reappear without a hard refresh).
pub server_time: DateTime<Utc>,
}
pub async fn feed_delta(
@@ -193,6 +198,33 @@ pub async fn feed_delta(
auth: AuthUser,
Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> {
// Rate-limit the delta the same way as the paginated feed. Without this, ~100 clients
// reconnecting at once (post-outage, or a flapping network) each fire an unbounded
// delta fetch — a reconnect stampede. Keyed per-user so one client can't starve others
// behind a shared NAT.
let rate_limits_on = config::get_bool(&state.config_cache, "rate_limits_enabled", true).await;
let feed_rate_on = config::get_bool(&state.config_cache, "feed_rate_enabled", true).await;
if rate_limits_on && feed_rate_on {
let rate_limit = config::get_usize(&state.config_cache, "feed_rate_per_min", 60).await;
if !state.rate_limiter.check(
format!("feed_delta:{}", auth.user_id),
rate_limit,
Duration::from_secs(60),
) {
return Err(AppError::TooManyRequests(
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
None,
));
}
}
// Anchor the next cursor to the DB clock, captured *before* the queries so an upload
// committed during this handler is re-fetched next time rather than skipped (a
// duplicate id merges idempotently on the client; a miss is unrecoverable).
let server_time: DateTime<Utc> = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
// Bounded like the paginated feed: a stale `since` could otherwise pull the
// entire event's uploads in one response. If a client hits the cap it should
// fall back to a full feed refresh rather than another delta.
@@ -254,6 +286,7 @@ pub async fn feed_delta(
uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
truncated,
server_time,
}))
}

View File

@@ -9,6 +9,7 @@ use crate::auth::middleware::RequireHost;
use crate::error::AppError;
use crate::models::comment::Comment;
use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::upload::Upload;
use crate::models::user::UserRole;
use crate::state::{AppState, SseEvent};
@@ -35,11 +36,27 @@ pub struct EventStatus {
pub export_released: bool,
}
#[derive(Deserialize)]
pub struct BanRequest {
pub hide_uploads: bool,
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
/// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
/// always keeps at least one operator" floor.
async fn remaining_operators(
state: &AppState,
event_id: Uuid,
excluding: Uuid,
) -> Result<i64, AppError> {
let count = 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(event_id)
.bind(excluding)
.fetch_one(&state.pool)
.await?;
Ok(count)
}
#[derive(Deserialize)]
pub struct SetRoleRequest {
pub role: String,
@@ -93,8 +110,8 @@ pub async fn ban_user(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>,
Json(body): Json<BanRequest>,
) -> Result<StatusCode, AppError> {
// The ban request carries no body — ban always hides (no per-request options).
// Cannot ban yourself or another host/admin
if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into()));
@@ -112,29 +129,46 @@ pub async fn ban_user(
return Err(AppError::Forbidden("Du kannst diesen Benutzer nicht sperren.".into()));
}
// Floor: never leave the event with zero operators. Banning removes the target from
// the active-operator pool, so refuse if they're the last non-banned host/admin.
if target.0 == "host" && remaining_operators(&state, auth.event_id, user_id).await? == 0 {
return Err(AppError::BadRequest(
"Der letzte Host kann nicht gesperrt werden.".into(),
));
}
// Ban ALWAYS hides: a banned user's content is "gone" everywhere. The visibility
// views/queries now also filter on `is_banned` (defense in depth), and we set
// `uploads_hidden` so the existing `user-hidden` live-eviction path fires too. The old
// opt-out checkbox is gone — `hide_uploads` in the request is ignored.
//
// We deliberately do NOT revoke the banned user's sessions. This is a *read-only ban*
// by design (USER_JOURNEYS §10.3): the user keeps read access to the feed and can still
// download the released export — writes and host/admin actions are what the ban blocks
// (enforced live on the write handlers + Require{Host,Admin}). Revoking sessions would
// contradict that model, break the documented "banned guest can still download the
// keepsake" flow, and be ineffective anyway (the user could just /recover a new session).
sqlx::query(
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = $2 WHERE id = $1 AND event_id = $3",
"UPDATE \"user\" SET is_banned = TRUE, uploads_hidden = TRUE WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(body.hide_uploads)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
// If we hid their uploads, evict them live from every feed + the diashow so a
// banned guest's content disappears without each viewer having to reload.
if body.hide_uploads {
let _ = state.sse_tx.send(SseEvent::new(
"user-hidden",
serde_json::json!({ "user_id": user_id }).to_string(),
));
}
// Evict their content live from every feed + the diashow so it disappears without
// each viewer having to reload. (Their own SSE stream is separately dropped by the
// is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
// retaining plain read access.)
let _ = state.sse_tx.send(SseEvent::new(
"user-hidden",
serde_json::json!({ "user_id": user_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
hide_uploads = body.hide_uploads,
"host: ban_user"
);
@@ -166,8 +200,10 @@ pub async fn unban_user(
));
}
// Unban restores visibility too: ban set `uploads_hidden = TRUE`, so clearing only
// `is_banned` would leave their content invisible. Clear both.
let result = sqlx::query(
"UPDATE \"user\" SET is_banned = FALSE WHERE id = $1 AND event_id = $2",
"UPDATE \"user\" SET is_banned = FALSE, uploads_hidden = FALSE WHERE id = $1 AND event_id = $2",
)
.bind(user_id)
.bind(auth.event_id)
@@ -230,6 +266,17 @@ pub async fn set_role(
));
}
// Floor: demoting the last non-banned host/admin to guest would leave the event with
// no operator. Refuse.
if new_role == "guest"
&& target.0 == "host"
&& remaining_operators(&state, auth.event_id, user_id).await? == 0
{
return Err(AppError::BadRequest(
"Der letzte Host kann nicht zum Gast gemacht werden.".into(),
));
}
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
.bind(user_id)
.bind(new_role)
@@ -308,6 +355,16 @@ pub async fn reset_user_pin(
.execute(&state.pool)
.await?;
// A PIN reset means the old credential is compromised/forgotten — revoke every
// existing session so old devices must re-authenticate with the new PIN.
let _ = Session::delete_all_for_user(&state.pool, user_id).await;
// Resolve any pending in-app "I forgot my PIN" request for this user.
let _ = sqlx::query("DELETE FROM pin_reset_request WHERE user_id = $1")
.bind(user_id)
.execute(&state.pool)
.await;
// Notify the *recipient* device(s) if they happen to be online so they can clear
// their cached local PIN. They'll save the new one on the next /recover.
let _ = state.sse_tx.send(SseEvent::new(
@@ -325,6 +382,48 @@ pub async fn reset_user_pin(
Ok(Json(PinResetResponse { pin }))
}
#[derive(Serialize, sqlx::FromRow)]
pub struct PinResetRequestSummary {
pub id: Uuid,
pub user_id: Uuid,
pub display_name: String,
pub created_at: DateTime<Utc>,
}
/// List pending in-app PIN-reset requests so a host can action them (via the existing
/// `reset_user_pin`, which also clears the request).
pub async fn list_pin_reset_requests(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
) -> Result<Json<Vec<PinResetRequestSummary>>, AppError> {
let rows = sqlx::query_as::<_, PinResetRequestSummary>(
"SELECT r.id, r.user_id, u.display_name, r.created_at
FROM pin_reset_request r
JOIN \"user\" u ON u.id = r.user_id
WHERE r.event_id = $1
ORDER BY r.created_at ASC",
)
.bind(auth.event_id)
.fetch_all(&state.pool)
.await?;
Ok(Json(rows))
}
/// Dismiss a PIN-reset request without resetting (e.g. the host couldn't verify the
/// requester's identity).
pub async fn dismiss_pin_reset_request(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
sqlx::query("DELETE FROM pin_reset_request WHERE id = $1 AND event_id = $2")
.bind(id)
.bind(auth.event_id)
.execute(&state.pool)
.await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn host_delete_upload(
State(state): State<AppState>,
RequireHost(auth): RequireHost,
@@ -401,8 +500,17 @@ pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> {
// Reopening also invalidates any prior release: the keepsake was snapshotted at
// release time, so allowing new uploads afterwards would silently diverge the live
// feed from the frozen export. Clearing `export_released_at` (and the readiness
// flags) lets the host re-release later to regenerate a correct, complete keepsake.
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NULL WHERE slug = $1 AND uploads_locked_at IS NOT NULL",
"UPDATE event
SET uploads_locked_at = NULL,
export_released_at = NULL,
export_zip_ready = FALSE,
export_html_ready = FALSE
WHERE slug = $1 AND (uploads_locked_at IS NOT NULL OR export_released_at IS NOT NULL)",
)
.bind(&state.config.event_slug)
.execute(&state.pool)
@@ -422,8 +530,15 @@ pub async fn release_gallery(
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent
// release calls can't both pass a check-then-set and double-enqueue exports.
// rows_affected == 0 means someone already released.
//
// Releasing also locks uploads in the same statement (release ⇒ lock). Otherwise a
// guest whose offline upload reconnects *after* the export snapshot would land in the
// live feed but not in the downloaded keepsake — a silent, non-regenerable data loss.
// `COALESCE` preserves an earlier explicit lock time rather than overwriting it.
let result = sqlx::query(
"UPDATE event SET export_released_at = NOW()
"UPDATE event
SET export_released_at = NOW(),
uploads_locked_at = COALESCE(uploads_locked_at, NOW())
WHERE slug = $1 AND export_released_at IS NULL",
)
.bind(&state.config.event_slug)
@@ -441,32 +556,26 @@ pub async fn release_gallery(
});
}
// Release locked uploads too — tell any open composer to flip to the locked UI live
// rather than discovering it via a rejected upload.
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
// We won the claim — load the event for its id/name to enqueue export jobs.
let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
// Enqueue export jobs
for export_type in ["zip", "html"] {
sqlx::query(
"INSERT INTO export_job (event_id, type) VALUES ($1, $2::export_type)
ON CONFLICT (event_id, type) DO NOTHING",
)
.bind(event.id)
.bind(export_type)
.execute(&state.pool)
.await?;
}
// Spawn export workers
crate::services::export::spawn_export_jobs(
// Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
// cleanly and startup recovery uses identical logic.
crate::services::export::enqueue_and_spawn_exports(
event.id,
event.name,
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
state.sse_tx.clone(),
);
)
.await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -55,6 +55,12 @@ pub struct MeContextDto {
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,
}
pub async fn get_context(
@@ -69,6 +75,11 @@ pub async fn get_context(
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,
@@ -76,5 +87,7 @@ pub async fn get_context(
privacy_note,
quota_enabled,
storage_quota_enabled,
uploads_locked,
gallery_released,
}))
}

View File

@@ -2,7 +2,7 @@ use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::middleware::AuthUser;
@@ -12,11 +12,22 @@ use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload;
use crate::state::AppState;
#[derive(Serialize)]
pub struct LikeResponse {
/// The caller's like state *after* this toggle. The client sets `liked_by_me` from
/// this rather than blind-inverting local state — otherwise a second device (same
/// recovered user) drifts, since the `like-update` broadcast only carries `like_count`.
pub liked: bool,
/// Fresh like count, or `null` if the (best-effort) count query hiccuped. The client
/// keeps its current count when this is null rather than adopting a wrong number.
pub like_count: Option<i64>,
}
pub async fn toggle_like(
State(state): State<AppState>,
auth: AuthUser,
Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
) -> Result<Json<LikeResponse>, AppError> {
// Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await?
@@ -35,7 +46,7 @@ pub async fn toggle_like(
// ("Event schließen") freezes *new uploads* only — likes, comments and
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix).
// Try to insert; if conflict, delete (toggle)
// Try to insert; if conflict, delete (toggle). `liked` = the caller's state afterwards.
let result = sqlx::query(
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
ON CONFLICT (upload_id, user_id) DO NOTHING",
@@ -45,7 +56,8 @@ pub async fn toggle_like(
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
let liked = result.rows_affected() > 0;
if !liked {
// Already liked — remove
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
.bind(upload_id)
@@ -55,24 +67,29 @@ pub async fn toggle_like(
}
// Fresh count so feed clients can patch the single card in place instead of
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The
// count + broadcast are a UI optimisation — the like itself is already committed,
// so a failure here must not fail the request. Swallow the error and skip the
// broadcast; the next event or a pull-to-refresh reconciles the count.
if let Ok(like_count) = sqlx::query_scalar::<_, i64>(
// refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The like
// itself is already committed, so a failed count must not fail the request — but we
// also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
// client until the next event). On error we skip the broadcast and return null.
let like_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
)
.bind(upload_id)
.fetch_one(&state.pool)
.await
{
.ok();
if let Some(count) = like_count {
// Broadcast the new count so other clients patch their card. Only `like_count` is
// shared — each client's own `liked_by_me` only changes via its own toggle (which
// now reads it straight from this response).
let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(),
data: serde_json::json!({ "upload_id": upload_id, "like_count": like_count }).to_string(),
data: serde_json::json!({ "upload_id": upload_id, "like_count": count }).to_string(),
});
}
Ok(StatusCode::NO_CONTENT)
Ok(Json(LikeResponse { liked, like_count }))
}
#[derive(Deserialize, Default)]

View File

@@ -23,6 +23,10 @@ pub struct SseQuery {
#[derive(Serialize)]
pub struct StreamTicketResponse {
pub ticket: String,
/// Server clock at mint time — the client seeds its SSE reconnect cursor from this
/// instead of `new Date()`, so a skewed browser clock can't drop uploads. See
/// `DeltaResponse::server_time`.
pub server_time: chrono::DateTime<chrono::Utc>,
}
/// Mint a short-lived single-use SSE ticket. The browser's `EventSource` cannot
@@ -33,9 +37,12 @@ pub struct StreamTicketResponse {
pub async fn issue_ticket(
State(state): State<AppState>,
auth: AuthUser,
) -> Json<StreamTicketResponse> {
) -> Result<Json<StreamTicketResponse>, AppError> {
let ticket = state.sse_tickets.issue(auth.token_hash);
Json(StreamTicketResponse { ticket })
let server_time = sqlx::query_scalar("SELECT NOW()")
.fetch_one(&state.pool)
.await?;
Ok(Json(StreamTicketResponse { ticket, server_time }))
}
/// SSE stream endpoint. Authenticates via a single-use ticket (see
@@ -74,9 +81,12 @@ pub async fn stream(
});
// The session is only checked once at open. Re-validate it periodically so a
// logged-out or expired session's stream is closed rather than kept alive until the
// client happens to disconnect. Bounds a stale stream to ~60s. Only a *definitively*
// gone/expired session ends the stream; a transient DB error just retries next tick.
// logged-out, expired, OR banned session's stream is closed rather than kept alive
// until the client happens to disconnect. Bounds a stale stream to ~60s. Banning
// already revokes the user's sessions (so the row is gone), but re-reading the live
// user row and dropping on `is_banned` is a cheap defense-in-depth. Only a
// *definitive* gone/expired/banned state ends the stream; a transient DB error just
// retries next tick.
let pool = state.pool.clone();
let session_hash = token_hash.clone();
let session_gone = async move {
@@ -84,9 +94,10 @@ pub async fn stream(
ticker.tick().await; // consume the immediate first tick
loop {
ticker.tick().await;
match Session::find_by_token_hash(&pool, &session_hash).await {
Ok(Some(_)) => {} // still valid — keep streaming
Ok(None) => break, // logged out or expired — stop
match Session::find_user_by_token_hash(&pool, &session_hash).await {
Ok(Some(user)) if !user.is_banned => {} // still valid — keep streaming
Ok(Some(_)) => break, // banned — cut the stream
Ok(None) => break, // logged out or expired — stop
Err(e) => {
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
}

View File

@@ -77,6 +77,13 @@ pub async fn upload(
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Uploads sind gesperrt.".into()));
}
// Belt-and-suspenders on top of the lock (release ⇒ lock): once the gallery is
// released the export has been snapshotted, so a late upload could never make it into
// the keepsake. Reject it explicitly rather than silently diverging the live feed.
if event.export_released_at.is_some() {
drain_multipart(multipart).await;
return Err(AppError::Forbidden("Galerie wurde bereits freigegeben.".into()));
}
// Read config limits from DB
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await;
@@ -213,15 +220,21 @@ pub async fn upload(
// disable it on trusted instances.
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await;
let storage_quota_on = config::get_bool(&state.config_cache, "storage_quota_enabled", true).await;
// When quota is enforced, this holds the byte ceiling so the increment UPDATE below can
// enforce it atomically (`WHERE total + size <= limit`). Without that guard, two
// concurrent uploads from the same user (e.g. phone + laptop) both pass this stale
// pre-check and both increment, blowing past the quota. The pre-check stays as a
// fast path that avoids the disk write when the user is already clearly over.
let mut quota_limit: Option<i64> = None;
if quota_on && storage_quota_on {
let estimate = compute_storage_quota(&state).await;
if let Some(limit) = estimate.limit_bytes {
quota_limit = Some(limit);
let prospective_total = user.total_upload_bytes.saturating_add(size);
if prospective_total > limit {
let _ = tokio::fs::remove_file(&temp_abs).await;
return Err(AppError::TooManyRequests(
return Err(AppError::QuotaExceeded(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
None,
));
}
}
@@ -256,11 +269,33 @@ pub async fn upload(
// bytes with no row to reclaim them (silent quota erosion / spurious lockout).
let tx_result: Result<Upload, AppError> = async {
let mut tx = state.pool.begin().await?;
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
// Increment the user's byte total. When a quota is in force, guard it atomically
// (`total + size <= limit`) so two concurrent uploads can't both slip past the
// stale pre-check — the loser's UPDATE matches 0 rows and we abort with the same
// terminal quota error (the tx rolls back on drop; the on-disk file is cleaned by
// the error path below).
let inc = if let Some(limit) = quota_limit {
sqlx::query(
"UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2
WHERE id = $1 AND total_upload_bytes + $2 <= $3",
)
.bind(auth.user_id)
.bind(size)
.bind(limit)
.execute(&mut *tx)
.await?;
.await?
} else {
sqlx::query("UPDATE \"user\" SET total_upload_bytes = total_upload_bytes + $2 WHERE id = $1")
.bind(auth.user_id)
.bind(size)
.execute(&mut *tx)
.await?
};
if inc.rows_affected() == 0 {
return Err(AppError::QuotaExceeded(
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
));
}
let upload = Upload::create(
&mut *tx,
auth.event_id,

View File

@@ -44,6 +44,18 @@ async fn main() -> Result<()> {
let state = AppState::new(pool.clone(), config.clone());
// Re-spawn exports for events that were released but whose keepsake never finished
// (crash mid-export). Needs the media/export paths + SSE sender, so it runs here
// rather than inside `startup_recovery`. Fire-and-forget: the workers run in the
// background; the HTTP server can start accepting requests meanwhile.
services::export::recover_exports(
pool.clone(),
config.media_path.clone(),
config.export_path.clone(),
state.sse_tx.clone(),
)
.await;
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
services::maintenance::spawn_periodic_tasks(
@@ -60,8 +72,12 @@ async fn main() -> Result<()> {
.route("/api/v1/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover))
// Forgotten-PIN escape hatch: ask a host to reset it (unauthenticated, throttled).
.route("/api/v1/recover/request", post(auth::handlers::request_pin_reset))
.route("/api/v1/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout))
// "Sign out everywhere" — revoke all of the caller's sessions.
.route("/api/v1/sessions", delete(auth::handlers::logout_all))
// Upload — HTTP-level body cap as an OOM backstop. The handler still enforces
// the precise per-class limits from DB config (max_image/video_size_mb); this
// layer just stops a multi-GB body from being buffered into memory before that
@@ -118,6 +134,14 @@ async fn main() -> Result<()> {
"/api/v1/host/users/{id}/pin-reset",
post(handlers::host::reset_user_pin),
)
.route(
"/api/v1/host/pin-reset-requests",
get(handlers::host::list_pin_reset_requests),
)
.route(
"/api/v1/host/pin-reset-requests/{id}",
delete(handlers::host::dismiss_pin_reset_request),
)
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export (all authenticated users)

View File

@@ -65,13 +65,25 @@ impl Session {
.await
}
/// Update `last_seen_at`, keyed by the token hash so the auth extractor can touch a
/// session without a separate query to fetch its id first.
pub async fn touch_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE token_hash = $1")
.bind(token_hash)
.execute(pool)
.await?;
/// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so
/// an actively-used session never hits the fixed 30-day cliff (it renews on every
/// authenticated request). An idle session still expires `expiry_days` after its last
/// activity. Keyed by token hash so the auth extractor needs no prior id lookup.
pub async fn touch_and_renew(
pool: &PgPool,
token_hash: &str,
expiry_days: i64,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE session
SET last_seen_at = NOW(),
expires_at = NOW() + ($2 || ' days')::interval
WHERE token_hash = $1",
)
.bind(token_hash)
.bind(expiry_days.to_string())
.execute(pool)
.await?;
Ok(())
}
@@ -85,4 +97,14 @@ impl Session {
.await?;
Ok(())
}
/// Revoke every session for a user. Backs "sign out everywhere" and the forced
/// re-auth after a host PIN-reset or a ban. Returns the number of sessions cleared.
pub async fn delete_all_for_user(pool: &PgPool, user_id: Uuid) -> Result<u64, sqlx::Error> {
let r = sqlx::query("DELETE FROM session WHERE user_id = $1")
.bind(user_id)
.execute(pool)
.await?;
Ok(r.rows_affected())
}
}

View File

@@ -86,9 +86,9 @@ impl Upload {
/// Lean lookup for the public media aliases (`get_original`/`get_preview`/
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
/// excluding soft-deleted rows and ban-hidden owners (`user.uploads_hidden`), the
/// same filter `v_feed` applies. So moderation that removes a post from the feed
/// also stops its original/preview/thumbnail from being pulled by UUID.
/// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
/// (`is_banned`) — the same filter `v_feed` applies. So moderation that removes a post
/// from the feed also stops its original/preview/thumbnail from being pulled by UUID.
///
/// Selects four columns instead of the whole `Upload` row: this runs once per image
/// per cache-miss on the media hot path, so we avoid hydrating fields the response
@@ -101,7 +101,8 @@ impl Upload {
"SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
FROM upload up
JOIN \"user\" u ON u.id = up.user_id
WHERE up.id = $1 AND up.deleted_at IS NULL AND u.uploads_hidden = false",
WHERE up.id = $1 AND up.deleted_at IS NULL
AND u.uploads_hidden = false AND u.is_banned = false",
)
.bind(id)
.fetch_optional(pool)

View File

@@ -82,6 +82,91 @@ struct ViewerMedia {
// ── Entry point ──────────────────────────────────────────────────────────────
/// (Re)enqueue the export jobs for an event and spawn the workers. Safe to call on a
/// fresh release *and* on a re-release: the `export_job` rows are reset to a clean
/// `pending` state (clearing any prior `failed`/`done` from an earlier run) and the
/// event's `export_*_ready` flags are cleared so downloads reflect the regenerating
/// export rather than the stale one. This is the single path used by `release_gallery`
/// and by startup export recovery, so the two can't drift.
pub async fn enqueue_and_spawn_exports(
event_id: Uuid,
event_name: String,
pool: PgPool,
media_path: PathBuf,
export_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) -> Result<()> {
for export_type in ["zip", "html"] {
// Reset a prior 'done'/'failed' row to 'pending' so this (re)release regenerates —
// but DO NOT touch a row that's still 'running'. If a worker from an earlier
// release is mid-export, leaving it 'running' makes the worker we spawn below bail
// its claim (WHERE status='pending' → 0 rows), so the two never race the temp file.
sqlx::query(
"INSERT INTO export_job (event_id, type, status, progress_pct)
VALUES ($1, $2::export_type, 'pending', 0)
ON CONFLICT (event_id, type) DO UPDATE
SET status = 'pending', progress_pct = 0, file_path = NULL,
error_message = NULL, completed_at = NULL
WHERE export_job.status <> 'running'",
)
.bind(event_id)
.bind(export_type)
.execute(&pool)
.await?;
}
// Clear readiness so /export downloads 404 until the fresh run completes, instead of
// serving a stale keepsake that predates a reopen→re-release.
sqlx::query("UPDATE event SET export_zip_ready = FALSE, export_html_ready = FALSE WHERE id = $1")
.bind(event_id)
.execute(&pool)
.await?;
spawn_export_jobs(event_id, event_name, pool, media_path, export_path, sse_tx);
Ok(())
}
/// Startup export recovery: re-spawn exports for any released event whose keepsake is
/// not fully ready (a crash mid-export left `export_released_at` set but the ZIP/HTML
/// jobs `failed`). Without this, `release_gallery` would reject a retry with "bereits
/// freigegeben" and downloads would 404 forever. Runs once at boot, after `AppState`
/// exists (it needs the media/export paths + SSE sender).
pub async fn recover_exports(
pool: PgPool,
media_path: PathBuf,
export_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>,
) {
let rows = match sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, name FROM event
WHERE export_released_at IS NOT NULL
AND NOT (export_zip_ready AND export_html_ready)",
)
.fetch_all(&pool)
.await
{
Ok(r) => r,
Err(e) => {
tracing::error!("export recovery: failed to query released events: {e:#}");
return;
}
};
for (event_id, event_name) in rows {
tracing::warn!("export recovery: re-spawning export jobs for event {event_id}");
if let Err(e) = enqueue_and_spawn_exports(
event_id,
event_name,
pool.clone(),
media_path.clone(),
export_path.clone(),
sse_tx.clone(),
)
.await
{
tracing::error!("export recovery: failed to re-spawn for event {event_id}: {e:#}");
}
}
}
pub fn spawn_export_jobs(
event_id: Uuid,
event_name: String,
@@ -124,7 +209,10 @@ async fn run_zip_export(
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "zip").await;
if !claim_job(pool, event_id, "zip").await {
// Another worker already owns this ZIP export — bail rather than race the temp file.
return Ok(());
}
let uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32;
@@ -217,7 +305,10 @@ async fn run_html_export(
export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> {
mark_running(pool, event_id, "html").await;
if !claim_job(pool, event_id, "html").await {
// Another worker already owns this HTML export — bail rather than race the temp dir.
return Ok(());
}
// 1. Query data
let uploads = query_uploads(pool, event_id).await?;
@@ -511,7 +602,8 @@ async fn query_uploads(pool: &PgPool, event_id: Uuid) -> Result<Vec<ExportUpload
FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id
LEFT JOIN \"like\" l ON l.upload_id = u.id
WHERE u.event_id = $1 AND u.deleted_at IS NULL AND usr.uploads_hidden = FALSE
WHERE u.event_id = $1 AND u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC",
)
@@ -548,14 +640,24 @@ async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, Stri
Ok(rows)
}
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) {
let _ = sqlx::query(
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type",
/// Atomically claim a pending export job for this worker. Returns `true` only if we won
/// (status flipped `pending`→`running` in one statement). Returns `false` when another
/// worker already owns it — e.g. a reopen→re-release spawned a second worker while a run
/// from the first release is still in flight. Both write the SAME fixed temp path
/// (`Gallery.zip.tmp` / `viewer_tmp_*`), so a loser MUST NOT run or it would interleave
/// bytes and corrupt the archive. The row-level lock serializes the two UPDATEs, so
/// exactly one sees `status = 'pending'`.
async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
sqlx::query(
"UPDATE export_job SET status = 'running'
WHERE event_id = $1 AND type = $2::export_type AND status = 'pending'",
)
.bind(event_id)
.bind(export_type)
.execute(pool)
.await;
.await
.map(|r| r.rows_affected() > 0)
.unwrap_or(false)
}
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {

View File

@@ -44,8 +44,11 @@ pub async fn startup_recovery(pool: &PgPool) {
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
}
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
// Export jobs interrupted mid-run are marked 'failed' here so they aren't left
// 'running' forever. The host CANNOT re-trigger a released export (release_gallery
// rejects an already-released event), so `export::recover_exports` re-spawns these
// failed-but-released jobs from `main` once `AppState` exists (it needs the media
// paths + SSE sender this fn doesn't have).
match sqlx::query(
"UPDATE export_job
SET status = 'failed',

View File

@@ -42,7 +42,11 @@ pub struct AppState {
impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self {
let (sse_tx, _) = broadcast::channel(256);
// Broadcast buffer for live SSE fan-out. Sized to absorb a burst (e.g. many
// uploads landing at once during a busy moment) before a slow consumer lags and
// has to `resync`. The resync path is a correctness backstop, not the happy path —
// a roomier buffer keeps ~1000 concurrent clients from all resyncing at once.
let (sse_tx, _) = broadcast::channel(1024);
let compression = CompressionWorker::new(
pool.clone(),
config.media_path.clone(),