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

@@ -0,0 +1,25 @@
DROP TABLE IF EXISTS pin_reset_request;
-- Restore v_feed without the is_banned filter.
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.mime_type,
u.caption,
u.created_at,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;

View File

@@ -0,0 +1,39 @@
-- Ban now ALWAYS hides: exclude banned uploaders from the feed view. Previously ban and
-- hide were decoupled (a host could ban without hiding), leaving a banned user's photos on
-- the feed and baked into the export. `find_visible_media` and the export query get the
-- same `is_banned = FALSE` filter in code.
CREATE OR REPLACE VIEW v_feed AS
SELECT
u.id,
u.event_id,
u.user_id,
usr.display_name AS uploader_name,
usr.is_banned,
usr.uploads_hidden,
u.preview_path,
u.thumbnail_path,
u.mime_type,
u.caption,
u.created_at,
COUNT(DISTINCT l.user_id) AS like_count,
COUNT(DISTINCT c.id) AS comment_count
FROM upload u
JOIN "user" usr ON u.user_id = usr.id
LEFT JOIN "like" l ON l.upload_id = u.id
LEFT JOIN comment c ON c.upload_id = u.id AND c.deleted_at IS NULL
WHERE u.deleted_at IS NULL
AND usr.uploads_hidden = FALSE
AND usr.is_banned = FALSE
GROUP BY u.id, usr.display_name, usr.is_banned, usr.uploads_hidden;
-- pin_reset_request: a guest who forgot their PIN (localStorage was the only copy) can ask
-- a host to reset it in-app, instead of being permanently orphaned. One pending request per
-- user; cleared when the host resets the PIN or dismisses it, and cascades if the user/event
-- is removed.
CREATE TABLE pin_reset_request (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES event(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id)
);

View File

@@ -83,7 +83,19 @@ pub async fn join(
let pin_hash = let pin_hash =
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; 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( let token = jwt::create_token(
user.id, user.id,
@@ -359,3 +371,77 @@ pub async fn logout(
Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?; Session::delete_by_token_hash(&state.pool, &auth.token_hash).await?;
Ok(StatusCode::NO_CONTENT) 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> { 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>( let data = jsonwebtoken::decode::<Claims>(
token, token,
&DecodingKey::from_secret(secret.as_bytes()), &DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(), &validation,
)?; )?;
Ok(data.claims) Ok(data.claims)
} }

View File

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

View File

@@ -10,6 +10,10 @@ pub enum AppError {
Conflict(String), Conflict(String),
/// Second field: optional retry-after seconds to include in the response. /// Second field: optional retry-after seconds to include in the response.
TooManyRequests(String, Option<u64>), 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), Internal(anyhow::Error),
} }
@@ -22,6 +26,7 @@ impl AppError {
Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"), Self::NotFound(_) => (StatusCode::NOT_FOUND, "not_found"),
Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"), Self::Conflict(_) => (StatusCode::CONFLICT, "conflict"),
Self::TooManyRequests(..) => (StatusCode::TOO_MANY_REQUESTS, "too_many_requests"), 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"), Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"),
} }
} }
@@ -34,6 +39,7 @@ impl AppError {
| Self::NotFound(msg) | Self::NotFound(msg)
| Self::Conflict(msg) => msg.clone(), | Self::Conflict(msg) => msg.clone(),
Self::TooManyRequests(msg, _) => msg.clone(), Self::TooManyRequests(msg, _) => msg.clone(),
Self::QuotaExceeded(msg) => msg.clone(),
Self::Internal(err) => { Self::Internal(err) => {
tracing::error!("internal error: {err:#}"); tracing::error!("internal error: {err:#}");
"Ein interner Fehler ist aufgetreten.".to_string() "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 /// rather than merging (the older missed uploads are absent and unrecoverable
/// via a later delta, which advances `since` past them). /// via a later delta, which advances `since` past them).
pub truncated: bool, 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( pub async fn feed_delta(
@@ -193,6 +198,33 @@ pub async fn feed_delta(
auth: AuthUser, auth: AuthUser,
Query(q): Query<DeltaQuery>, Query(q): Query<DeltaQuery>,
) -> Result<Json<DeltaResponse>, AppError> { ) -> 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 // 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 // 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. // fall back to a full feed refresh rather than another delta.
@@ -254,6 +286,7 @@ pub async fn feed_delta(
uploads, uploads,
deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(), deleted_ids: deleted_ids.into_iter().map(|r| r.0).collect(),
truncated, truncated,
server_time,
})) }))
} }

View File

