fix(audit-followup): close regressions/gaps from the persona-audit re-review
Adversarial re-review of the persona-audit commit surfaced two HIGH regressions I'd introduced plus MED/LOW gaps. All fixed: HIGH - Auth privilege escalation: reads are sessionStorage-first, but guest `setAuth` wrote only localStorage — a resident admin token shadowed a new guest login (guest ran as admin on a shared device). setAuth/setAdminAuth now DISPLACE the other store so exactly one identity is resident. Adds unit + e2e regression guards. - Host dashboard: `exportGenerating` used `||`, so a one-half export failure stuck on "wird erstellt…" forever and never showed the re-release hint. Changed to `&&`. MEDIUM - Locked-upload auto-resume was unreliable (`event-opened` has no reconnect replay and SSE is down on non-feed pages) — now also resumes on `feed-delta`, which fires on every SSE reconnect. - Queue-cap eviction could drop a recoverable locked item (parked as `error` with a live blob) — now evicts only `done`/`blocked`. - Host page async-`onMount` leaked SSE handlers if unmounted mid-load — added a `destroyed` guard before registration. LOW - An unparseable 403 body now keeps the blob (reversible) instead of purging it. - `refreshEventState` is sequence-guarded so a late close-refresh can't clobber a reopen. - `/export/status` moved out of the all-or-nothing `reload()` so its failure can't blank the whole host dashboard. Verified: svelte-check 0 errors, 36 frontend unit tests (incl. new displacement guards). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -55,18 +55,26 @@ function bindOnline(): void {
|
||||
bindOnline();
|
||||
|
||||
// Resume the queue when the host reopens the event. A queued upload that hit a locked/
|
||||
// released event kept its blob and parked as a retryable `error` (LockedError); the
|
||||
// `event-opened` SSE flips it back to pending and re-drains, so a photo staged during a
|
||||
// lock isn't lost across a reopen. One-way import (sse.ts never imports this module).
|
||||
// released event kept its blob and parked as a retryable `error` (LockedError); flipping it
|
||||
// back to pending and re-draining recovers it so a photo staged during a lock isn't lost.
|
||||
// We resume on TWO signals:
|
||||
// - `event-opened`: the live reopen, when the SSE happens to be connected.
|
||||
// - `feed-delta`: fired on every SSE (re)connect (see sse.ts `deltaFetchAndFan`). Because
|
||||
// `event-opened` has no server-side replay and the SSE is disconnected on non-feed pages
|
||||
// / backgrounded tabs, a reopen is often MISSED live — this catches up on the next
|
||||
// reconnect. Retrying while still locked just re-parks the item (bounded, one per event).
|
||||
// One-way import (sse.ts never imports this module).
|
||||
let sseBound = false;
|
||||
function bindSse(): void {
|
||||
if (sseBound || typeof window === 'undefined') return;
|
||||
onSseEvent('event-opened', () => {
|
||||
const resume = () => {
|
||||
void (async () => {
|
||||
await requeueRetriable();
|
||||
await processQueue();
|
||||
})();
|
||||
});
|
||||
};
|
||||
onSseEvent('event-opened', resume);
|
||||
onSseEvent('feed-delta', resume);
|
||||
sseBound = true;
|
||||
}
|
||||
bindSse();
|
||||
@@ -266,12 +274,15 @@ export async function addToQueue(
|
||||
if (dup) return 'duplicate';
|
||||
|
||||
// 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 and tell the caller so it can surface a "queue full" message (silent loss of a
|
||||
// photo the user believed was queued is the worst outcome here).
|
||||
// evict the oldest item whose blob is already gone or unrecoverable — ONLY `done` (blob
|
||||
// deleted on success) or `blocked` (blob purged, terminal). NEVER evict `error`: those are
|
||||
// retryable and still hold a live blob (a network blip, or a locked/released upload that
|
||||
// resumes on reopen) — evicting one would silently lose a photo the fix deliberately kept.
|
||||
// If nothing is evictable, refuse and tell the caller so it can surface a "queue full"
|
||||
// message rather than dropping a photo the user believed was queued.
|
||||
if (mine.length >= MAX_QUEUE_ITEMS) {
|
||||
const evictable = get(queueItems).find(
|
||||
(i) => i.userId === userId && (i.status === 'done' || i.status === 'error' || i.status === 'blocked')
|
||||
(i) => i.userId === userId && (i.status === 'done' || i.status === 'blocked')
|
||||
);
|
||||
if (evictable) {
|
||||
await database.delete(STORE_NAME, evictable.id);
|
||||
@@ -486,7 +497,14 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// A REVERSIBLE lock (event closed / gallery released) is tagged
|
||||
// `uploads_locked` by the backend — keep the blob and park it retryable so
|
||||
// a host reopen resumes it, instead of purging it like a permanent 4xx.
|
||||
if (body?.error === 'uploads_locked') {
|
||||
// Also treat ANY 403 we can't positively identify as permanent (`forbidden`
|
||||
// = banned) as reversible: an unparseable 403 body (proxy/WAF/captive
|
||||
// portal) must NOT purge the blob — losing a photo is the worst outcome, and
|
||||
// 403 is the reversible-lock status here.
|
||||
if (
|
||||
body?.error === 'uploads_locked' ||
|
||||
(xhr.status === 403 && body?.error !== 'forbidden')
|
||||
) {
|
||||
reject(new LockedError(body?.message || 'Event ist geschlossen.'));
|
||||
break;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user