fix(frontend): stop a parked photo being stranded for the session, and fix an SSE id

A parked upload has three ways to be released, and two were weaker than the
toast that promises "wird gesendet, sobald die Sperre aufgehoben ist":

* The live user-shown / event-opened events only reach a tab with an open
  stream, and streams are opened by /feed, /diashow, /export, /host and
  /admin — NOT /upload, which is exactly where the toast sends the guest to
  watch their queue.
* The boot-time release ran once and swallowed any failure, so a single
  failed request on venue wifi — the condition the whole parking mechanism
  exists for — skipped it for the entire session, leaving the row reading
  "Du bist gesperrt." after the ban was long lifted.

Now retried once. Deliberately NOT on a 401: api.get already answered that by
clearing auth and redirecting to /join, so a second attempt can only fire a
second redirect two seconds later, by which time the guest may have navigated
away. (That is not hypothetical — it made an existing browser-chaos spec fail
while I was writing this.) Guarded rather than an early return, so the SSE
listener registrations below still run.

Also: noteDelivered mapped upload-processed to p.id, but that payload carries
upload_id, so it recorded nothing — the docstring claimed a property the code
did not have. And it recorded user-shown against a `carried` clause that only
ever means "hidden", where it could only suppress a later genuine signal.
This commit is contained in:
fabi
2026-08-12 09:15:40 +02:00
parent 9f239882ac
commit 5aa2b2e886
2 changed files with 47 additions and 5 deletions

View File

@@ -367,11 +367,18 @@ function noteDelivered(eventName: string, data: string): void {
// Mirrors the three clauses in `carried`: uploads by upload id, deletions by upload id,
// ban-hides by user id.
const relevant =
eventName === 'new-upload' || eventName === 'upload-processed'
eventName === 'new-upload'
? p.id
: eventName === 'upload-deleted'
: // `upload-processed` carries `upload_id`, not `id` (see compression.rs) — reading
// `p.id` recorded nothing at all, so this branch quietly did the opposite of what
// the comment above claims. Harmless today only because the delta cursor is
// anchored by `new-upload`, which is not a property worth depending on.
eventName === 'upload-processed' || eventName === 'upload-deleted'
? (p.upload_id ?? p.id)
: eventName === 'user-hidden' || eventName === 'user-shown'
: // Only `user-hidden` has a matching clause in `carried` (`hidden_user_ids` comes
// from `uploads_hidden = TRUE`). Recording an UNBAN's user id could only ever
// suppress a later genuine signal, so it is deliberately not recorded.
eventName === 'user-hidden'
? p.user_id
: undefined;
if (typeof relevant === 'string') rememberDelivered(relevant);

View File

@@ -19,7 +19,7 @@
import { privacyNote } from '$lib/privacy-note-store';
import { refreshQuota } from '$lib/quota-store';
import { onSseEvent } from '$lib/sse';
import { api } from '$lib/api';
import { api, ApiError } from '$lib/api';
import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
import { setRole } from '$lib/role-store';
@@ -104,9 +104,44 @@
banned: ctx.is_banned,
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
});
} catch {
} catch (err) {
// Cross-cutting hydration on boot — failure is non-fatal; users without
// a session land on /join anyway, and the per-page mount will retry.
//
// But ONE consequence is not recoverable by a per-page mount: the park release
// above. A parked photo's other two release paths are the live `user-shown` /
// `event-opened` SSE events, and the routes that open a stream are /feed, /diashow,
// /export, /host and /admin — NOT /upload, which is exactly where the toast sends
// the guest to watch their queue. So on venue wifi, the condition this branch
// exists for, one failed request could strand the photo for the whole session with
// the queue row still reading "Du bist gesperrt.". Retry once, briefly.
//
// NOT on a 401. `api.get` already answered that one by calling `clearAuth()` and
// redirecting to /join, so there is no session left to hydrate and a second attempt
// can only fire a SECOND redirect — two seconds later, by which time the guest may
// have navigated somewhere else. Retry the transient case this exists for (offline,
// 5xx, a dropped request) and nothing else.
//
// A guard, not an early `return`: everything below this block — `refreshQuota`
// and, outside it, every SSE listener registration — still has to run.
const worthRetrying = !(err instanceof ApiError && err.status === 401);
if (worthRetrying) {
try {
await new Promise((r) => setTimeout(r, 2000));
const ctx = await api.get<MeContextDto>('/me/context');
isBanned.set(ctx.is_banned);
eventState.set({
uploadsLocked: ctx.uploads_locked,
galleryReleased: ctx.gallery_released
});
void releaseResolvedParks({
banned: ctx.is_banned,
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
});
} catch {
// Still down. The "Erneut" button on the parked row remains the way back.
}
}
}
void refreshQuota();
}