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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user