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

@@ -3,7 +3,7 @@
import { getToken, getDisplayName, getExpiry, clearAuth, currentPin } from '$lib/auth';
import { role, setRole } from '$lib/role-store';
import { clearQueue } from '$lib/upload-queue';
import { api } from '$lib/api';
import { api, ApiError } from '$lib/api';
import { onMount, onDestroy } from 'svelte';
import { dataMode } from '$lib/data-mode-store';
import { themePreference, type ThemePreference } from '$lib/theme-store';
@@ -14,6 +14,7 @@
import { avatarPalette, initials } from '$lib/avatar';
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import { vibrate } from '$lib/haptics';
import { toast } from '$lib/toast-store';
import type { MeContextDto } from '$lib/types';
let displayName = $state<string | null>(null);
@@ -124,6 +125,40 @@
goto('/join');
}
let deleteConfirmOpen = $state(false);
let deleting = $state(false);
/**
* Erase this account. Unlike `handleLogout`, the server call is NOT best-effort — if it fails
* we must keep the guest signed in and say so, because clearing local auth on a failed delete
* would leave them believing their photos were gone while every one of them is still live.
*/
async function handleDeleteAccount() {
if (deleting) return;
deleting = true;
try {
await api.delete('/me');
} catch (e) {
deleteConfirmOpen = false;
deleting = false;
toast(
e instanceof ApiError ? e.message : 'Löschen fehlgeschlagen. Bitte versuch es erneut.',
'error',
6000
);
return;
}
// Only now is it safe to tear down locally. The queue must go too: its blobs are this
// guest's photos, and leaving them would re-upload everything on the next session.
try {
await clearQueue();
} catch {
/* best-effort cleanup */
}
clearAuth();
goto('/join');
}
function formatDate(d: Date): string {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' });
}
@@ -517,7 +552,13 @@
</a>
<!-- Leave / logout (this device) -->
<!-- `data-testid` rather than an accessible-name locator: this trigger and the
confirm sheet's button are BOTH labelled "Abmelden", so `getByRole('button',
{ name: /abmelden/i })` is ambiguous the moment the sheet opens. The rename
away from "Event verlassen" silently broke three e2e locators — including the
one behind the smoke spec — precisely because they keyed on visible copy. -->
<button
data-testid="account-logout"
onclick={() => {
leaveEverywhere = false;
leaveConfirmOpen = true;
@@ -570,6 +611,32 @@
>Auf allen Geräten abmelden</span
>
</button>
<!-- Erasure. The join page's data notice promises this exists, and until now nothing
did: there was no user-deletion route at any role, so honouring "please remove my
photos" meant hand-written SQL against production. -->
<button
data-testid="account-delete"
onclick={() => (deleteConfirmOpen = true)}
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
>
<svg
class="h-5 w-5 text-red-500 dark:text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400"
>Konto und alle Fotos löschen</span
>
</button>
</div>
</div>
</div>
@@ -624,3 +691,15 @@
onConfirm={() => handleLogout(leaveEverywhere)}
onCancel={() => (leaveConfirmOpen = false)}
/>
<!-- Erasure confirm. Separate sheet from the logout one because the copy has to be unambiguous
about the difference: logging out keeps everything, this destroys it. -->
<ConfirmSheet
open={deleteConfirmOpen}
title="Konto und alle Fotos löschen?"
message="Alle deine Fotos, Bildtexte, Kommentare und Likes werden endgültig gelöscht — auch aus der Diashow und aus dem Erinnerungs-Archiv. Das lässt sich nicht rückgängig machen."
confirmLabel="Endgültig löschen"
tone="danger"
onConfirm={handleDeleteAccount}
onCancel={() => (deleteConfirmOpen = false)}
/>