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

@@ -58,9 +58,21 @@
} 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(() => {
if (burstTimer) clearTimeout(burstTimer);
unsubCommentDeleted();
unsubNewComment();
});
// Only refetch when a *different* upload is shown. The feed reassigns the

View File

@@ -14,6 +14,7 @@
case 'uploading': return 'Wird hochgeladen';
case 'done': return 'Fertig';
case 'error': return 'Fehler';
case 'blocked': return 'Gesperrt';
}
}
@@ -23,6 +24,7 @@
case 'uploading': return 'text-blue-600 dark:text-blue-400';
case 'done': return 'text-green-600 dark:text-green-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
</button>
{/if}
{#if item.status === 'done' || item.status === 'error'}
{#if item.status === 'done' || item.status === 'error' || item.status === 'blocked'}
<button
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"

View File

@@ -5,6 +5,11 @@
import { scrollLock } from '$lib/actions/scroll-lock';
import CameraCapture from '$lib/components/CameraCapture.svelte';
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 fileInput: HTMLInputElement;
@@ -149,6 +154,22 @@
</div>
<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 -->
<button
onclick={openGallery}
@@ -189,5 +210,6 @@
>
Abbrechen
</button>
{/if}
</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 type { DeltaResponse } from './types';
type StreamTicketResponse = { ticket: string };
type StreamTicketResponse = { ticket: string; server_time: string };
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.
void (async () => {
let ticket: string;
let serverTime: string;
try {
const res = await api.post<StreamTicketResponse>('/stream/ticket', {});
ticket = res.ticket;
serverTime = res.server_time;
} catch {
// Failed to mint a ticket (auth lapse, network blip). Back off and retry
// via the existing error path.
@@ -95,12 +97,17 @@ export function connectSse(): void {
eventSource.onopen = () => {
// Successful connection — reset the backoff counter.
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;
if (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) {
@@ -159,7 +166,12 @@ export function setLastEventTime(time: 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);
if (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`
* 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>(
`/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));
} catch {
// 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
// gap — the client must full-refresh rather than merge (see feed-delta handler).
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
@@ -56,6 +59,8 @@ export interface MeContextDto {
privacy_note: string;
quota_enabled: boolean;
storage_quota_enabled: boolean;
uploads_locked: boolean;
gallery_released: boolean;
}
// mirrors backend/src/handlers/host.rs::PinResetResponse

View File

@@ -11,7 +11,10 @@ export interface QueueItem {
mimeType: string;
caption: 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;
error?: string;
}
@@ -26,8 +29,50 @@ export const rateLimitRetryAt = writable<number | null>(null);
const DB_NAME = 'eventsnap-uploads';
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;
// 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> {
if (db) return db;
// 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> {
const database = await getDb();
const myUserId = getUserId();
@@ -98,6 +163,14 @@ export async function loadQueue(): Promise<void> {
error: entry.error
}));
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(
@@ -108,6 +181,33 @@ export async function addToQueue(
const database = await getDb();
const userId = getUserId();
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 entry = {
id,
@@ -184,6 +284,10 @@ async function processQueue(): Promise<void> {
try {
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 next = items.find((item) => item.status === 'pending');
if (!next) break;
@@ -201,7 +305,12 @@ async function processQueue(): Promise<void> {
}, e.retryAfterSecs * 1000);
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 {
@@ -258,6 +367,8 @@ async function uploadItem(id: string): Promise<void> {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} 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 {
const body = JSON.parse(xhr.responseText);
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 {
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 {
// 5xx / unexpected — transient, keep it retryable.
try {
const body = JSON.parse(xhr.responseText);
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('abort', () => reject(new Error('Abgebrochen')));
xhr.addEventListener('error', () => reject(new NetworkError('Netzwerkfehler')));
xhr.addEventListener('abort', () => reject(new NetworkError('Abgebrochen')));
xhr.send(formData);
});
@@ -296,6 +420,24 @@ async function uploadItem(id: string): Promise<void> {
updateItemStatus(id, 'pending');
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.';
entry.status = 'error';
entry.error = msg;