fix(user-flow): close offline-queue, export, session, ban & realtime gaps
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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user