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:
@@ -78,7 +78,10 @@
|
||||
'<button id="app-boot-reload" class="app-boot__fail-btn">Neu laden</button>' +
|
||||
'</div>';
|
||||
var btn = document.getElementById('app-boot-reload');
|
||||
if (btn) btn.addEventListener('click', function () { location.reload(); });
|
||||
if (btn)
|
||||
btn.addEventListener('click', function () {
|
||||
location.reload();
|
||||
});
|
||||
}, 15000);
|
||||
})();
|
||||
</script>
|
||||
@@ -151,7 +154,12 @@
|
||||
.app-boot__fail {
|
||||
max-width: 20rem;
|
||||
text-align: center;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
.app-boot__fail-title {
|
||||
margin: 0 0 0.5rem;
|
||||
@@ -198,7 +206,12 @@
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: #faf9f7;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
color: #545350;
|
||||
}
|
||||
html.dark .app-boot__noscript {
|
||||
|
||||
@@ -15,11 +15,28 @@ export class ApiError extends Error {
|
||||
|
||||
const TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Pages that ARE the recovery flow — redirecting from them would loop. */
|
||||
const AUTH_ROUTES = ['/join', '/recover'];
|
||||
/**
|
||||
* Pages that ARE a credential-entry form — redirecting away from them would loop.
|
||||
*
|
||||
* `/admin/login` belongs here: a mistyped admin password 401s, the hard redirect below fired, and
|
||||
* the admin was thrown off their own login form — destroying the error message that would have
|
||||
* told them what happened. The redirect exists to rescue a session that died mid-app; someone
|
||||
* actively typing credentials into a login form does not need rescuing.
|
||||
*
|
||||
* It must be `/admin/login` and NOT the `/admin` prefix. `/admin` would also match the dashboard,
|
||||
* and suppressing `clearAuth()` there is a trap: the dead token stays resident, `admin/+page`
|
||||
* bounces to `/admin/login`, and that page's `getRole() === 'admin'` guard — which decodes the JWT
|
||||
* without checking `exp` — bounces straight back. The result is an unbreakable flip-flop with no
|
||||
* way to reach the login form short of clearing site data, on the one device running the party.
|
||||
*/
|
||||
const AUTH_ROUTES = ['/join', '/recover', '/admin/login'];
|
||||
|
||||
/**
|
||||
* Send a guest whose session died back to the join screen.
|
||||
* Send someone whose session died back to the right credential form.
|
||||
*
|
||||
* Staff go to `/admin/login`, guests to `/join`. Sending an admin whose session expired to the
|
||||
* guest join screen is a dead end: they do not have a name and PIN to type, and the screen gives
|
||||
* them no route back to their own login.
|
||||
*
|
||||
* Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already
|
||||
* imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would
|
||||
@@ -29,9 +46,31 @@ const AUTH_ROUTES = ['/join', '/recover'];
|
||||
*/
|
||||
function redirectToJoin(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (onAuthRoute()) return;
|
||||
const staff = window.location.pathname.startsWith('/admin');
|
||||
window.location.assign(staff ? '/admin/login' : '/join');
|
||||
}
|
||||
|
||||
/**
|
||||
* Are we currently ON a credential-entry page?
|
||||
*
|
||||
* A 401 means two completely different things depending on the answer, and treating them alike
|
||||
* destroyed data (H14). Off these pages it means "your session died" — clear it and rescue the
|
||||
* guest. ON them it means "the credentials you just typed were wrong", which is ordinary form
|
||||
* validation and must change no stored state at all.
|
||||
*
|
||||
* The concrete failure: an authenticated guest taps "Gerät wechseln", mistypes their own name, and
|
||||
* the backend answers 401 (deliberately identical for a wrong PIN and an unknown name — that
|
||||
* indistinguishability is an anti-enumeration property, see `recover`'s dummy bcrypt, so it must not
|
||||
* be "fixed" by making the responses differ). `clearAuth()` then threw away their working token,
|
||||
* and the recover page additionally called `clearPin()` — which discarded the ONLY copy of their
|
||||
* PIN, since localStorage is where it lives and the server holds only the bcrypt. One typo, and
|
||||
* both their session and their credential were gone; rejoining under the same name 409s.
|
||||
*/
|
||||
function onAuthRoute(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const path = window.location.pathname;
|
||||
if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return;
|
||||
window.location.assign('/join');
|
||||
return AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`));
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
@@ -106,7 +145,7 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
||||
// An expired/invalid token (401) clears the dead session. Banned users are
|
||||
// NOT logged out — they keep read access by design (USER_JOURNEYS §10) and
|
||||
// simply get a 403 "gesperrt" toast on writes.
|
||||
if (res.status === 401) {
|
||||
if (res.status === 401 && !onAuthRoute()) {
|
||||
clearAuth();
|
||||
// Clearing auth alone leaves the guest stranded: the bottom nav and FAB are
|
||||
// gated on `isAuthenticated` so they simply vanish, route guards only run in
|
||||
|
||||
@@ -112,7 +112,7 @@ export function setAdminAuth(jwt: string, userId: string, displayName?: string):
|
||||
|
||||
// Hook registry: cross-cutting stores (export-status, etc.) register a callback
|
||||
// here at import-time so they get reset on every clearAuth path — both the
|
||||
// explicit "Event verlassen" button and the api.ts 401 auto-clear. Keeps
|
||||
// explicit "Abmelden" button and the api.ts 401 auto-clear. Keeps
|
||||
// clearAuth the single source of truth without baking dependencies on every
|
||||
// downstream store into this module (which would create circular imports).
|
||||
const clearAuthHooks: Array<() => void> = [];
|
||||
|
||||
@@ -61,6 +61,9 @@ const KNOWN_EVENTS = [
|
||||
'new-comment',
|
||||
'comment-deleted',
|
||||
'user-hidden',
|
||||
// The mirror of `user-hidden`: a host lifted a ban, so the guest's uploads return to every
|
||||
// feed and the projector, and their own parked upload queue resumes.
|
||||
'user-shown',
|
||||
'event-closed',
|
||||
'event-opened',
|
||||
'event-updated',
|
||||
@@ -144,7 +147,9 @@ export function connectSse(): void {
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
eventSource.addEventListener(eventName, (e) => {
|
||||
noteStreamActivity();
|
||||
dispatch(eventName, (e as MessageEvent).data);
|
||||
const data = (e as MessageEvent).data;
|
||||
noteDelivered(eventName, data);
|
||||
dispatch(eventName, data);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,6 +196,10 @@ export function disconnectSse(): void {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
// A new stream has delivered nothing, so anything the next delta returns is genuinely
|
||||
// undelivered as far as THIS connection is concerned. Keeping the old set would suppress the
|
||||
// liveness signal for content that arrived during the gap — the opposite failure to H1.
|
||||
forgetDelivered();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
@@ -305,6 +314,78 @@ function dispatch(eventType: string, data: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ids the LIVE STREAM has actually delivered to us — the evidence the liveness backstop needs.
|
||||
*
|
||||
* The backstop asks "did the poll find something the stream never pushed?", and the old answer was
|
||||
* simply "did the poll return any rows?". Those are not the same question, and on a live event they
|
||||
* diverge constantly: `dispatch` advances the cursor to an upload's `created_at`, which is always
|
||||
* EARLIER than the `server_time` the previous delta stored, so the cursor rewinds onto
|
||||
* `feed_delta`'s deliberately inclusive `>=` boundary and the poll re-returns an upload the stream
|
||||
* had already delivered a moment ago. Row count > 0, so the backstop concluded the socket was dead
|
||||
* and tore down a perfectly healthy stream — then reset `reconnectAttempt` to 0, bypassing the
|
||||
* jittered backoff. At 100 guests that is roughly one reconnect per second, sustained, all evening,
|
||||
* each costing ~10 queries.
|
||||
*
|
||||
* Bounded FIFO: an event runs for hours and this must not grow without limit. The cap only needs to
|
||||
* exceed what one delta window can return (`DELTA_LIMIT` server-side), because anything older than
|
||||
* the current window cannot be re-returned as "new".
|
||||
*/
|
||||
const DELIVERED_MEMORY = 500;
|
||||
const deliveredIds: string[] = [];
|
||||
const deliveredSet = new Set<string>();
|
||||
|
||||
function rememberDelivered(id: string): void {
|
||||
if (deliveredSet.has(id)) return;
|
||||
deliveredSet.add(id);
|
||||
deliveredIds.push(id);
|
||||
if (deliveredIds.length > DELIVERED_MEMORY) {
|
||||
const evicted = deliveredIds.shift();
|
||||
if (evicted !== undefined) deliveredSet.delete(evicted);
|
||||
}
|
||||
}
|
||||
|
||||
/** Record whatever ids a stream payload carried, so a later delta can be recognised as a repeat.
|
||||
*
|
||||
* Only the id that IS the thing the liveness check tests, per event — not every id field present.
|
||||
*
|
||||
* This used to harvest `id`, `upload_id` and `user_id` from every payload, which quietly disarmed
|
||||
* two thirds of the backstop. `new-upload` carries `id` + `user_id`, and `like-update` /
|
||||
* `new-comment` carry `upload_id` + `user_id`, so by the time anything was deleted or anyone was
|
||||
* banned their ids were already in `deliveredSet` — recorded from ordinary traffic about content
|
||||
* that was still perfectly live. `carried`'s `deleted_ids` and `hidden_user_ids` clauses were then
|
||||
* false essentially always.
|
||||
*
|
||||
* The cost: a socket that goes half-open (a phone roaming APs leaves `readyState === OPEN`, so the
|
||||
* cheap check misses it) is only noticed once a genuinely NEW upload appears. A host moderating
|
||||
* three photos, or banning a guest, produced a delta whose every id was "already delivered" — so
|
||||
* the stream stayed dead and the host kept moderating into a feed nobody's app was listening to.
|
||||
*/
|
||||
function noteDelivered(eventName: string, data: string): void {
|
||||
try {
|
||||
const p = JSON.parse(data) as { id?: unknown; upload_id?: unknown; user_id?: unknown };
|
||||
// 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'
|
||||
? p.id
|
||||
: eventName === 'upload-deleted'
|
||||
? (p.upload_id ?? p.id)
|
||||
: eventName === 'user-hidden' || eventName === 'user-shown'
|
||||
? p.user_id
|
||||
: undefined;
|
||||
if (typeof relevant === 'string') rememberDelivered(relevant);
|
||||
} catch {
|
||||
// non-JSON payload — nothing to record
|
||||
}
|
||||
}
|
||||
|
||||
/** Reset on disconnect: a fresh stream has delivered nothing yet. */
|
||||
function forgetDelivered(): void {
|
||||
deliveredIds.length = 0;
|
||||
deliveredSet.clear();
|
||||
}
|
||||
|
||||
/** Pull an ISO `created_at` out of an event payload if it has one, else undefined. */
|
||||
function extractCreatedAt(data: string): string | undefined {
|
||||
try {
|
||||
@@ -333,10 +414,18 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
// "Did the poll find something the STREAM never delivered?" — not "did it return rows?".
|
||||
// See `deliveredIds`: on a live event the cursor rewinds onto the inclusive `>=` boundary
|
||||
// and re-returns uploads the stream already pushed, so a row count made every healthy
|
||||
// stream look dead and produced a sustained reconnect storm.
|
||||
//
|
||||
// Ids the stream delivered while we were connected are excluded. Anything genuinely new —
|
||||
// including everything that arrived while the socket was half-open — still counts, which is
|
||||
// the signal this backstop exists for.
|
||||
return (
|
||||
response.uploads.length > 0 ||
|
||||
response.deleted_ids.length > 0 ||
|
||||
response.hidden_user_ids.length > 0
|
||||
response.uploads.some((u) => !deliveredSet.has(u.id)) ||
|
||||
response.deleted_ids.some((id) => !deliveredSet.has(id)) ||
|
||||
response.hidden_user_ids.some((id) => !deliveredSet.has(id))
|
||||
);
|
||||
} catch (e) {
|
||||
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
||||
|
||||
@@ -142,7 +142,6 @@ describe('shouldAbortForStall', () => {
|
||||
expect(shouldAbortForStall(now - 91_000, now, true)).toBe(false);
|
||||
expect(shouldAbortForStall(now - 121_000, now, true)).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -215,6 +215,21 @@ interface QueueEntry {
|
||||
* Cleared by `retryItem` — an explicit tap is the guest changing their mind.
|
||||
*/
|
||||
cancelled?: boolean;
|
||||
/**
|
||||
* Parked waiting for a specific host action, with the blob intact.
|
||||
*
|
||||
* Both values are reversible 403s whose answer cannot change without somebody deciding to
|
||||
* change it, which makes automatic retries pure waste: they re-pushed the whole photo over
|
||||
* cellular on every budget refill for the rest of the night while the guest was told to tap
|
||||
* a camera button that 403s.
|
||||
*
|
||||
* - `'reopen'` — the gallery was released (`gallery_released`). Cleared by `event-opened`.
|
||||
* - `'unban'` — the uploader is banned (`user_banned`). Cleared by `user-shown`.
|
||||
*
|
||||
* An explicit `retryItem` clears either: a deliberate tap is the guest asking us to try
|
||||
* anyway, and if the condition still holds the next response simply re-parks it.
|
||||
*/
|
||||
parkedFor?: 'reopen' | 'unban';
|
||||
blob?: Blob;
|
||||
}
|
||||
|
||||
@@ -266,7 +281,7 @@ onClearAuth(() => queueItems.set([]));
|
||||
let sseBound = false;
|
||||
function bindSse(): void {
|
||||
if (sseBound || typeof window === 'undefined') return;
|
||||
const resume = (options: { resetAttempts?: boolean } = {}) => {
|
||||
const resume = (options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}) => {
|
||||
void (async () => {
|
||||
await requeueRetriable(options);
|
||||
await processQueue();
|
||||
@@ -275,7 +290,20 @@ function bindSse(): void {
|
||||
// A reopen is a deliberate host action that changes the server's answer, so it's fair to
|
||||
// give parked items a fresh retry budget. A plain reconnect is not — that's the signal
|
||||
// that fires over and over on a flapping AP.
|
||||
onSseEvent('event-opened', () => resume({ resetAttempts: true }));
|
||||
onSseEvent('event-opened', () => resume({ resetAttempts: true, release: 'reopen' }));
|
||||
// An unban is the same kind of evidence, for the guest it names. `user-shown` is broadcast to
|
||||
// everyone (every feed needs to un-hide that user's photos), so check it is actually us
|
||||
// before resuming — otherwise one guest's unban would resume every OTHER banned guest's
|
||||
// queue straight into another 403.
|
||||
onSseEvent('user-shown', (payload) => {
|
||||
let userId: unknown;
|
||||
try {
|
||||
userId = (JSON.parse(String(payload)) as { user_id?: unknown }).user_id;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (userId && userId === getUserId()) resume({ resetAttempts: true, release: 'unban' });
|
||||
});
|
||||
onSseEvent('feed-delta', () => resume());
|
||||
sseBound = true;
|
||||
}
|
||||
@@ -293,8 +321,14 @@ bindSse();
|
||||
*
|
||||
* `resetAttempts` is for signals that are positive evidence the blocking condition changed
|
||||
* (the host reopening the event), where starting the budget over is warranted.
|
||||
*
|
||||
* `release` names the host action that just happened, and un-parks only the items that were
|
||||
* waiting for exactly that (`parkedFor`). A reopen must not resume a banned guest's queue, and
|
||||
* an unban must not resume uploads into a released gallery — both would just 403 again.
|
||||
*/
|
||||
async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Promise<void> {
|
||||
async function requeueRetriable(
|
||||
options: { resetAttempts?: boolean; release?: 'reopen' | 'unban' } = {}
|
||||
): Promise<void> {
|
||||
const myUserId = getUserId();
|
||||
const all = await storeGetAll();
|
||||
const now = Date.now();
|
||||
@@ -306,6 +340,13 @@ async function requeueRetriable(options: { resetAttempts?: boolean } = {}): Prom
|
||||
// not even on `resetAttempts` (the host reopening the event says nothing about whether
|
||||
// this guest still wants this photo sent). Only `retryItem` clears it.
|
||||
if (entry.cancelled) continue;
|
||||
// Parked waiting on a host action. Only the matching signal releases it, so a plain
|
||||
// reconnect leaves it alone instead of re-pushing the photo at a server whose answer
|
||||
// cannot have changed. A manual "Erneut" bypasses this via `retryItem`.
|
||||
if (entry.parkedFor) {
|
||||
if (entry.parkedFor !== options.release) continue;
|
||||
entry.parkedFor = undefined;
|
||||
}
|
||||
if (options.resetAttempts) {
|
||||
entry.attempts = 0;
|
||||
entry.nextAttemptAt = undefined;
|
||||
@@ -612,6 +653,31 @@ class AuthError extends Error {}
|
||||
*/
|
||||
class LockedError extends Error {}
|
||||
|
||||
/**
|
||||
* The gallery has been RELEASED — `gallery_released`. A subclass of `LockedError` so every
|
||||
* blob-preserving code path below keeps treating it as a reversible lock (the host *can* still
|
||||
* reopen, and losing a photo is the worst outcome).
|
||||
*
|
||||
* What differs is the retry policy. A closed event is a pause the host means to undo, so
|
||||
* auto-resuming on reconnect is right. A released gallery is the end of the event, and in the
|
||||
* normal flow nobody reopens it — so auto-retrying re-pushes a multi-megabyte photo over
|
||||
* cellular on every budget refill, forever, for an answer that will not change, while the guest
|
||||
* is told to tap a camera button that 403s. Items parked this way sit still (see
|
||||
* `awaitingReopen` in `requeueRetriable`) until a real `event-opened` arrives or the guest
|
||||
* retries by hand.
|
||||
*/
|
||||
class ReleasedError extends LockedError {}
|
||||
|
||||
/**
|
||||
* The uploader is banned — `user_banned`. Also a `LockedError` subclass, so the blob survives:
|
||||
* `unban_user` exists and the host's confirm copy promises the photos come back, which the old
|
||||
* generic-`forbidden` classification made impossible for anything mid-flight (blob purged, row
|
||||
* moved to `blocked`, and `blocked` has no retry button).
|
||||
*
|
||||
* Parks still like `ReleasedError` and waits for `user-shown`.
|
||||
*/
|
||||
class BannedError extends LockedError {}
|
||||
|
||||
/** Retry policy for an upload response status. */
|
||||
export type UploadOutcome = 'success' | 'rate_limit' | 'auth' | 'transient' | 'terminal';
|
||||
|
||||
@@ -656,11 +722,36 @@ export function classifyUploadStatus(status: number): UploadOutcome {
|
||||
export function isReversibleLock(status: number, errorCode: unknown): boolean {
|
||||
return (
|
||||
errorCode === 'uploads_locked' ||
|
||||
errorCode === 'gallery_released' ||
|
||||
// A ban is lifted by `unban_user`, and the host UI promises the photos come back. Purging
|
||||
// the blob here made that promise impossible to keep for anything mid-flight.
|
||||
errorCode === 'user_banned' ||
|
||||
errorCode === 'quota_exceeded' ||
|
||||
(status === 403 && errorCode !== 'forbidden')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the reversible-lock bucket, is this the END of the event rather than a pause?
|
||||
*
|
||||
* `gallery_released` means the keepsake has been snapshotted. The blob is still kept (a host
|
||||
* reopen is possible), but the item must stop auto-retrying — see `ReleasedError`. Pure +
|
||||
* exported for the same reason as `isReversibleLock`: it decides whether a guest's photo gets
|
||||
* re-pushed over cellular all night.
|
||||
*/
|
||||
export function isGalleryReleased(errorCode: unknown): boolean {
|
||||
return errorCode === 'gallery_released';
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this a ban (`user_banned`)? Reversible, blob kept — but like a release it will not lift on
|
||||
* its own, so the item parks still and waits for the `user-shown` SSE rather than re-pushing the
|
||||
* photo on every reconnect at a guest who is currently not allowed to upload.
|
||||
*/
|
||||
export function isUserBanned(errorCode: unknown): boolean {
|
||||
return errorCode === 'user_banned';
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate a persisted IndexedDB entry into an in-memory `QueueItem`. Pure + exported so the
|
||||
* field-mapping is unit-testable. The rule that must not regress: `lastModified` MUST be carried
|
||||
@@ -716,6 +807,35 @@ export async function loadQueue(): Promise<void> {
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release parked items whose blocking condition is already over, using the authoritative state
|
||||
* the app fetches at boot.
|
||||
*
|
||||
* `parkedFor` is persisted to IndexedDB, but the only things that cleared it were the LIVE
|
||||
* `event-opened` / `user-shown` SSE events. Those only reach a tab that is open at the moment the
|
||||
* host acts — and the realistic sequence is the opposite one: the guest's photo is parked, they
|
||||
* close the app at the end of the night, and the host lifts the ban or reopens uploads the next
|
||||
* morning. Nothing then ever un-parked the item, so it sat in the queue forever while the toast
|
||||
* had promised "wird gesendet, sobald die Sperre aufgehoben ist".
|
||||
*
|
||||
* Called once per boot with what `/me/context` and the event state actually say, so a park can
|
||||
* never outlive the condition it was waiting on. Cheap: a no-op unless something is parked.
|
||||
*/
|
||||
export async function releaseResolvedParks(state: {
|
||||
banned: boolean;
|
||||
uploadsOpen: boolean;
|
||||
}): Promise<void> {
|
||||
// Each release is scoped to its own signal, exactly as the SSE path is: being unbanned says
|
||||
// nothing about whether the gallery reopened, and vice versa.
|
||||
if (!state.banned) {
|
||||
await requeueRetriable({ resetAttempts: true, release: 'unban' });
|
||||
}
|
||||
if (state.uploadsOpen) {
|
||||
await requeueRetriable({ resetAttempts: true, release: 'reopen' });
|
||||
}
|
||||
await processQueue();
|
||||
}
|
||||
|
||||
/** Outcome of an `addToQueue` call, so the caller can tell the user when a file was NOT
|
||||
* actually queued (deduped, or the queue is full of un-evictable in-flight items). */
|
||||
export type EnqueueResult = 'queued' | 'duplicate' | 'full';
|
||||
@@ -813,6 +933,10 @@ export async function retryItem(id: string): Promise<void> {
|
||||
// And it is the one thing that un-cancels: tapping "Erneut" on a row the guest stopped
|
||||
// themselves is them changing their mind.
|
||||
entry.cancelled = false;
|
||||
// Same for a parked item: an explicit tap is the guest asking us to try anyway (the host may
|
||||
// have reopened or unbanned without this device seeing the SSE). If the condition still
|
||||
// holds the next response re-parks it, so this cannot become a loop.
|
||||
entry.parkedFor = undefined;
|
||||
await storePut(entry);
|
||||
|
||||
queueItems.update((items) =>
|
||||
@@ -1124,7 +1248,16 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// portal) must NOT purge the blob — losing a photo is the worst outcome, and
|
||||
// 403 is the reversible-lock status here.
|
||||
if (isReversibleLock(xhr.status, body?.error)) {
|
||||
settle(() => reject(new LockedError(body?.message || 'Event ist geschlossen.')));
|
||||
const msg = body?.message || 'Event ist geschlossen.';
|
||||
settle(() =>
|
||||
reject(
|
||||
isGalleryReleased(body?.error)
|
||||
? new ReleasedError(msg)
|
||||
: isUserBanned(body?.error)
|
||||
? new BannedError(msg)
|
||||
: new LockedError(msg)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
// Any other 4xx the server will keep rejecting (banned, too large, wrong
|
||||
@@ -1190,20 +1323,31 @@ async function uploadItem(id: string): Promise<void> {
|
||||
// the hourly media reclaim puts them back under it). KEEP the blob and park the item
|
||||
// as retryable so it survives until then. `event-opened` and the `feed-delta`
|
||||
// reconnect both auto-resume it; a manual "Erneut" also works. Never purge here.
|
||||
const exhausted = chargeAttempt(entry);
|
||||
// Neither a release nor a ban lifts on its own, so charging an attempt — and with it
|
||||
// the backoff ladder and budget refill that drive automatic re-pushes — buys nothing
|
||||
// but bandwidth. Park those still and wait for the host action.
|
||||
const parkedFor: 'reopen' | 'unban' | undefined =
|
||||
e instanceof ReleasedError ? 'reopen' : e instanceof BannedError ? 'unban' : undefined;
|
||||
const exhausted = parkedFor ? false : chargeAttempt(entry);
|
||||
entry.status = 'error';
|
||||
entry.error = withRetryHint(e.message, exhausted);
|
||||
entry.parkedFor = parkedFor;
|
||||
entry.error = parkedFor ? e.message : withRetryHint(e.message, exhausted);
|
||||
await storePut(entry);
|
||||
updateItemStatus(id, 'error', entry.error);
|
||||
// Say it out loud. The queue list is only mounted on /upload and the composer sends
|
||||
// the guest straight to /feed, so this message otherwise lands in a store that
|
||||
// nothing on screen renders — the photo just never appears and the guest, with no
|
||||
// operator to ask, assumes it worked.
|
||||
toast(
|
||||
`${entry.fileName}: ${e.message} Du findest den Upload über den Kamera-Button.`,
|
||||
'warning',
|
||||
6000
|
||||
);
|
||||
//
|
||||
// For a release, be explicit that the photo is NOT lost and NOT coming back on its
|
||||
// own — that is the whole difference the guest needs to act on.
|
||||
const parkedHint =
|
||||
parkedFor === 'reopen'
|
||||
? ' Dein Foto bleibt auf diesem Gerät gespeichert — frag die Gastgeber, ob sie die Galerie noch einmal öffnen.'
|
||||
: parkedFor === 'unban'
|
||||
? ' Dein Foto bleibt auf diesem Gerät gespeichert und wird gesendet, sobald die Sperre aufgehoben ist.'
|
||||
: ' Du findest den Upload über den Kamera-Button.';
|
||||
toast(`${entry.fileName}: ${e.message}${parkedHint}`, 'warning', parkedFor ? 9000 : 6000);
|
||||
throw e;
|
||||
}
|
||||
if (e instanceof AuthError) {
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
import Toaster from '$lib/components/Toaster.svelte';
|
||||
import { showBottomNav } from '$lib/ui-store';
|
||||
import { isAuthenticated } from '$lib/auth';
|
||||
import { queueItems, isProcessing, loadQueue, rateLimitRetryAt } from '$lib/upload-queue';
|
||||
import {
|
||||
queueItems,
|
||||
isProcessing,
|
||||
loadQueue,
|
||||
rateLimitRetryAt,
|
||||
releaseResolvedParks
|
||||
} from '$lib/upload-queue';
|
||||
import { privacyNote } from '$lib/privacy-note-store';
|
||||
import { refreshQuota } from '$lib/quota-store';
|
||||
import { onSseEvent } from '$lib/sse';
|
||||
@@ -88,6 +94,16 @@
|
||||
galleryReleased: ctx.gallery_released
|
||||
});
|
||||
isBanned.set(ctx.is_banned);
|
||||
// Now that we know the AUTHORITATIVE state, release anything the queue parked
|
||||
// waiting on a host action that has already happened. 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: the guest closes the app, the host
|
||||
// lifts the ban or reopens uploads later. Without this the photo stays parked
|
||||
// forever, which is exactly the "my pictures never sent" the host gets asked about.
|
||||
void releaseResolvedParks({
|
||||
banned: ctx.is_banned,
|
||||
uploadsOpen: !ctx.uploads_locked && !ctx.gallery_released
|
||||
});
|
||||
} catch {
|
||||
// Cross-cutting hydration on boot — failure is non-fatal; users without
|
||||
// a session land on /join anyway, and the per-page mount will retry.
|
||||
@@ -115,8 +131,7 @@
|
||||
// `{ user_id: UUID }`, broadcast to everyone (it also evicts the banned user's cards
|
||||
// from every feed), so only OUR id means us. Without this, `isBanned` was seeded once
|
||||
// on boot and never moved — a guest banned mid-party kept the full UI and learned
|
||||
// about it one 403 toast at a time, which reads as the app being broken. The ban is
|
||||
// one-way here on purpose: an unban has no SSE, and the next `/me/context` clears it.
|
||||
// about it one 403 toast at a time, which reads as the app being broken.
|
||||
onSseEvent('user-hidden', (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
@@ -125,6 +140,18 @@
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
}),
|
||||
// And the mirror. This used to be one-way ("an unban has no SSE, and the next
|
||||
// `/me/context` clears it") because `unban_user` broadcast nothing at all — so a
|
||||
// guest whose ban was lifted kept the banned UI until they happened to reload,
|
||||
// which for a PWA with no URL bar is not a thing they can easily do.
|
||||
onSseEvent('user-shown', (data) => {
|
||||
try {
|
||||
const payload = JSON.parse(data) as { user_id: string };
|
||||
if (payload.user_id === getUserId()) isBanned.set(false);
|
||||
} catch {
|
||||
// Malformed payload — discard; nothing actionable for the user.
|
||||
}
|
||||
}),
|
||||
// Reflect a host closing/reopening uploads live, so the composer switches to a
|
||||
// locked state immediately instead of a guest finding out via a rejected upload.
|
||||
// `event-closed` fires for BOTH a plain lock and a gallery release (release ⇒ lock),
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -445,6 +445,12 @@
|
||||
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||
unsubs.push(onSseEvent('user-hidden', handleUserHidden));
|
||||
// An unban restores that guest's photos to the eligible set. The periodic `reconcile`
|
||||
// below would pick this up on its own, but this is the projector nobody is standing at —
|
||||
// waiting a whole interval to un-hide photos the host has just decided are fine again is
|
||||
// needlessly visible. A full reconcile is the only correct response anyway: the hidden
|
||||
// cards were dropped from local state, so there is nothing to restore in place.
|
||||
unsubs.push(onSseEvent('user-shown', () => void reconcile()));
|
||||
unsubs.push(onSseEvent('feed-delta', handleFeedDelta));
|
||||
// Open the stream ourselves — a kiosk/projector loads /diashow directly (never
|
||||
// via /feed), so we can't rely on another page having opened the singleton
|
||||
@@ -515,8 +521,8 @@
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-4 left-4 max-w-xs rounded-md bg-black/60 px-3 py-2 text-left text-xs text-white/70 backdrop-blur"
|
||||
>
|
||||
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische
|
||||
Bildschirmsperre am Gerät deaktivieren.
|
||||
Dieser Browser kann den Bildschirm nicht wachhalten. Bitte die automatische Bildschirmsperre
|
||||
am Gerät deaktivieren.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -126,9 +126,7 @@
|
||||
// they did, the guest got a green success toast, a consumed single-use ticket, one
|
||||
// of three daily slots gone, and nothing in their Downloads. Now the mint fails
|
||||
// honestly and lands in the `catch` below.
|
||||
const { ticket } = await api.post<{ ticket: string }>(
|
||||
`/export/ticket?kind=${kind}`
|
||||
);
|
||||
const { ticket } = await api.post<{ ticket: string }>(`/export/ticket?kind=${kind}`);
|
||||
downloadFrame.src = `${endpoint}?ticket=${encodeURIComponent(ticket)}`;
|
||||
// An iframe download produces NO visible change: no spinner, no navigation, and
|
||||
// on mobile often no browser chrome either. Without a word here the guest cannot
|
||||
|
||||
@@ -308,7 +308,11 @@
|
||||
unsubscribers.push(
|
||||
onSseEvent('new-upload', (data) => {
|
||||
try {
|
||||
const upload: FeedUpload = JSON.parse(data);
|
||||
// The `new-upload` payload is the backend's `UploadDto`, which carries `hashtags`
|
||||
// on top of what `FeedUpload` declares — needed for the filter check below.
|
||||
// Typed explicitly rather than widening `FeedUpload`, because the feed's own
|
||||
// rows (from `/feed`) genuinely do not include them.
|
||||
const upload: FeedUpload & { hashtags?: string[] } = JSON.parse(data);
|
||||
// GRID view must NOT prepend live. Its rows are POSITIONAL windows
|
||||
// (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts
|
||||
// every tile by one slot: each row's keyed `{#each}` then sees a different set of
|
||||
@@ -329,6 +333,12 @@
|
||||
// row, and a duplicate id in a keyed `{#each}` is a thrown error, not a
|
||||
// cosmetic glitch.
|
||||
if (uploads.some((u) => u.id === upload.id)) return;
|
||||
// Respect the active filter (H13). `uploads` IS the filtered set — the server
|
||||
// applied `filterParams()` — but this handler prepended unconditionally, so a
|
||||
// guest who filtered to #tanzflaeche had that filter quietly destroyed within
|
||||
// minutes by everyone else's uploads, with no indication and no way back short
|
||||
// of toggling the chip. The payload carries `hashtags`, so we can just check.
|
||||
if (selectedHashtag && !upload.hashtags?.includes(selectedHashtag)) return;
|
||||
uploads = [upload, ...uploads];
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -372,6 +382,17 @@
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
// The ban was lifted — their cards must come back. Unlike a hide we cannot do this in
|
||||
// place: the cards were filtered out of `uploads`, so there is nothing left to restore
|
||||
// from. Flag the feed stale and let the existing tap-to-refresh pill resync, which is
|
||||
// the same treatment a truncated delta gets and keeps the guest's scroll position.
|
||||
//
|
||||
// Without this the unban was invisible to every open feed and to the unattended
|
||||
// projector until somebody reloaded by hand — while the host's confirm copy promised
|
||||
// the photos were back.
|
||||
onSseEvent('user-shown', () => {
|
||||
feedStale = true;
|
||||
}),
|
||||
// Patch the single affected card in place from the SSE payload instead of
|
||||
// refetching page 1 — a busy event fires these constantly and a full reload
|
||||
// would yank every scrolled-down user back to the top on each reaction.
|
||||
@@ -406,9 +427,39 @@
|
||||
return;
|
||||
}
|
||||
if (delta.uploads.length) {
|
||||
// `/feed/delta` takes NO filter parameters, so its rows are the unfiltered
|
||||
// event. Merging them into a filtered view is the same defect as the
|
||||
// `new-upload` prepend above (H13) — and here we cannot check hashtags,
|
||||
// because the delta rows do not carry them.
|
||||
//
|
||||
// So when a filter is active, don't merge: flag the feed stale and let the
|
||||
// existing tap-to-refresh pill re-run page 1 under `filterParams()`. Same
|
||||
// treatment a truncated delta gets, and it keeps the guest's scroll.
|
||||
// Dedupe FIRST, in both branches. `delta.uploads.length > 0` is not
|
||||
// evidence that anything NEW arrived: the delta cursor boundary is
|
||||
// inclusive and `sse.ts` deliberately rewinds `lastEventTime` to an
|
||||
// upload's `created_at`, so a delta routinely re-returns rows the
|
||||
// stream already delivered. The filtered branch skipped this check
|
||||
// entirely and set `feedStale` on EVERY delta — and the backstop
|
||||
// polls every 60-120s, so a guest who tapped a hashtag got a "Neue
|
||||
// Beiträge" pill they could never clear, each tap costing a full
|
||||
// filtered refetch. That trains guests to ignore the one control
|
||||
// that means something. It also fired for any other guest's
|
||||
// non-matching upload, which by construction is never in the
|
||||
// filtered `uploads`.
|
||||
const seen = new Set(uploads.map((u) => u.id));
|
||||
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
|
||||
if (fresh.length) uploads = [...fresh, ...uploads];
|
||||
if (fresh.length) {
|
||||
if (selectedHashtag || activeFilters.length) {
|
||||
// Still cannot MERGE under a filter — `/feed/delta` takes no
|
||||
// filter params and its rows carry no hashtags, so we cannot
|
||||
// tell which belong in this view. Flagging stale is right;
|
||||
// doing it for rows already on screen was not.
|
||||
feedStale = true;
|
||||
} else {
|
||||
uploads = [...fresh, ...uploads];
|
||||
}
|
||||
}
|
||||
}
|
||||
// A delta reconciles new uploads and deletions, but not like/comment
|
||||
// counts that changed on already-visible cards while we were
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { api, ApiError } from '$lib/api';
|
||||
import { setAuth, getPin, getToken, clearPin } from '$lib/auth';
|
||||
import { setAuth, getPin, getToken, clearPin, getDisplayName } from '$lib/auth';
|
||||
import { markGuideSeen } from '$lib/onboarding';
|
||||
import { browser } from '$app/environment';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
@@ -80,10 +80,25 @@
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
error = e.message;
|
||||
// A wrong PIN here often means the locally-cached PIN is stale (a host reset it
|
||||
// while this device was offline and missed the `pin-reset` SSE). Drop the cached
|
||||
// value so it doesn't keep pre-filling the field with the dead PIN.
|
||||
if (e.status === 401) clearPin();
|
||||
// A wrong PIN CAN mean the locally-cached PIN is stale (a host reset it while this
|
||||
// device was offline and missed the `pin-reset` SSE), and then dropping it stops the
|
||||
// field pre-filling with a dead value. `+layout.svelte` already handles the online
|
||||
// case; this is the offline backstop.
|
||||
//
|
||||
// But it must be narrow, because the backend returns the SAME 401 for a wrong PIN
|
||||
// and an UNKNOWN NAME (deliberately — it closes an enumeration and timing oracle).
|
||||
// Clearing on any 401 meant a guest who mistyped their own name lost the only copy
|
||||
// of their PIN: localStorage is where it lives, the server keeps only the bcrypt,
|
||||
// and rejoining under the same name 409s. One typo, permanently locked out of their
|
||||
// own account, needing a host with a dashboard open.
|
||||
//
|
||||
// So clear only when the evidence actually points at a stale cache: the name they
|
||||
// submitted is the one this device belongs to, AND the PIN that was rejected is the
|
||||
// cached one. Any other 401 leaves stored state untouched.
|
||||
const submittedOwnName =
|
||||
getDisplayName()?.trim().toLowerCase() === displayName.trim().toLowerCase();
|
||||
const submittedCachedPin = getPin() !== null && pin.trim() === getPin();
|
||||
if (e.status === 401 && submittedOwnName && submittedCachedPin) clearPin();
|
||||
} else {
|
||||
error = 'Ein Fehler ist aufgetreten.';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user