Two independent lines of production hardening diverged at7d0334band attacked overlapping problems. Neither was a superset, so this is a merge of substance rather than a fast-forward: every conflict was resolved on the merits, and the losing side's intent was re-checked against the winner rather than assumed. MIGRATIONS. The branch's 021/022/023 collided with main's already-DEPLOYED 021_hashtag_counts_respect_bans and 022_client_upload_idempotency. Renumbered to 023/024/025 in a prior commit — main's versions are applied in production, so their version numbers are immutable and the branch's had to move. Verified by running the full sqlx::test suite, which applies the whole chain from scratch. RESOLVED IN MAIN'S FAVOUR (the branch would have regressed these): * upload-queue.ts wholesale — the branch's copy has ZERO client_upload_id references, so taking it would have silently destroyed end-to-end upload idempotency, the one thing standing between a lost response and a duplicate photo charged twice against the guest's quota. * maintenance.rs supervisor — the branch replaced it with a bare tokio::spawn, where one panic silently stops session pruning, media reclaim, the temp sweep and both HashMap prunes, permanently and with no log line. * The decode-budget probe on spawn_blocking, not inline on the async runtime. * feed/+page.svelte's 8s debounce + jitter + max-wait + hidden-tab deferral, against the branch's naive 800ms — at 100 guests the branch's version walks straight into the per-user feed rate limit. * db.rs pool tuning, /uploaders, and the docker-compose deployment story. * ONE /health, still DB-backed. The branch's split (dependency-free liveness + DB-backed readiness) is defensible, but a constant-"ok" /health is the exact defectfaea555fixed and verified live, its motive (Caddy's boot gate) is already covered by app depends_on db: service_healthy, and the two handlers were the same SELECT 1 under two names. TAKEN FROM THE BRANCH: * The large-PNG OOM guard and its bounded-retry counter (023). Together these turn a single upload that can OOM-kill a 1G container into a bounded failure instead of an infinite restart loop under `restart: unless-stopped`. * 024_feed_scalar_counts — the feed no longer aggregates the whole event per page. Pure SQL; column names, order and types are unchanged by design. * The admin-lockout fix: look the admin up BY ROLE, never by name. 025 also frees any guest already squatting on a reserved name. * PIN lockout tier ordering, bounded caption/hashtag reads, SSE ticket caps, PoolTimedOut -> 503 + Retry-After, and the ffmpeg stderr drain. * backfill_video_posters, which main lacked entirely. * TempFileGuard, plus sweep_orphan_originals wired into main's SUPERVISED loop (not the branch's bare one) — it reclaims final-named originals whose commit never happened, a class main's .tmp-only sweep structurally cannot see. * shouldAbortForStall, hand-ported into main's upload-queue.ts since that file was resolved to main. Widens the watchdog at loadend instead of disarming it, bounding a half-open socket at 2 minutes rather than handing the window to xhr.timeout (5-60 min) with the whole queue's `processing` latch held. ALSO: RUST_LOG and EXPORT_PATH pinned in compose. The code fallback was `debug` (a line per request, all night) and EXPORT_PATH was the one path with a mount-shaped default that nothing validated. Verified: cargo check --all-targets, cargo clippy (clean), 144/144 backend tests against a live Postgres including upload_idempotency and upload_concurrency, 51/51 vitest, svelte-check 0 errors, eslint clean, vite build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
140 lines
5.0 KiB
TypeScript
140 lines
5.0 KiB
TypeScript
import { getToken, clearAuth } from './auth';
|
||
|
||
const BASE = '/api/v1';
|
||
|
||
export class ApiError extends Error {
|
||
status: number;
|
||
code: string;
|
||
|
||
constructor(status: number, code: string, message: string) {
|
||
super(message);
|
||
this.status = status;
|
||
this.code = code;
|
||
}
|
||
}
|
||
|
||
const TIMEOUT_MS = 20_000;
|
||
|
||
/** Pages that ARE the recovery flow — redirecting from them would loop. */
|
||
const AUTH_ROUTES = ['/join', '/recover'];
|
||
|
||
/**
|
||
* Send a guest whose session died back to the join screen.
|
||
*
|
||
* 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
|
||
* create an import cycle. A full document load is also the more correct behaviour after a
|
||
* session loss — it resets every module-level store, which is exactly what we want, and the
|
||
* queued upload blobs live in IndexedDB so they survive it.
|
||
*/
|
||
function redirectToJoin(): void {
|
||
if (typeof window === 'undefined') return;
|
||
const path = window.location.pathname;
|
||
if (AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`))) return;
|
||
window.location.assign('/join');
|
||
}
|
||
|
||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||
const headers: Record<string, string> = {};
|
||
const token = getToken();
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
if (body !== undefined) {
|
||
headers['Content-Type'] = 'application/json';
|
||
}
|
||
|
||
// Abort hung requests so a dead connection surfaces as a friendly error
|
||
// instead of a spinner that never resolves.
|
||
//
|
||
// The timer must stay armed until the BODY has been read, not just the headers.
|
||
// `fetch` resolves as soon as the response head arrives, so clearing it in a `finally`
|
||
// around the fetch left `res.text()` below completely uncovered — and no longer
|
||
// abortable, since the controller had already been disarmed. An upstream that sends
|
||
// headers and then stalls the body (the shape of a half-dead proxy, or of the pool
|
||
// saturation this same release adds shedding for) hung that call forever.
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`${BASE}${path}`, {
|
||
method,
|
||
headers,
|
||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||
signal: controller.signal
|
||
});
|
||
} catch (e) {
|
||
clearTimeout(timer);
|
||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||
throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.');
|
||
}
|
||
throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.');
|
||
}
|
||
|
||
if (res.status === 204) {
|
||
// Must clear on this path too, or every no-content request (logout, delete, like)
|
||
// leaks a live 20 s timer.
|
||
clearTimeout(timer);
|
||
return undefined as T;
|
||
}
|
||
|
||
// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
|
||
// it directly would throw an opaque SyntaxError. Read text, parse defensively.
|
||
let raw: string;
|
||
try {
|
||
raw = await res.text();
|
||
} catch (e) {
|
||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||
throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.');
|
||
}
|
||
throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.');
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
|
||
let data: { error?: string; message?: string } | unknown = null;
|
||
if (raw) {
|
||
try {
|
||
data = JSON.parse(raw);
|
||
} catch {
|
||
data = null;
|
||
}
|
||
}
|
||
|
||
if (!res.ok) {
|
||
// 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) {
|
||
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
|
||
// onMount (which does not re-run), and a standalone PWA has no URL bar — so
|
||
// there is no way back to /join. Real triggers mid-event are a host PIN reset
|
||
// (which revokes that guest's sessions) and a redeployed JWT_SECRET.
|
||
// Queued upload blobs survive in IndexedDB and are picked up again after
|
||
// re-joining — via the `onSetAuth` hook in upload-queue.ts, NOT the boot-time
|
||
// call in +layout.svelte: this redirect lands on /join with no token, so the
|
||
// layout's `if (getToken())` skips it, and the subsequent recover navigates
|
||
// with `goto()`, which never re-runs `onMount`.
|
||
redirectToJoin();
|
||
}
|
||
const d = (data ?? {}) as { error?: string; message?: string };
|
||
throw new ApiError(
|
||
res.status,
|
||
d.error ?? 'unknown',
|
||
d.message ?? `Serverfehler (${res.status}).`
|
||
);
|
||
}
|
||
|
||
return data as T;
|
||
}
|
||
|
||
export const api = {
|
||
get: <T>(path: string) => request<T>('GET', path),
|
||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
|
||
delete: <T>(path: string) => request<T>('DELETE', path)
|
||
};
|