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)
}