fix(frontend): park uploads that cannot succeed, and stop two false signals

The upload queue gains `parkedFor`, so a photo rejected for a reason that cannot change on
its own stops re-pushing itself. A ban used to come back as a generic `forbidden`, which
purged the blob and moved the row to `blocked` — a terminal state with no retry button — so
lifting a ban restored everything except the photo actually in flight. Ban and release are
now distinct codes that keep the blob, charge no attempt, and tell the guest what has to
happen. `releaseResolvedParks` drains them at boot from /me/context, because the live
`user-shown` / `event-opened` events only reach a tab that was open when the host acted,
and the usual sequence is the other way round.

Two signals were firing on nothing. A filtered feed set `feedStale` on EVERY delta without
deduping — and the delta cursor boundary is inclusive while sse.ts deliberately rewinds
`lastEventTime`, so deltas routinely re-return rows already delivered. With the backstop
polling every 60-120s, a guest who tapped a hashtag got a "Neue Beiträge" pill they could
never clear, each tap costing a full filtered refetch. It now dedupes in both branches.

The SSE liveness backstop had the mirror problem: `noteDelivered` harvested id, upload_id
AND user_id from every payload, so by the time anything was deleted or anyone banned, their
ids were already marked delivered from ordinary traffic about live content. The
`deleted_ids` and `hidden_user_ids` clauses were false essentially always, leaving a
half-open socket undetected while a host moderated into a feed nobody was listening to.
Each event now records only the id its own clause tests.

Also: /admin no longer bounces to /join on a cleared session — AUTH_ROUTES had the `/admin`
prefix, which suppressed clearAuth() on the dashboard and let the login guard bounce back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:44:26 +02:00
parent f5c55d6f92
commit a53729a704
13 changed files with 621 additions and 50 deletions

View File

@@ -8,10 +8,14 @@
// Show which event the guest is joining (USER_JOURNEYS §1). Public, pre-auth.
let eventName = $state('');
// The operator's own data notice, if they set one. Usually empty — see the notice block below.
let privacyNote = $state('');
let noticeOpen = $state(false);
onMount(async () => {
try {
const ev = await api.get<{ name: string; slug: string }>('/event');
const ev = await api.get<{ name: string; slug: string; privacy_note?: string }>('/event');
eventName = ev.name;
privacyNote = ev.privacy_note?.trim() ?? '';
} catch {
// Non-fatal — fall back to the generic heading if the lookup fails.
}
@@ -34,6 +38,39 @@
let pinRequestSent = $state(false);
let pinRequestLoading = $state(false);
/**
* Stable idempotency key for THIS join attempt, surviving a reload or a PWA relaunch.
*
* The failure it closes: `/join` commits the account and the PIN hash, but the plaintext PIN
* only ever exists in the response body. Lose that response — the 5G-to-nothing handoff every
* venue car park has — and the retry used to 409 on the guest's own name, leaving them staring
* at a PIN prompt for a PIN nobody had ever seen. With a key the server recognises the retry
* and answers with a working PIN (it rotates it; see migration 027).
*
* Kept in localStorage rather than component state because the guest's instinctive response to
* a hung request is to reload the page, which would otherwise mint a fresh key and re-create
* the exact bug. Cleared once the join has demonstrably landed.
*/
const JOIN_KEY_STORAGE = 'eventsnap:join-attempt-id';
function joinAttemptId(): string {
let id: string | null = null;
try {
id = localStorage.getItem(JOIN_KEY_STORAGE);
} catch {
// Private mode / storage disabled. A per-call UUID is still better than none: it makes
// a retry within this page view idempotent, which is the common case.
}
if (!id) {
id = crypto.randomUUID();
try {
localStorage.setItem(JOIN_KEY_STORAGE, id);
} catch {
/* best effort */
}
}
return id;
}
async function handleJoin() {
if (!displayName.trim()) return;
loading = true;
@@ -44,9 +81,19 @@
pin: string;
user_id: string;
is_new: boolean;
}>('/join', { display_name: displayName.trim() });
}>('/join', {
display_name: displayName.trim(),
client_join_id: joinAttemptId()
});
setAuth(res.jwt, res.pin, res.user_id, displayName.trim());
// The join landed and we hold the PIN — retire the key so a later deliberate join (a
// second guest on a shared device) is a new attempt rather than a retry of this one.
try {
localStorage.removeItem(JOIN_KEY_STORAGE);
} catch {
/* best effort */
}
pin = res.pin;
showPinModal = true;
} catch (e) {
@@ -311,6 +358,70 @@
</button>
</form>
<!--
Data notice AT THE POINT OF COLLECTION.
There was none at all — not on this page, not anywhere pre-auth — while
PROJECT.md:407 claimed there was. For ~100 EU guests uploading photos of
identifiable people, including children, that was the most consequential gap in
the whole audit, and the least work to close.
The baseline text below is hardcoded rather than read from `privacy_note`,
because `privacy_note` defaults to '' (migration 009) — a notice an operator can
leave blank is not a notice. The operator's own text is shown IN ADDITION when
they have set one.
Summary visible without a tap (that is the part that has to be unmissable);
detail behind a disclosure so it does not bury the one field on the page.
-->
<div class="mt-5 border-t border-gray-200 pt-4 dark:border-gray-700">
<p class="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
Mit dem Beitreten legst du ein Konto mit deinem Namen an. Deine Fotos, Kommentare und
dein Name sind für alle Gäste dieses Events sichtbar.
</p>
<button
type="button"
onclick={() => (noticeOpen = !noticeOpen)}
data-testid="join-privacy-toggle"
aria-expanded={noticeOpen}
class="mt-1 text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{noticeOpen ? 'Weniger anzeigen' : 'Was passiert mit meinen Daten?'}
</button>
{#if noticeOpen}
<div
class="mt-2 space-y-2 text-xs leading-relaxed text-gray-600 dark:text-gray-400"
data-testid="join-privacy-note"
>
<p>
<strong class="text-gray-800 dark:text-gray-200">Was gespeichert wird:</strong> dein angezeigter
Name, deine hochgeladenen Fotos und Videos samt Aufnahmezeitpunkt, deine Bildtexte, Kommentare
und Likes. Dazu ein verschlüsselter Prüfwert deines PINs — der PIN selbst wird nicht gespeichert.
</p>
<p>
<strong class="text-gray-800 dark:text-gray-200">Wer es sehen kann:</strong> alle Gäste
dieses Events. Die Gastgeber können außerdem Beiträge und Kommentare entfernen. Am Ende
erhalten die Gastgeber ein Archiv mit allen Fotos.
</p>
<p>
<strong class="text-gray-800 dark:text-gray-200">Wie lange:</strong> bis die Gastgeber
das Event abschließen und die Installation abbauen. Du kannst eigene Fotos jederzeit selbst
löschen, und über „Mein Konto“ dein Konto samt aller Inhalte entfernen lassen.
</p>
<p>
Lade bitte keine Fotos von Personen hoch, die damit nicht einverstanden sind — bei
Kindern brauchst du das Einverständnis der Eltern.
</p>
{#if privacyNote}
<p class="border-t border-gray-200 pt-2 dark:border-gray-700">
<strong class="text-gray-800 dark:text-gray-200">Hinweis der Gastgeber:</strong>
{privacyNote}
</p>
{/if}
</div>
{/if}
</div>
<p class="mt-4 text-center text-sm">
<a
href="/recover"