@@ -9,6 +9,7 @@ use crate::auth::middleware::RequireHost;
use crate::error::AppError; use crate::error::AppError;
use crate::models::comment::Comment; use crate::models::comment::Comment;
use crate::models::event::Event; use crate::models::event::Event;
use crate::models::session::Session;
use crate::models::upload::Upload; use crate::models::upload::Upload;
use crate::models::user::UserRole; use crate::models::user::UserRole;
use crate::state::{AppState, SseEvent}; use crate::state::{AppState, SseEvent};
@@ -35,11 +36,27 @@ pub struct EventStatus {
pub export_released: bool, pub export_released: bool,
} }
#[derive(Deserialize)] /// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
pub struct BanRequest { /// who would remain if `excluding` were demoted or banned. Used to enforce the "an event
pub hide_uploads: bool, /// 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)] #[derive(Deserialize)]
pub struct SetRoleRequest { pub struct SetRoleRequest {
pub role: String, pub role: String,
@@ -93,8 +110,8 @@ pub async fn ban_user(
State(state): State<AppState>, State(state): State<AppState>,
RequireHost(auth): RequireHost, RequireHost(auth): RequireHost,
Path(user_id): Path<Uuid>, Path(user_id): Path<Uuid>,
Json(body): Json<BanRequest>,
) -> Result<StatusCode, AppError> { ) -> Result<StatusCode, AppError> {
// The ban request carries no body — ban always hides (no per-request options).
// Cannot ban yourself or another host/admin // Cannot ban yourself or another host/admin
if user_id == auth.user_id { if user_id == auth.user_id {
return Err(AppError::BadRequest("Du kannst dich nicht selbst sperren.".into())); 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())); 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( 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(user_id)
.bind(body.hide_uploads)
.bind(auth.event_id) .bind(auth.event_id)
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
// If we hid their uploads, evict them live from every feed + the diashow so a // Evict their content live from every feed + the diashow so it disappears without
// banned guest's content disappears without each viewer having to reload. // each viewer having to reload. (Their own SSE stream is separately dropped by the
if body.hide_uploads { // is_banned revalidation in `sse::stream`, so they stop receiving live pushes while
let _ = state.sse_tx.send(SseEvent::new( // retaining plain read access.)
"user-hidden", let _ = state.sse_tx.send(SseEvent::new(
serde_json::json!({ "user_id": user_id }).to_string(), "user-hidden",
)); serde_json::json!({ "user_id": user_id }).to_string(),
} ));
tracing::info!( tracing::info!(
actor_user_id = %auth.user_id, actor_user_id = %auth.user_id,
target_user_id = %user_id, target_user_id = %user_id,
event_id = %auth.event_id, event_id = %auth.event_id,
hide_uploads = body.hide_uploads,
"host: ban_user" "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( 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(user_id)
.bind(auth.event_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") sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
.bind(user_id) .bind(user_id)
.bind(new_role) .bind(new_role)
@@ -308,6 +355,16 @@ pub async fn reset_user_pin(
.execute(&state.pool) .execute(&state.pool)
.await?; .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 // 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. // their cached local PIN. They'll save the new one on the next /recover.
let _ = state.sse_tx.send(SseEvent::new( let _ = state.sse_tx.send(SseEvent::new(
@@ -325,6 +382,48 @@ pub async fn reset_user_pin(
Ok(Json(PinResetResponse { 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( pub async fn host_delete_upload(
State(state): State<AppState>, State(state): State<AppState>,
RequireHost(auth): RequireHost, RequireHost(auth): RequireHost,
@@ -401,8 +500,17 @@ pub async fn open_event(
State(state): State<AppState>, State(state): State<AppState>,
RequireHost(_auth): RequireHost, RequireHost(_auth): RequireHost,
) -> Result<StatusCode, AppError> { ) -> 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( 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) .bind(&state.config.event_slug)
.execute(&state.pool) .execute(&state.pool)
@@ -422,8 +530,15 @@ pub async fn release_gallery(
// Atomic claim: the conditional UPDATE is the sole gate, so two concurrent // 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. // release calls can't both pass a check-then-set and double-enqueue exports.
// rows_affected == 0 means someone already released. // 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( 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", WHERE slug = $1 AND export_released_at IS NULL",
) )
.bind(&state.config.event_slug) .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. // 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) let event = Event::find_by_slug(&state.pool, &state.config.event_slug)
.await? .await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
// Enqueue export jobs // Enqueue + spawn via the shared path so a re-release (after reopen) regenerates
for export_type in ["zip", "html"] { // cleanly and startup recovery uses identical logic.
sqlx::query( crate::services::export::enqueue_and_spawn_exports(
"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(
event.id, event.id,
event.name, event.name,
state.pool.clone(), state.pool.clone(),
state.config.media_path.clone(), state.config.media_path.clone(),
state.config.export_path.clone(), state.config.export_path.clone(),
state.sse_tx.clone(), state.sse_tx.clone(),
); )
.await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -55,6 +55,12 @@ pub struct MeContextDto {
pub privacy_note: String, pub privacy_note: String,
pub quota_enabled: bool, pub quota_enabled: bool,
pub storage_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( 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 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 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 { Ok(Json(MeContextDto {
user_id: user.id, user_id: user.id,
display_name: user.display_name, display_name: user.display_name,
@@ -76,5 +87,7 @@ pub async fn get_context(
privacy_note, privacy_note,
quota_enabled, quota_enabled,
storage_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::http::StatusCode;
use axum::Json; use axum::Json;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Deserialize; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::auth::middleware::AuthUser; use crate::auth::middleware::AuthUser;
@@ -12,11 +12,22 @@ use crate::models::hashtag::{self, Hashtag};
use crate::models::upload::Upload; use crate::models::upload::Upload;
use crate::state::AppState; 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( pub async fn toggle_like(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
Path(upload_id): Path<Uuid>, Path(upload_id): Path<Uuid>,
) -> Result<StatusCode, AppError> { ) -> Result<Json<LikeResponse>, AppError> {
// Check if user is banned // Check if user is banned
let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id) let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await? .await?
@@ -35,7 +46,7 @@ pub async fn toggle_like(
// ("Event schließen") freezes *new uploads* only — likes, comments and // ("Event schließen") freezes *new uploads* only — likes, comments and
// browsing stay open (USER_JOURNEYS §9.3, FEATURES capability matrix). // 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( let result = sqlx::query(
"INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2) "INSERT INTO \"like\" (upload_id, user_id) VALUES ($1, $2)
ON CONFLICT (upload_id, user_id) DO NOTHING", ON CONFLICT (upload_id, user_id) DO NOTHING",
@@ -45,7 +56,8 @@ pub async fn toggle_like(
.execute(&state.pool) .execute(&state.pool)
.await?; .await?;
if result.rows_affected() == 0 { let liked = result.rows_affected() > 0;
if !liked {
// Already liked — remove // Already liked — remove
sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2") sqlx::query("DELETE FROM \"like\" WHERE upload_id = $1 AND user_id = $2")
.bind(upload_id) .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 // 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 // refetching page 1 (mirrors v_feed.like_count = COUNT(DISTINCT user_id)). The like
// count + broadcast are a UI optimisation — the like itself is already committed, // itself is already committed, so a failed count must not fail the request — but we
// so a failure here must not fail the request. Swallow the error and skip the // also must NOT broadcast/return a bogus 0 (that would push like_count: 0 to every
// broadcast; the next event or a pull-to-refresh reconciles the count. // client until the next event). On error we skip the broadcast and return null.
if let Ok(like_count) = sqlx::query_scalar::<_, i64>( let like_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1", "SELECT COUNT(DISTINCT user_id) FROM \"like\" WHERE upload_id = $1",
) )
.bind(upload_id) .bind(upload_id)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await .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 { let _ = state.sse_tx.send(crate::state::SseEvent {
event_type: "like-update".to_string(), 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)] #[derive(Deserialize, Default)]

View File

@@ -23,6 +23,10 @@ pub struct SseQuery {
#[derive(Serialize)] #[derive(Serialize)]
pub struct StreamTicketResponse { pub struct StreamTicketResponse {
pub ticket: String, 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 /// 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( pub async fn issue_ticket(
State(state): State<AppState>, State(state): State<AppState>,
auth: AuthUser, auth: AuthUser,
) -> Json<StreamTicketResponse> { ) -> Result<Json<StreamTicketResponse>, AppError> {
let ticket = state.sse_tickets.issue(auth.token_hash); 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 /// 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 // 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 // logged-out, expired, OR banned session's stream is closed rather than kept alive
// client happens to disconnect. Bounds a stale stream to ~60s. Only a *definitively* // until the client happens to disconnect. Bounds a stale stream to ~60s. Banning
// gone/expired session ends the stream; a transient DB error just retries next tick. // 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 pool = state.pool.clone();
let session_hash = token_hash.clone(); let session_hash = token_hash.clone();
let session_gone = async move { let session_gone = async move {
@@ -84,9 +94,10 @@ pub async fn stream(
ticker.tick().await; // consume the immediate first tick ticker.tick().await; // consume the immediate first tick
loop { loop {
ticker.tick().await; ticker.tick().await;
match Session::find_by_token_hash(&pool, &session_hash).await { match Session::find_user_by_token_hash(&pool, &session_hash).await {
Ok(Some(_)) => {} // still valid — keep streaming Ok(Some(user)) if !user.is_banned => {} // still valid — keep streaming
Ok(None) => break, // logged out or expired — stop Ok(Some(_)) => break, // banned — cut the stream
Ok(None) => break, // logged out or expired — stop
Err(e) => { Err(e) => {
tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying"); tracing::warn!(error = ?e, "SSE session revalidation query failed; retrying");
} }

View File

@@ -77,6 +77,13 @@ pub async fn upload(
drain_multipart(multipart).await; drain_multipart(multipart).await;
return Err(AppError::Forbidden("Uploads sind gesperrt.".into())); 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 // Read config limits from DB
let max_image_mb: i64 = config::get_i64(&state.config_cache, "max_image_size_mb", 20).await; 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. // disable it on trusted instances.
let quota_on = config::get_bool(&state.config_cache, "quota_enabled", true).await; 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; 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 { if quota_on && storage_quota_on {
let estimate = compute_storage_quota(&state).await; let estimate = compute_storage_quota(&state).await;
if let Some(limit) = estimate.limit_bytes { if let Some(limit) = estimate.limit_bytes {
quota_limit = Some(limit);
let prospective_total = user.total_upload_bytes.saturating_add(size); let prospective_total = user.total_upload_bytes.saturating_add(size);
if prospective_total > limit { if prospective_total > limit {
let _ = tokio::fs::remove_file(&temp_abs).await; 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(), "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). // bytes with no row to reclaim them (silent quota erosion / spurious lockout).
let tx_result: Result<Upload, AppError> = async { let tx_result: Result<Upload, AppError> = async {
let mut tx = state.pool.begin().await?; 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(auth.user_id)
.bind(size) .bind(size)
.bind(limit)
.execute(&mut *tx) .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( let upload = Upload::create(
&mut *tx, &mut *tx,
auth.event_id, auth.event_id,

View File

@@ -44,6 +44,18 @@ async fn main() -> Result<()> {
let state = AppState::new(pool.clone(), config.clone()); 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 // Hourly background hygiene: prune expired sessions, evict cold rate-limiter
// keys. Keeps the DB and process from growing unboundedly over multi-day events. // keys. Keeps the DB and process from growing unboundedly over multi-day events.
services::maintenance::spawn_periodic_tasks( 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/event", get(handlers::public::get_public_event))
.route("/api/v1/join", post(auth::handlers::join)) .route("/api/v1/join", post(auth::handlers::join))
.route("/api/v1/recover", post(auth::handlers::recover)) .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/admin/login", post(auth::handlers::admin_login))
.route("/api/v1/session", delete(auth::handlers::logout)) .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 // 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 // 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 // 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", "/api/v1/host/users/{id}/pin-reset",
post(handlers::host::reset_user_pin), 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/upload/{id}", delete(handlers::host::host_delete_upload))
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment)) .route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
// Export (all authenticated users) // Export (all authenticated users)

View File

@@ -65,13 +65,25 @@ impl Session {
.await .await
} }
/// Update `last_seen_at`, keyed by the token hash so the auth extractor can touch a /// Touch `last_seen_at` AND slide `expires_at` forward by `expiry_days` from now, so
/// session without a separate query to fetch its id first. /// an actively-used session never hits the fixed 30-day cliff (it renews on every
pub async fn touch_by_token_hash(pool: &PgPool, token_hash: &str) -> Result<(), sqlx::Error> { /// authenticated request). An idle session still expires `expiry_days` after its last
sqlx::query("UPDATE session SET last_seen_at = NOW() WHERE token_hash = $1") /// activity. Keyed by token hash so the auth extractor needs no prior id lookup.
.bind(token_hash) pub async fn touch_and_renew(
.execute(pool) pool: &PgPool,
.await?; 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(()) Ok(())
} }
@@ -85,4 +97,14 @@ impl Session {
.await?; .await?;
Ok(()) 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`/ /// Lean lookup for the public media aliases (`get_original`/`get_preview`/
/// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload — /// `get_thumbnail`): returns ONLY the file paths + mime for a visible upload —
/// excluding soft-deleted rows and ban-hidden owners (`user.uploads_hidden`), the /// excluding soft-deleted rows, hidden owners (`uploads_hidden`), and banned owners
/// same filter `v_feed` applies. So moderation that removes a post from the feed /// (`is_banned`) — the same filter `v_feed` applies. So moderation that removes a post
/// also stops its original/preview/thumbnail from being pulled by UUID. /// 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 /// 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 /// 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 "SELECT up.original_path, up.preview_path, up.thumbnail_path, up.mime_type
FROM upload up FROM upload up
JOIN \"user\" u ON u.id = up.user_id 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) .bind(id)
.fetch_optional(pool) .fetch_optional(pool)

View File

@@ -82,6 +82,91 @@ struct ViewerMedia {
// ── Entry point ────────────────────────────────────────────────────────────── // ── 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( pub fn spawn_export_jobs(
event_id: Uuid, event_id: Uuid,
event_name: String, event_name: String,
@@ -124,7 +209,10 @@ async fn run_zip_export(
export_path: &Path, export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>, sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> { ) -> 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 uploads = query_uploads(pool, event_id).await?;
let total = uploads.len().max(1) as f32; let total = uploads.len().max(1) as f32;
@@ -217,7 +305,10 @@ async fn run_html_export(
export_path: &Path, export_path: &Path,
sse_tx: &broadcast::Sender<SseEvent>, sse_tx: &broadcast::Sender<SseEvent>,
) -> Result<()> { ) -> 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 // 1. Query data
let uploads = query_uploads(pool, event_id).await?; 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 FROM upload u
JOIN \"user\" usr ON usr.id = u.user_id JOIN \"user\" usr ON usr.id = u.user_id
LEFT JOIN \"like\" l ON l.upload_id = u.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 GROUP BY u.id, usr.display_name
ORDER BY u.created_at ASC", 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) Ok(rows)
} }
async fn mark_running(pool: &PgPool, event_id: Uuid, export_type: &str) { /// Atomically claim a pending export job for this worker. Returns `true` only if we won
let _ = sqlx::query( /// (status flipped `pending`→`running` in one statement). Returns `false` when another
"UPDATE export_job SET status = 'running' WHERE event_id = $1 AND type = $2::export_type", /// 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(event_id)
.bind(export_type) .bind(export_type)
.execute(pool) .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) { 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:#}"), Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
} }
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger. // Export jobs interrupted mid-run are marked 'failed' here so they aren't left
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release. // '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( match sqlx::query(
"UPDATE export_job "UPDATE export_job
SET status = 'failed', SET status = 'failed',

View File

@@ -42,7 +42,11 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn new(pool: PgPool, config: AppConfig) -> Self { 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( let compression = CompressionWorker::new(
pool.clone(), pool.clone(),
config.media_path.clone(), config.media_path.clone(),

View File

@@ -129,9 +129,9 @@ the Host can clean up later).
viewer). Progress is visible in the Admin dashboard's Export tab; SSE viewer). Progress is visible in the Admin dashboard's Export tab; SSE
`export-progress` keeps it live; `export-available` notifies all guests when ready. `export-progress` keeps it live; `export-available` notifies all guests when ready.
5. **Nutzerverwaltung** — search users; per-user controls: 5. **Nutzerverwaltung** — search users; per-user controls:
- **Sperren** opens a confirmation modal with a checkbox "Uploads aus der Galerie - **Sperren** opens a confirmation modal. Banning **always hides** the user's existing
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them uploads (a banned user's content is "gone" everywhere) — there is no opt-out. Submitting
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`. calls `POST /host/users/{id}/ban` (no body).
- **Entsperren** lifts the ban. - **Entsperren** lifts the ban.
- **Host** promotes a guest to host. - **Host** promotes a guest to host.
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to - **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
@@ -154,11 +154,15 @@ the Host can clean up later).
("Du bist gesperrt."). Ban enforcement lives on the write handlers and the ("Du bist gesperrt."). Ban enforcement lives on the write handlers and the
host/admin extractors, not the base auth extractor. host/admin extractors, not the base auth extractor.
3. They can still browse the read-only feed (and download the export once it's released). 3. They can still browse the read-only feed (and download the export once it's released).
Their sessions are **not** revoked — this is a read-only ban, not a logout. Their own
live SSE stream is dropped by the `is_banned` revalidation (no live pushes), but plain
feed reads still work.
4. They cannot upload, like, or comment; a banned host also loses all host/admin 4. They cannot upload, like, or comment; a banned host also loses all host/admin
actions (including unbanning themselves). actions (including unbanning themselves).
5. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the 5. Banning **always hides**: the user's existing uploads are filtered out of the feed for
feed for everyone (`v_feed` enforces this), and a live `user-hidden` SSE event evicts everyone (`v_feed`, `find_visible_media`, and the export query all enforce
their cards from every open feed + the diashow without a reload. `is_banned = FALSE`), and a live `user-hidden` SSE event evicts their cards from every
open feed + the diashow without a reload.
## 11. Admin — instance configuration ## 11. Admin — instance configuration

View File

@@ -31,21 +31,21 @@ test.describe('Feed — like + comment', () => {
expect(row.liked_by_me).toBe(false); expect(row.liked_by_me).toBe(false);
// First like → counted exactly once. // First like → counted exactly once.
expect(await like(liker.jwt, uploadId)).toBe(204); expect(await like(liker.jwt, uploadId)).toBe(200);
row = findFeedRow(await api.getFeed(liker.jwt), uploadId); row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(1); expect(row.like_count).toBe(1);
expect(row.liked_by_me).toBe(true); expect(row.liked_by_me).toBe(true);
// Liking again is a toggle → back to zero (guards against a regression that // Liking again is a toggle → back to zero (guards against a regression that
// double-counts a repeated like instead of removing it). // double-counts a repeated like instead of removing it).
expect(await like(liker.jwt, uploadId)).toBe(204); expect(await like(liker.jwt, uploadId)).toBe(200);
row = findFeedRow(await api.getFeed(liker.jwt), uploadId); row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(0); expect(row.like_count).toBe(0);
expect(row.liked_by_me).toBe(false); expect(row.liked_by_me).toBe(false);
// A second distinct user's like is counted independently (per-user semantics). // A second distinct user's like is counted independently (per-user semantics).
expect(await like(liker.jwt, uploadId)).toBe(204); // liker likes again → 1 expect(await like(liker.jwt, uploadId)).toBe(200); // liker likes again → 1
expect(await like(author.jwt, uploadId)).toBe(204); // author likes too → 2 expect(await like(author.jwt, uploadId)).toBe(200); // author likes too → 2
row = findFeedRow(await api.getFeed(liker.jwt), uploadId); row = findFeedRow(await api.getFeed(liker.jwt), uploadId);
expect(row.like_count).toBe(2); expect(row.like_count).toBe(2);
}); });

View File

@@ -61,7 +61,7 @@ test.describe('Host — event lock', () => {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${g.jwt}` }, headers: { Authorization: `Bearer ${g.jwt}` },
}); });
expect(likeRes.status).toBe(204); expect(likeRes.status).toBe(200);
// Comments stay open on a locked event. // Comments stay open on a locked event.
const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, { const commentRes = await fetch(`${BASE}/api/v1/upload/${id}/comments`, {

View File

@@ -16,13 +16,15 @@ test.describe('Host — moderation API', () => {
expect(row?.uploads_hidden).toBe(true); expect(row?.uploads_hidden).toBe(true);
}); });
test('ban without hiding leaves uploads_hidden=false', async ({ api, host, guest }) => { test('ban always hides — the hide_uploads flag is ignored (USER_JOURNEYS §10.5)', async ({ api, host, guest }) => {
// Ban is now unconditionally a hide: even asking NOT to hide still hides, because a
// banned user's content is "gone" everywhere. The legacy hide_uploads arg is ignored.
const target = await guest('Banned2'); const target = await guest('Banned2');
await api.banUser(host.jwt, target.userId, false); await api.banUser(host.jwt, target.userId, false);
const users = await api.listUsers(host.jwt); const users = await api.listUsers(host.jwt);
const row = users.find((u: any) => u.id === target.userId); const row = users.find((u: any) => u.id === target.userId);
expect(row?.is_banned).toBe(true); expect(row?.is_banned).toBe(true);
expect(row?.uploads_hidden).toBe(false); expect(row?.uploads_hidden).toBe(true);
}); });
test('banned user cannot call /upload', async ({ api, host, guest }) => { test('banned user cannot call /upload', async ({ api, host, guest }) => {
@@ -102,7 +104,7 @@ test.describe('Host — live role/ban revocation (H1)', () => {
// H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is // H1 / USER_JOURNEYS §10: a banned user keeps *read* access but every write is
// rejected with 403. The ban is enforced on write handlers + Require{Host,Admin}, // rejected with 403. The ban is enforced on write handlers + Require{Host,Admin},
// NOT in the base extractor (which would wrongly block reads too). // NOT in the base extractor (which would wrongly block reads too).
test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest }) => { test('a banned user keeps read access but is blocked from writes', async ({ api, host, guest, db }) => {
const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101'; const base = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const target = await guest('BannedRW'); const target = await guest('BannedRW');
const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` }); const auth = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
@@ -147,10 +149,11 @@ test.describe('Host — live role/ban revocation (H1)', () => {
}); });
expect(delComment.status).toBe(403); expect(delComment.status).toBe(403);
// The upload survived every blocked write (still fetchable via the feed). // The upload survived every blocked write — the delete was rejected, so the row still
const feed = await fetch(base + '/api/v1/feed', { headers: auth(host.jwt) }); // exists (deleted_at IS NULL). We check the DB directly rather than the feed: ban now
const body = await feed.json(); // ALWAYS hides, so a banned user's own upload is (correctly) filtered out of every feed
const rows = body.uploads ?? body.items ?? body; // even though the row is intact. Read access to *other* content is proven by the 200 on
expect(Array.isArray(rows) && rows.some((u: any) => u.id === ownUpload)).toBe(true); // /me/context above.
expect(await db.countUploadsForUser(target.userId)).toBe(1);
}); });
}); });

View File

@@ -0,0 +1,198 @@
/**
* Re-review regression guard — Chain #2 ("export corruption on reopen→re-release").
*
* Bug that was fixed: `spawn_export_jobs` ran the worker unconditionally and the
* worker wrote a FIXED temp path (`Gallery.zip.tmp` / `viewer_tmp_{event}`).
* The new reopen→re-release flow could therefore spawn a second worker while the
* first was still running — two workers stomping the same temp file → a corrupt
* keepsake ZIP served to guests.
*
* The fix: `claim_job` is an atomic `UPDATE ... WHERE status='pending'`; a worker
* that doesn't win the claim bails without touching the temp file. The re-enqueue
* `ON CONFLICT DO UPDATE ... WHERE status <> 'running'` leaves an in-flight job
* alone. Net: exactly one worker per (event,type).
*
* This test seeds real uploads, releases, then churns reopen→re-release (stressing
* the claim guard), waits for the export to settle, and asserts the produced ZIP is
* INTACT (passes `unzip -t`) with exactly one entry per upload — plus that no
* export_job row is left stuck `running`. A temp-file race would corrupt/truncate
* the archive or drop entries, failing these assertions.
*
* Note on scope: a Dockerised export over small fixtures completes in well under a
* second, so this cannot *deterministically* force two workers to overlap in time.
* It stresses the claim/ON-CONFLICT guard under rapid churn and hard-checks archive
* integrity; the atomic single-worker guarantee itself is additionally proven by
* code review + SQL PREPARE. Together they cover the regression.
*/
import { test, expect } from '../../fixtures/test';
import { Client } from 'pg';
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { seedUpload } from '../../helpers/seed';
const BASE = process.env.E2E_FRONTEND_URL ?? 'http://localhost:3101';
const SLUG = 'e2e-test-event';
const N_UPLOADS = 6;
const PG = {
host: process.env.E2E_DB_HOST ?? 'localhost',
port: Number(process.env.E2E_DB_PORT ?? '55432'),
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
};
async function pgQuery<T = any>(sql: string): Promise<T[]> {
const c = new Client(PG);
await c.connect();
try {
return (await c.query(sql)).rows as T[];
} finally {
await c.end();
}
}
function post(path: string, jwt: string) {
return fetch(BASE + path, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` } });
}
async function exportStatus(jwt: string): Promise<any> {
const res = await fetch(BASE + '/api/v1/export/status', { headers: { Authorization: `Bearer ${jwt}` } });
return res.json();
}
async function mintTicket(jwt: string): Promise<string> {
const res = await post('/api/v1/export/ticket', jwt);
return (await res.json()).ticket;
}
test.describe('Flow re-review — reopen→re-release export integrity (#2)', () => {
// The real export job runs image processing; give it head-room over the tiny fixtures.
test.setTimeout(90_000);
test('rapid release/reopen/re-release yields exactly one INTACT ZIP and no stuck jobs', async ({
host,
}) => {
// Seed real, decodable uploads while the event is open.
for (let i = 0; i < N_UPLOADS; i++) {
await seedUpload(host.jwt, { caption: `pic ${i}` });
}
// First release → export starts and uploads lock (release ⇒ lock).
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Post-release uploads must be rejected (belt-and-suspenders on top of the lock).
const { uploadRaw } = await import('../../helpers/upload-client');
const { readFileSync } = await import('node:fs');
const rejected = await uploadRaw(host.jwt, readFileSync(join(process.cwd(), 'fixtures', 'media', 'sample.jpg')), {
filename: 'late.jpg',
contentType: 'image/jpeg',
});
expect(rejected.status).toBe(403);
// Churn: reopen (clears release + ready flags) then re-release, several times in
// quick succession. This is the path that used to be able to double-spawn workers
// over the shared temp file. End on a release so the gallery is left released.
for (let i = 0; i < 4; i++) {
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
const rel = await post('/api/v1/host/gallery/release', host.jwt);
expect(rel.status).toBe(204);
}
// Wait for the export to settle: both jobs done and the ZIP marked ready.
await expect
.poll(async () => {
const s = await exportStatus(host.jwt);
return s.released === true && s.zip?.status === 'done' && s.html?.status === 'done';
}, { timeout: 60_000, intervals: [500] })
.toBe(true);
// No export_job may be left stuck 'running' (would mean a worker that never
// completed — the failure mode the claim guard exists to avoid).
const jobs = await pgQuery<{ type: string; status: string }>(
`SELECT ej.type::text AS type, ej.status::text AS status
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}'`
);
expect(jobs.length).toBe(2);
for (const j of jobs) expect(j.status, `${j.type} job status`).toBe('done');
// Download the ZIP and prove it is INTACT — a temp-file race would corrupt or
// truncate it. `unzip -t` verifies every entry's CRC; a non-zero exit throws.
const ticket = await mintTicket(host.jwt);
const res = await fetch(BASE + '/api/v1/export/zip?ticket=' + encodeURIComponent(ticket));
expect(res.status).toBe(200);
const bytes = Buffer.from(await res.arrayBuffer());
// ZIP local-file-header magic — a stomped/half-written file fails this immediately.
expect(bytes.subarray(0, 2).toString('latin1')).toBe('PK');
const dir = mkdtempSync(join(tmpdir(), 'es-zip-'));
const zipPath = join(dir, 'Gallery.zip');
writeFileSync(zipPath, bytes);
// Integrity: `unzip -t` exits 0 only if all CRCs check out and the archive is whole.
execFileSync('unzip', ['-t', zipPath], { stdio: 'pipe' });
// Completeness: exactly one entry per seeded upload — no entry lost to a stomped
// temp file, none duplicated by a second worker.
const listing = execFileSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.endsWith('/'));
expect(listing.length, `zip entries:\n${listing.join('\n')}`).toBe(N_UPLOADS);
});
test('re-release does NOT disturb an in-flight (running) export job — the anti-race guard', async ({
host,
}) => {
// Deterministic proof of the fix, independent of export timing. We simulate a worker
// that is still mid-export by pinning the zip job to `running` with a sentinel
// progress value, then drive reopen→re-release. The fix's
// ON CONFLICT ... DO UPDATE ... WHERE export_job.status <> 'running'
// must leave that row untouched (and the freshly-spawned worker's
// claim_job: UPDATE ... WHERE status = 'pending'
// finds nothing to claim, so it bails without racing the temp file).
//
// On the OLD code (unconditional ON CONFLICT DO UPDATE) the row would be reset to
// pending/progress=0 and a second worker would claim and run concurrently — so the
// sentinel below would be wiped. This assertion therefore fails on the bug and passes
// on the fix.
await seedUpload(host.jwt, { caption: 'a' });
await seedUpload(host.jwt, { caption: 'b' });
// Release and let the first export fully finish so no real worker is active.
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
await expect
.poll(async () => {
const s = await exportStatus(host.jwt);
return s.zip?.status === 'done' && s.html?.status === 'done';
}, { timeout: 60_000, intervals: [500] })
.toBe(true);
// Simulate an in-flight worker owning the zip job, with a sentinel we can check.
await pgQuery(
`UPDATE export_job ej SET status = 'running', progress_pct = 77
FROM event e
WHERE e.id = ej.event_id AND e.slug = '${SLUG}' AND ej.type = 'zip'`
);
// Reopen (clears release + ready flags, does NOT touch job rows) then re-release.
expect((await post('/api/v1/host/event/open', host.jwt)).status).toBe(204);
expect((await post('/api/v1/host/gallery/release', host.jwt)).status).toBe(204);
// Give any spawned worker a beat to attempt (and, correctly, bail) its claim.
await new Promise((r) => setTimeout(r, 750));
// The guard must have preserved the 'running' sentinel — untouched by both the
// re-enqueue and the bailing worker.
const [row] = await pgQuery<{ status: string; progress_pct: number }>(
`SELECT ej.status::text AS status, ej.progress_pct
FROM export_job ej JOIN event e ON e.id = ej.event_id
WHERE e.slug = '${SLUG}' AND ej.type = 'zip'`
);
expect(row.status, 'zip job status').toBe('running');
expect(Number(row.progress_pct), 'zip job sentinel progress').toBe(77);
});
});

View File

@@ -0,0 +1,100 @@
/**
* Re-review regression guard — Chain B1 ("offline queue must auto-resume").
*
* Bug that was fixed: a file staged while OFFLINE was attempted immediately;
* the XHR network error marked the queue item `error`, and every resume path
* (the `online` listener, `processQueue`, `loadQueue`) only picks up `pending`
* items — so the item was stranded until a manual "Erneut" tap.
*
* The fix: network failures are a distinct `NetworkError` that keeps the item
* `pending`; `processQueue` bails while `navigator.onLine === false` (so an
* offline-staged item is never burned to `error`); and `requeueRetriable()`
* flips transient `error`→`pending` on the `online` event and on mount.
*
* This test drives a REAL browser going offline → stage+submit → reconnect and
* asserts the upload lands with NO manual interaction. It fails on the old code
* (item errors, never resumes) and passes on the fix.
*/
import { test, expect } from '../../fixtures/test';
import { FeedPage, UploadSheet } from '../../page-objects';
import { join } from 'node:path';
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
// White-box: read the upload queue straight out of IndexedDB so we can assert
// the item's status without depending on the (currently unrendered) queue widget.
async function queueStatuses(page: import('@playwright/test').Page): Promise<string[]> {
return page.evaluate(async () => {
const statuses = await new Promise<string[]>((resolve, reject) => {
const req = indexedDB.open('eventsnap-uploads', 3);
req.onerror = () => reject(req.error);
req.onsuccess = () => {
const dbh = req.result;
const tx = dbh.transaction('queue', 'readonly');
const all = tx.objectStore('queue').getAll();
all.onsuccess = () => resolve(all.result.map((r: any) => r.status));
all.onerror = () => reject(all.error);
};
});
return statuses;
});
}
test.describe('Flow re-review — offline upload auto-resume (B1)', () => {
test('offline-staged upload stays pending, then flushes on reconnect with no manual tap', async ({
page,
guest,
signIn,
db,
}) => {
const g = await guest('OfflineResume');
await signIn(page, g);
await page.goto('/feed');
expect(await db.countUploadsForUser(g.userId)).toBe(0);
// Warm the code-split `/upload` route chunk while still ONLINE. Without this, the
// client-side navigation the UploadSheet triggers would try to fetch the chunk with
// no connectivity and hit SvelteKit's error boundary — a test artifact unrelated to
// the upload-queue fix under test. (A production PWA service worker would cache it.)
await page.goto('/upload');
await page.goto('/feed');
// Go offline BEFORE staging so the first attempt happens with no connectivity.
await page.context().setOffline(true);
const feed = new FeedPage(page);
const sheet = new UploadSheet(page);
await feed.openUploadSheet();
await sheet.stageFiles([SAMPLE_JPG]);
await sheet.captionInput.waitFor({ state: 'visible', timeout: 10_000 });
await sheet.fillCaption('shot with no signal');
await sheet.submit();
// handleSubmit navigates to /feed after enqueuing even while offline.
await page.waitForURL('**/feed', { timeout: 15_000 });
// While offline: nothing may have reached the server, and — the crux of the
// fix — the item must be parked as `pending`, NOT burned to `error`/`blocked`
// (which on the old code would require a manual retry tap).
await expect
.poll(() => queueStatuses(page), { timeout: 8_000 })
.toEqual(['pending']);
expect(await db.countUploadsForUser(g.userId)).toBe(0);
// Reconnect. The `online` listener must resume the queue automatically — we do
// NOT navigate, tap retry, or otherwise touch the page.
await page.context().setOffline(false);
// The queued upload is now created server-side without any manual interaction.
await expect
.poll(() => db.countUploadsForUser(g.userId), { timeout: 20_000 })
.toBe(1);
// And the queue item ends terminal-good (done) or is drained — never stuck in error.
await expect
.poll(async () => {
const s = await queueStatuses(page);
return s.every((x) => x === 'done') || s.length === 0;
}, { timeout: 10_000 })
.toBe(true);
});
});

View File

@@ -58,9 +58,21 @@
} catch { /* ignore */ } } catch { /* ignore */ }
}); });
// Pull in a comment posted from another device on the CURRENTLY-open upload, so the
// open list matches the count. The `new-comment` broadcast carries only the count (not
// the body), so refetch — skipped when it's for a different upload, and the dedupe
// below makes our own just-posted optimistic comment a no-op.
const unsubNewComment = onSseEvent('new-comment', (data) => {
try {
const { upload_id } = JSON.parse(data) as { upload_id: string };
if (upload_id === upload.id) void loadComments(upload.id);
} catch { /* ignore */ }
});
onDestroy(() => { onDestroy(() => {
if (burstTimer) clearTimeout(burstTimer); if (burstTimer) clearTimeout(burstTimer);
unsubCommentDeleted(); unsubCommentDeleted();
unsubNewComment();
}); });
// Only refetch when a *different* upload is shown. The feed reassigns the // Only refetch when a *different* upload is shown. The feed reassigns the

View File

@@ -14,6 +14,7 @@
case 'uploading': return 'Wird hochgeladen'; case 'uploading': return 'Wird hochgeladen';
case 'done': return 'Fertig'; case 'done': return 'Fertig';
case 'error': return 'Fehler'; case 'error': return 'Fehler';
case 'blocked': return 'Gesperrt';
} }
} }
@@ -23,6 +24,7 @@
case 'uploading': return 'text-blue-600 dark:text-blue-400'; case 'uploading': return 'text-blue-600 dark:text-blue-400';
case 'done': return 'text-green-600 dark:text-green-400'; case 'done': return 'text-green-600 dark:text-green-400';
case 'error': return 'text-red-600 dark:text-red-400'; case 'error': return 'text-red-600 dark:text-red-400';
case 'blocked': return 'text-red-600 dark:text-red-400';
} }
} }
@@ -94,7 +96,7 @@
Erneut Erneut
</button> </button>
{/if} {/if}
{#if item.status === 'done' || item.status === 'error'} {#if item.status === 'done' || item.status === 'error' || item.status === 'blocked'}
<button <button
onclick={() => removeItem(item.id)} onclick={() => removeItem(item.id)}
class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300" class="inline-flex h-9 w-9 items-center justify-center text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"

View File

@@ -5,6 +5,11 @@
import { scrollLock } from '$lib/actions/scroll-lock'; import { scrollLock } from '$lib/actions/scroll-lock';
import CameraCapture from '$lib/components/CameraCapture.svelte'; import CameraCapture from '$lib/components/CameraCapture.svelte';
import type { PendingFile } from '$lib/pending-upload-store'; import type { PendingFile } from '$lib/pending-upload-store';
import { eventState, uploadsClosed } from '$lib/event-state-store';
// Uploads closed (event locked or gallery released) — show a lock notice instead of
// the capture options, so a guest can't stage a photo that would just be rejected.
let closed = $derived(uploadsClosed($eventState));
let showCamera = $state(false); let showCamera = $state(false);
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
@@ -149,6 +154,22 @@
</div> </div>
<div class="space-y-3 px-4 pb-4 pt-2"> <div class="space-y-3 px-4 pb-4 pt-2">
{#if closed}
<!-- Uploads closed: no capture options, just an explanation + dismiss. -->
<div class="rounded-xl bg-amber-50 px-5 py-4 text-center dark:bg-amber-950/30">
<p class="font-semibold text-amber-800 dark:text-amber-300">Uploads geschlossen</p>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-400">
Der Host hat die Uploads für dieses Event beendet. Du kannst weiterhin Fotos ansehen,
liken und kommentieren.
</p>
</div>
<button
onclick={close}
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
Schließen
</button>
{:else}
<!-- Gallery option --> <!-- Gallery option -->
<button <button
onclick={openGallery} onclick={openGallery}
@@ -189,5 +210,6 @@
> >
Abbrechen Abbrechen
</button> </button>
{/if}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,44 @@
import { writable } from 'svelte/store';
import { api } from './api';
import type { MeContextDto } from './types';
/**
* Live event lock/release state, so the composer can reflect a close/reopen the *instant*
* it happens instead of a guest discovering the lock via a rejected upload. Seeded from
* `/me/context` on boot and kept current by the `event-closed`/`event-opened` SSE events
* (see the root layout, which subscribes once).
*/
export interface EventState {
uploadsLocked: boolean;
galleryReleased: boolean;
}
export const eventState = writable<EventState>({ uploadsLocked: false, galleryReleased: false });
/** True when uploads are closed for any reason (event locked or gallery released). */
export function uploadsClosed(s: EventState): boolean {
return s.uploadsLocked || s.galleryReleased;
}
/** Refresh from the server. Non-fatal on failure — the next SSE event reconciles. */
export async function refreshEventState(): Promise<void> {
try {
const ctx = await api.get<MeContextDto>('/me/context');
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
} catch {
// non-fatal
}
}
/** Apply an `event-closed` SSE event (uploads just locked). */
export function markClosed(): void {
eventState.update((s) => ({ ...s, uploadsLocked: true }));
}
/** Apply an `event-opened` SSE event (uploads reopened → release also cleared). */
export function markOpened(): void {
eventState.set({ uploadsLocked: false, galleryReleased: false });
}

View File

@@ -15,7 +15,7 @@ import { getToken } from './auth';
import { api } from './api'; import { api } from './api';
import type { DeltaResponse } from './types'; import type { DeltaResponse } from './types';
type StreamTicketResponse = { ticket: string }; type StreamTicketResponse = { ticket: string; server_time: string };
type EventHandler = (data: string) => void; type EventHandler = (data: string) => void;
@@ -78,9 +78,11 @@ export function connectSse(): void {
// pass that on the URL. The JWT itself never appears in URLs / access logs. // pass that on the URL. The JWT itself never appears in URLs / access logs.
void (async () => { void (async () => {
let ticket: string; let ticket: string;
let serverTime: string;
try { try {
const res = await api.post<StreamTicketResponse>('/stream/ticket', {}); const res = await api.post<StreamTicketResponse>('/stream/ticket', {});
ticket = res.ticket; ticket = res.ticket;
serverTime = res.server_time;
} catch { } catch {
// Failed to mint a ticket (auth lapse, network blip). Back off and retry // Failed to mint a ticket (auth lapse, network blip). Back off and retry
// via the existing error path. // via the existing error path.
@@ -95,12 +97,17 @@ export function connectSse(): void {
eventSource.onopen = () => { eventSource.onopen = () => {
// Successful connection — reset the backoff counter. // Successful connection — reset the backoff counter.
reconnectAttempt = 0; reconnectAttempt = 0;
// If we have a previous timestamp this is a reconnect — fetch the gap. // If we have a previous timestamp this is a reconnect — fetch the gap. The
// delta advances `lastEventTime` from the SERVER clock it returns.
const since = lastEventTime; const since = lastEventTime;
if (since) { if (since) {
void deltaFetchAndFan(since); void deltaFetchAndFan(since);
} else {
// First connect: seed the cursor from the server clock at ticket-mint time,
// never `new Date()` — a skewed browser clock would otherwise shift the very
// first reconnect window and could drop uploads.
lastEventTime = serverTime;
} }
lastEventTime = new Date().toISOString();
}; };
for (const eventName of KNOWN_EVENTS) { for (const eventName of KNOWN_EVENTS) {
@@ -159,7 +166,12 @@ export function setLastEventTime(time: string): void {
} }
function dispatch(eventType: string, data: string): void { function dispatch(eventType: string, data: string): void {
lastEventTime = new Date().toISOString(); // Advance the reconnect cursor from the SERVER timestamp carried in the payload (when
// present — e.g. a new upload's `created_at`), never the browser clock. Events without
// a timestamp (likes, lock toggles) leave the cursor where it is; the next delta
// re-fetches from the last content timestamp we saw, which merges idempotently.
const ts = extractCreatedAt(data);
if (ts) lastEventTime = ts;
const list = handlers.get(eventType); const list = handlers.get(eventType);
if (list) { if (list) {
for (const handler of list) { for (const handler of list) {
@@ -168,6 +180,17 @@ function dispatch(eventType: string, data: string): void {
} }
} }
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
function extractCreatedAt(data: string): string | undefined {
try {
const parsed = JSON.parse(data);
if (parsed && typeof parsed.created_at === 'string') return parsed.created_at;
} catch {
// non-JSON payload (e.g. a plain count) — no timestamp to extract
}
return undefined;
}
/** /**
* Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta` * Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta`
* event. Subscribers (typically the feed page) merge the result into their * event. Subscribers (typically the feed page) merge the result into their
@@ -179,6 +202,9 @@ async function deltaFetchAndFan(since: string): Promise<void> {
const response = await api.get<DeltaResponse>( const response = await api.get<DeltaResponse>(
`/feed/delta?since=${encodeURIComponent(since)}` `/feed/delta?since=${encodeURIComponent(since)}`
); );
// Advance the cursor to the server clock this delta was computed at, so the next
// reconnect resumes exactly where the server left off (no browser-clock skew).
lastEventTime = response.server_time;
dispatch('feed-delta', JSON.stringify(response)); dispatch('feed-delta', JSON.stringify(response));
} catch { } catch {
// non-fatal // non-fatal

View File

@@ -30,6 +30,9 @@ export interface DeltaResponse {
// True when the delta hit the backend cap and is only the newest slice of the // True when the delta hit the backend cap and is only the newest slice of the
// gap — the client must full-refresh rather than merge (see feed-delta handler). // gap — the client must full-refresh rather than merge (see feed-delta handler).
truncated: boolean; truncated: boolean;
// Server clock when this delta was computed — the next reconnect cursor advances
// from THIS, never the browser clock, so clock skew can't silently drop uploads.
server_time: string;
} }
// mirrors backend/src/handlers/feed.rs::HashtagCount // mirrors backend/src/handlers/feed.rs::HashtagCount
@@ -56,6 +59,8 @@ export interface MeContextDto {
privacy_note: string; privacy_note: string;
quota_enabled: boolean; quota_enabled: boolean;
storage_quota_enabled: boolean; storage_quota_enabled: boolean;
uploads_locked: boolean;
gallery_released: boolean;
} }
// mirrors backend/src/handlers/host.rs::PinResetResponse // mirrors backend/src/handlers/host.rs::PinResetResponse

View File

@@ -11,7 +11,10 @@ export interface QueueItem {
mimeType: string; mimeType: string;
caption: string; caption: string;
hashtags: string; hashtags: string;
status: 'pending' | 'uploading' | 'done' | 'error'; // 'error' is retryable (network / 5xx); 'blocked' is TERMINAL (a 4xx the server will
// keep rejecting — locked event, banned user, released gallery, quota full). Blocked
// items have had their blob purged from IndexedDB and offer no retry.
status: 'pending' | 'uploading' | 'done' | 'error' | 'blocked';
progress: number; progress: number;
error?: string; error?: string;
} }
@@ -26,8 +29,50 @@ export const rateLimitRetryAt = writable<number | null>(null);
const DB_NAME = 'eventsnap-uploads'; const DB_NAME = 'eventsnap-uploads';
const STORE_NAME = 'queue'; const STORE_NAME = 'queue';
/** Hard cap on queued items per device — bounds IndexedDB growth from stuck blobs. */
const MAX_QUEUE_ITEMS = 100;
let db: IDBPDatabase | null = null; let db: IDBPDatabase | null = null;
// Resume the queue as soon as connectivity returns. Registered once, guarded for SSR.
// This is the other half of the "flushes when you're back online" promise — without it
// a reconnect only resumes if the user manually re-stages a file.
let onlineBound = false;
function bindOnline(): void {
if (onlineBound || typeof window === 'undefined') return;
window.addEventListener('online', () => {
void (async () => {
await requeueRetriable();
await processQueue();
})();
});
onlineBound = true;
}
bindOnline();
/**
* Flip transient `error` items (5xx / a network drop that got marked before we could
* reclassify it) back to `pending` so a resume actually retries them. Terminal `blocked`
* items (403/413) are left alone — retrying those never succeeds.
*/
async function requeueRetriable(): Promise<void> {
const database = await getDb();
const myUserId = getUserId();
const all = await database.getAll(STORE_NAME);
for (const entry of all) {
if (entry.userId === myUserId && entry.status === 'error' && entry.blob) {
entry.status = 'pending';
entry.error = undefined;
await database.put(STORE_NAME, entry);
}
}
queueItems.update((items) =>
items.map((item) =>
item.status === 'error' ? { ...item, status: 'pending' as const, progress: 0, error: undefined } : item
)
);
}
async function getDb(): Promise<IDBPDatabase> { async function getDb(): Promise<IDBPDatabase> {
if (db) return db; if (db) return db;
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices. // v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
@@ -76,6 +121,26 @@ class RateLimitError extends Error {
} }
} }
/**
* A permanent, non-retryable failure — the server returned a 4xx (other than 429) that
* will never succeed on retry: uploads locked, user banned, gallery already released, or
* per-user quota full. The item is moved to the terminal `blocked` state and its blob is
* dropped from IndexedDB (no point keeping bytes we'll never send).
*/
class TerminalError extends Error {
constructor(message: string) {
super(message);
}
}
/**
* A connectivity failure — the request never reached the server (offline, DNS, dropped
* connection). The item stays `pending` (not `error`) so the `online` listener and the
* next `processQueue` pick it up automatically. This is what makes "the queue flushes
* when you're back online" actually true for a file staged with no signal.
*/
class NetworkError extends Error {}
export async function loadQueue(): Promise<void> { export async function loadQueue(): Promise<void> {
const database = await getDb(); const database = await getDb();
const myUserId = getUserId(); const myUserId = getUserId();
@@ -98,6 +163,14 @@ export async function loadQueue(): Promise<void> {
error: entry.error error: entry.error
})); }));
queueItems.set(items); queueItems.set(items);
// Staged-but-unsent items from a prior session (queued offline, tab closed before
// reconnect) must resume now — otherwise the "queue flushes when you're back online"
// promise only holds if the user manually re-stages a file. Reclaim transient errors
// (a network drop from a prior session) so they retry instead of stalling.
void (async () => {
await requeueRetriable();
await processQueue();
})();
} }
export async function addToQueue( export async function addToQueue(
@@ -108,6 +181,33 @@ export async function addToQueue(
const database = await getDb(); const database = await getDb();
const userId = getUserId(); const userId = getUserId();
if (!userId) return; // not authenticated — nothing to do if (!userId) return; // not authenticated — nothing to do
// Dedup: don't queue the same file twice while an identical one is still unsent
// (double-tap, re-added after a flaky reconnect). Matches on name+size+user.
const mine = get(queueItems).filter((i) => i.userId === userId);
const dup = mine.some(
(i) =>
i.fileName === file.name &&
i.fileSize === file.size &&
(i.status === 'pending' || i.status === 'uploading')
);
if (dup) return;
// Cap the queue so stuck/blocked blobs can't grow IndexedDB without bound. When full,
// evict the oldest terminal item (done/error/blocked) to make room; if none exist,
// refuse silently rather than pile on.
if (mine.length >= MAX_QUEUE_ITEMS) {
const evictable = get(queueItems).find(
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
);
if (evictable) {
await database.delete(STORE_NAME, evictable.id);
queueItems.update((items) => items.filter((it) => it.id !== evictable.id));
} else {
return;
}
}
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const entry = { const entry = {
id, id,
@@ -184,6 +284,10 @@ async function processQueue(): Promise<void> {
try { try {
while (true) { while (true) {
// Offline: leave items 'pending' rather than burning through them into 'error'.
// The `online` listener re-enters here the moment connectivity returns.
if (typeof navigator !== 'undefined' && navigator.onLine === false) break;
const items = get(queueItems); const items = get(queueItems);
const next = items.find((item) => item.status === 'pending'); const next = items.find((item) => item.status === 'pending');
if (!next) break; if (!next) break;
@@ -201,7 +305,12 @@ async function processQueue(): Promise<void> {
}, e.retryAfterSecs * 1000); }, e.retryAfterSecs * 1000);
break; break;
} }
// Other errors are already handled inside uploadItem (marked as 'error') if (e instanceof NetworkError) {
// Connectivity dropped mid-flight — the item is back to 'pending'. Stop the
// loop; the `online` listener resumes it. No error state, no manual tap.
break;
}
// Other errors are already handled inside uploadItem (marked 'error'/'blocked')
} }
} }
} finally { } finally {
@@ -258,6 +367,8 @@ async function uploadItem(id: string): Promise<void> {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
resolve(); resolve();
} else if (xhr.status === 429) { } else if (xhr.status === 429) {
// Rate limit only (quota-full is now a distinct 413, handled below as
// terminal) — back off and auto-resume when the window lifts.
try { try {
const body = JSON.parse(xhr.responseText); const body = JSON.parse(xhr.responseText);
const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60; const secs = typeof body.retry_after_secs === 'number' ? body.retry_after_secs : 60;
@@ -265,7 +376,20 @@ async function uploadItem(id: string): Promise<void> {
} catch { } catch {
reject(new RateLimitError(60)); reject(new RateLimitError(60));
} }
} else if (xhr.status >= 400 && xhr.status < 500) {
// Any other 4xx is permanent for this blob (locked/banned/released/quota).
// Retrying just re-hits the same rejection forever, so mark it terminal.
let msg = 'Upload nicht möglich.';
try {
const body = JSON.parse(xhr.responseText);
if (body.message) msg = body.message;
else if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
} catch {
if (xhr.status === 413) msg = 'Speicher-Limit erreicht.';
}
reject(new TerminalError(msg));
} else { } else {
// 5xx / unexpected — transient, keep it retryable.
try { try {
const body = JSON.parse(xhr.responseText); const body = JSON.parse(xhr.responseText);
reject(new Error(body.message || `HTTP ${xhr.status}`)); reject(new Error(body.message || `HTTP ${xhr.status}`));
@@ -275,8 +399,8 @@ async function uploadItem(id: string): Promise<void> {
} }
}); });
xhr.addEventListener('error', () => reject(new Error('Netzwerkfehler'))); xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler')));
xhr.addEventListener('abort', () => reject(new Error('Abgebrochen'))); xhr.addEventListener('abort', () => reject(new NetworkError('Abgebrochen')));
xhr.send(formData); xhr.send(formData);
}); });
@@ -296,6 +420,24 @@ async function uploadItem(id: string): Promise<void> {
updateItemStatus(id, 'pending'); updateItemStatus(id, 'pending');
throw e; // Propagate to processQueue for scheduling throw e; // Propagate to processQueue for scheduling
} }
if (e instanceof NetworkError) {
// No connectivity — keep the item pending and propagate so processQueue stops
// the loop (no point hammering while offline). The `online` listener resumes it.
entry.status = 'pending';
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'pending');
throw e;
}
if (e instanceof TerminalError) {
// Permanent rejection — drop the blob (we'll never resend it) and mark blocked
// so the UI shows a clear reason and offers no retry.
delete entry.blob;
entry.status = 'blocked';
entry.error = e.message;
await database.put(STORE_NAME, entry);
updateItemStatus(id, 'blocked', e.message);
return;
}
const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.'; const msg = e instanceof Error ? e.message : 'Upload fehlgeschlagen.';
entry.status = 'error'; entry.status = 'error';
entry.error = msg; entry.error = msg;

View File

@@ -15,6 +15,7 @@
import { onSseEvent } from '$lib/sse'; import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types'; import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened } from '$lib/event-state-store';
let { children } = $props(); let { children } = $props();
@@ -39,6 +40,10 @@
try { try {
const ctx = await api.get<MeContextDto>('/me/context'); const ctx = await api.get<MeContextDto>('/me/context');
privacyNote.set(ctx.privacy_note); privacyNote.set(ctx.privacy_note);
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
} catch { } catch {
// Cross-cutting hydration on boot — failure is non-fatal; users without // Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry. // a session land on /join anyway, and the per-page mount will retry.
@@ -61,7 +66,11 @@
} catch { } catch {
// Malformed payload — discard; nothing actionable for the user. // Malformed payload — discard; nothing actionable for the user.
} }
}) }),
// Reflect a host closing/reopening uploads live, so the composer switches to a
// locked state immediately instead of a guest finding out via a rejected upload.
onSseEvent('event-closed', () => markClosed()),
onSseEvent('event-opened', () => markOpened())
); );
}); });

View File

@@ -20,6 +20,7 @@
let expiry = $state<Date | null>(null); let expiry = $state<Date | null>(null);
let pinCopied = $state(false); let pinCopied = $state(false);
let leaveConfirmOpen = $state(false); let leaveConfirmOpen = $state(false);
let leaveEverywhere = $state(false);
let dataModeWarningOpen = $state(false); let dataModeWarningOpen = $state(false);
// `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE // `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE
@@ -99,10 +100,14 @@
setTimeout(() => (pinCopied = false), 2000); setTimeout(() => (pinCopied = false), 2000);
} }
async function handleLogout() { async function handleLogout(everywhere = false) {
// Session-delete is best-effort: the JWT is going away on this device either way, // Session-delete is best-effort: the JWT is going away on this device either way,
// so a network failure shouldn't block the user from leaving the event. // so a network failure shouldn't block the user from leaving the event.
try { await api.delete('/session'); } catch { /* best-effort logout */ } // `everywhere` revokes ALL of this user's sessions (other phones/laptops recovered
// via PIN), not just this device.
try {
await api.delete(everywhere ? '/sessions' : '/session');
} catch { /* best-effort logout */ }
// Wipe the IndexedDB upload queue so a second guest using the same device can't // Wipe the IndexedDB upload queue so a second guest using the same device can't
// inherit (or be blamed for) this guest's pending uploads. Not done on a 401 // inherit (or be blamed for) this guest's pending uploads. Not done on a 401
// auto-clear — that path preserves the queue in case the user re-authenticates. // auto-clear — that path preserves the queue in case the user re-authenticates.
@@ -379,9 +384,9 @@
</svg> </svg>
</a> </a>
<!-- Leave / logout --> <!-- Leave / logout (this device) -->
<button <button
onclick={() => (leaveConfirmOpen = true)} onclick={() => { leaveEverywhere = false; leaveConfirmOpen = true; }}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30" class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
> >
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"> <svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
@@ -389,6 +394,17 @@
</svg> </svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Event verlassen</span> <span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Event verlassen</span>
</button> </button>
<!-- Sign out everywhere (all devices) -->
<button
onclick={() => { leaveEverywhere = true; leaveConfirmOpen = true; }}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
>
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
</svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Auf allen Geräten abmelden</span>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -435,10 +451,12 @@
<!-- Leave-confirm bottom sheet --> <!-- Leave-confirm bottom sheet -->
<ConfirmSheet <ConfirmSheet
open={leaveConfirmOpen} open={leaveConfirmOpen}
title="Event verlassen?" title={leaveEverywhere ? 'Auf allen Geräten abmelden?' : 'Event verlassen?'}
message="Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren." message={leaveEverywhere
? 'Alle deine Sitzungen (auch auf anderen Geräten) werden beendet. Mit deinem PIN kannst du jederzeit zurückkehren.'
: 'Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.'}
confirmLabel="Abmelden" confirmLabel="Abmelden"
tone="danger" tone="danger"
onConfirm={handleLogout} onConfirm={() => handleLogout(leaveEverywhere)}
onCancel={() => (leaveConfirmOpen = false)} onCancel={() => (leaveConfirmOpen = false)}
/> />

View File

@@ -127,7 +127,6 @@
// Ban modal state // Ban modal state
let banTarget = $state<UserSummary | null>(null); let banTarget = $state<UserSummary | null>(null);
let banHideUploads = $state(false);
let banSubmitting = $state(false); let banSubmitting = $state(false);
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it // PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
@@ -254,14 +253,13 @@
function openBanModal(user: UserSummary) { function openBanModal(user: UserSummary) {
banTarget = user; banTarget = user;
banHideUploads = false;
} }
async function confirmBan() { async function confirmBan() {
if (!banTarget) return; if (!banTarget) return;
banSubmitting = true; banSubmitting = true;
try { try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads }); await api.post(`/host/users/${banTarget.id}/ban`, {});
toast(`${banTarget.display_name} wurde gesperrt.`, 'success'); toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null; banTarget = null;
users = await api.get<UserSummary[]>('/host/users'); users = await api.get<UserSummary[]>('/host/users');
@@ -416,17 +414,14 @@
{/if} {/if}
</Modal> </Modal>
<!-- Ban modal — checkbox-bearing, so uses the Modal shell instead of ConfirmSheet. --> <!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
<Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}> <Modal open={banTarget !== null} titleId="admin-ban-modal-title" onClose={() => (banTarget = null)}>
{#if banTarget} {#if banTarget}
<h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2> <h2 id="admin-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren? <strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
</p> </p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2"> <div class="flex gap-2">
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800">Abbrechen</button> <button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800 dark:active:bg-gray-800">Abbrechen</button>
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400"> <button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 active:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400 dark:active:bg-red-400">

View File

@@ -9,7 +9,7 @@
import { SlideQueue } from '$lib/diashow/queue'; import { SlideQueue } from '$lib/diashow/queue';
import { transitions, findTransition } from '$lib/diashow/transitions'; import { transitions, findTransition } from '$lib/diashow/transitions';
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock'; import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
import type { FeedUpload, FeedResponse } from '$lib/types'; import type { FeedUpload, FeedResponse, DeltaResponse } from '$lib/types';
const DWELL_OPTIONS = [3000, 6000, 10000]; const DWELL_OPTIONS = [3000, 6000, 10000];
@@ -100,6 +100,28 @@
} }
} }
// After an all-night projector reconnects (SSE drop, network blip), the live events
// it missed are gone. The SSE client fans out a `feed-delta` of everything since the
// last seen timestamp — merge it so the show backfills the gap instead of silently
// stalling on a stale set.
function handleFeedDelta(data: string) {
try {
const delta = JSON.parse(data) as DeltaResponse;
for (const id of delta.deleted_ids) {
const result = queue.remove(id, current?.id ?? null);
if (result.wasCurrent) advance();
}
for (const upload of delta.uploads) {
// Only enqueue displayable (processed) items; pushLive dedupes by id, so a
// later `upload-processed` still refines anything that arrives raw.
if (upload.preview_url || upload.thumbnail_url) queue.pushLive(upload);
}
if (!current) advance();
} catch {
// ignore — non-fatal; the next live event keeps the show moving
}
}
function handleUserHidden(data: string) { function handleUserHidden(data: string) {
try { try {
const payload = JSON.parse(data) as { user_id: string }; const payload = JSON.parse(data) as { user_id: string };
@@ -164,6 +186,7 @@
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed)); unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted)); unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
unsubs.push(onSseEvent('user-hidden', handleUserHidden)); unsubs.push(onSseEvent('user-hidden', handleUserHidden));
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
void loadInitial(); void loadInitial();
}); });

View File

@@ -389,18 +389,24 @@
async function handleLike(id: string) { async function handleLike(id: string) {
try { try {
await api.post(`/upload/${id}/like`); // Set state from the server's authoritative response rather than blind-inverting
// local state. On a second device (same recovered user), the `like-update`
// broadcast only carries `like_count` — so a blind invert would drift
// `liked_by_me` until refresh. The response gives both, exactly.
const res = await api.post<{ liked: boolean; like_count: number | null }>(`/upload/${id}/like`);
vibrate(10); vibrate(10);
// like_count is null when the server's count query hiccuped — keep the current
// count in that case rather than adopting a wrong number.
uploads = uploads.map((u) => uploads = uploads.map((u) =>
u.id === id u.id === id
? { ...u, liked_by_me: !u.liked_by_me, like_count: u.liked_by_me ? u.like_count - 1 : u.like_count + 1 } ? { ...u, liked_by_me: res.liked, like_count: res.like_count ?? u.like_count }
: u : u
); );
if (selectedUpload?.id === id) { if (selectedUpload?.id === id) {
selectedUpload = { selectedUpload = {
...selectedUpload, ...selectedUpload,
liked_by_me: !selectedUpload.liked_by_me, liked_by_me: res.liked,
like_count: selectedUpload.liked_by_me ? selectedUpload.like_count - 1 : selectedUpload.like_count + 1, like_count: res.like_count ?? selectedUpload.like_count,
}; };
} }
} catch (e) { } catch (e) {

View File

@@ -27,8 +27,16 @@
export_released: boolean; export_released: boolean;
} }
interface PinResetRequest {
id: string;
user_id: string;
display_name: string;
created_at: string;
}
let event = $state<EventStatus | null>(null); let event = $state<EventStatus | null>(null);
let users = $state<UserSummary[]>([]); let users = $state<UserSummary[]>([]);
let pinResetRequests = $state<PinResetRequest[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -47,7 +55,6 @@
// Ban modal state // Ban modal state
let banTarget = $state<UserSummary | null>(null); let banTarget = $state<UserSummary | null>(null);
let banHideUploads = $state(false);
let banSubmitting = $state(false); let banSubmitting = $state(false);
// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is // PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
@@ -114,9 +121,10 @@
loading = true; loading = true;
error = null; error = null;
try { try {
[event, users] = await Promise.all([ [event, users, pinResetRequests] = await Promise.all([
api.get<EventStatus>('/host/event'), api.get<EventStatus>('/host/event'),
api.get<UserSummary[]>('/host/users') api.get<UserSummary[]>('/host/users'),
api.get<PinResetRequest[]>('/host/pin-reset-requests')
]); ]);
} catch (e: unknown) { } catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.'; error = e instanceof Error ? e.message : 'Fehler beim Laden.';
@@ -125,6 +133,25 @@
} }
} }
async function resetPinForRequest(req: PinResetRequest) {
try {
const res = await api.post<{ pin: string }>(`/host/users/${req.user_id}/pin-reset`);
pinModal = { name: req.display_name, pin: res.pin };
await reload();
} catch (e: unknown) {
toastError(e);
}
}
async function dismissPinRequest(req: PinResetRequest) {
try {
await api.delete(`/host/pin-reset-requests/${req.id}`);
pinResetRequests = pinResetRequests.filter((r) => r.id !== req.id);
} catch (e: unknown) {
toastError(e);
}
}
async function toggleEventLock() { async function toggleEventLock() {
if (!event) return; if (!event) return;
try { try {
@@ -153,14 +180,13 @@
function openBanModal(user: UserSummary) { function openBanModal(user: UserSummary) {
banTarget = user; banTarget = user;
banHideUploads = false;
} }
async function confirmBan() { async function confirmBan() {
if (!banTarget) return; if (!banTarget) return;
banSubmitting = true; banSubmitting = true;
try { try {
await api.post(`/host/users/${banTarget.id}/ban`, { hide_uploads: banHideUploads }); await api.post(`/host/users/${banTarget.id}/ban`, {});
toast(`${banTarget.display_name} wurde gesperrt.`, 'success'); toast(`${banTarget.display_name} wurde gesperrt.`, 'success');
banTarget = null; banTarget = null;
await reload(); await reload();
@@ -278,21 +304,14 @@
{/if} {/if}
</Modal> </Modal>
<!-- Ban modal — needs a checkbox so it's not a pure ConfirmSheet, but still gets the same a11y shell. --> <!-- Ban modal — ban always hides now, so this is a plain confirm (no checkbox). -->
<Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}> <Modal open={banTarget !== null} titleId="host-ban-modal-title" onClose={() => (banTarget = null)}>
{#if banTarget} {#if banTarget}
<h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2> <h2 id="host-ban-modal-title" class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400"> <p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren? <strong>{banTarget.display_name}</strong> wird gesperrt: alle Uploads verschwinden aus
Galerie, Diashow und Export, und die Sitzung wird beendet. Rückgängig machbar über „Entsperren“.
</p> </p>
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
<input
type="checkbox"
bind:checked={banHideUploads}
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
</label>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
onclick={() => (banTarget = null)} onclick={() => (banTarget = null)}
@@ -336,6 +355,41 @@
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div> <div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
{:else if event} {:else if event}
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
{#if pinResetRequests.length > 0}
<div class="overflow-hidden rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<div class="border-b border-amber-200 px-5 py-3 dark:border-amber-900">
<h2 class="font-semibold text-amber-900 dark:text-amber-200">
PIN vergessen — {pinResetRequests.length} Anfrage{pinResetRequests.length === 1 ? '' : 'n'}
</h2>
<p class="mt-0.5 text-xs text-amber-700 dark:text-amber-400">
Prüfe die Identität, bevor du eine PIN zurücksetzt.
</p>
</div>
<ul class="divide-y divide-amber-200 dark:divide-amber-900">
{#each pinResetRequests as req (req.id)}
<li class="flex items-center justify-between gap-3 px-5 py-3">
<span class="min-w-0 truncate font-medium text-amber-900 dark:text-amber-100">{req.display_name}</span>
<div class="flex shrink-0 gap-2">
<button
onclick={() => dismissPinRequest(req)}
class="rounded-lg border border-amber-300 px-3 py-1.5 text-sm text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40"
>
Ablehnen
</button>
<button
onclick={() => resetPinForRequest(req)}
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-400"
>
PIN zurücksetzen
</button>
</div>
</li>
{/each}
</ul>
</div>
{/if}
<!-- ── Statistiken ─────────────────────────────────────────────── --> <!-- ── Statistiken ─────────────────────────────────────────────── -->
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"> <div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
<button <button

View File

@@ -29,6 +29,9 @@
let recoveryPin = $state(''); let recoveryPin = $state('');
let recoveryError = $state(''); let recoveryError = $state('');
let recoveryLoading = $state(false); let recoveryLoading = $state(false);
// Forgot-PIN request state (asks a host to reset it).
let pinRequestSent = $state(false);
let pinRequestLoading = $state(false);
async function handleJoin() { async function handleJoin() {
if (!displayName.trim()) return; if (!displayName.trim()) return;
@@ -85,9 +88,24 @@
nameTaken = false; nameTaken = false;
recoveryPin = ''; recoveryPin = '';
recoveryError = ''; recoveryError = '';
pinRequestSent = false;
// Keep displayName so the user can edit it slightly // Keep displayName so the user can edit it slightly
} }
// Forgot the PIN entirely: ask a host to reset it. The endpoint always 204s (no name
// enumeration), so we optimistically show a confirmation regardless.
async function requestPinReset() {
pinRequestLoading = true;
try {
await api.post('/recover/request', { display_name: takenName });
} catch {
// Non-fatal (rate limit etc.) — still show the confirmation so the user isn't stuck.
} finally {
pinRequestLoading = false;
pinRequestSent = true;
}
}
function copyPin() { function copyPin() {
navigator.clipboard.writeText(pin); navigator.clipboard.writeText(pin);
copied = true; copied = true;
@@ -173,6 +191,23 @@
Anderen Namen wählen Anderen Namen wählen
</button> </button>
<!-- Forgot the PIN entirely — ask a host to reset it in-app. -->
{#if pinRequestSent}
<p class="mt-3 rounded-lg bg-green-50 px-4 py-3 text-center text-sm text-green-700 dark:bg-green-950/30 dark:text-green-300">
Anfrage gesendet. Bitte einen Host, deine PIN zurückzusetzen — danach kannst du dich
mit der neuen PIN anmelden.
</p>
{:else}
<button
onclick={requestPinReset}
disabled={pinRequestLoading}
data-testid="request-pin-reset"
class="mt-3 w-full text-center text-sm text-blue-600 underline decoration-dotted underline-offset-2 hover:text-blue-700 disabled:opacity-50 dark:text-blue-400"
>
{pinRequestLoading ? 'Wird gesendet…' : 'PIN vergessen? Host um Zurücksetzen bitten'}
</button>
{/if}
{:else} {:else}
<!-- Normal join form --> <!-- Normal join form -->
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1> <h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>