Three features bundled into one release: - rate-limit /auth/login, /register, /me/password (token bucket, 5 req/sec sustained with 10-request burst by default; 429 + Retry-After header on hit; tracing::warn! per hit so operators see attack patterns; AUTH_RATE_PER_SEC / AUTH_RATE_BURST env knobs) - handle SIGTERM for graceful container stops (replaces bare ctrl_c() with a select over ctrl_c + SignalKind::terminate() so docker compose stop runs the daemon shutdown path instead of letting Chromium leak past SIGKILL) - clear session.user on 401 from any API call (setOn401Hook in api/client.ts, registered from session.svelte.ts gated on $app/environment::browser so the SSR bundle never installs it; fixes "logged in but no bookmarks/collections" mid-session expiry state) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
116 lines
3.9 KiB
TypeScript
116 lines
3.9 KiB
TypeScript
// All backend calls go through this module. Components and routes import
|
|
// the typed helpers below — they do not call fetch directly.
|
|
|
|
const BASE = import.meta.env?.VITE_API_BASE ?? '/api';
|
|
|
|
/**
|
|
* Builds an absolute URL to the streaming `/files/{key}` endpoint so
|
|
* components can use it directly in `<img src>` etc., without
|
|
* reconstructing the API base in each call site.
|
|
*/
|
|
export function fileUrl(key: string): string {
|
|
return `${BASE}/v1/files/${key}`;
|
|
}
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public readonly status: number,
|
|
public readonly code: string,
|
|
message: string
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiError';
|
|
}
|
|
}
|
|
|
|
type ErrorEnvelope = { error?: { code?: unknown; message?: unknown } };
|
|
|
|
/**
|
|
* Optional hook fired the first moment `request()` observes a 401 on
|
|
* any endpoint. Used by the session store to clear the cached user
|
|
* when the server reports the session is no longer valid (expired
|
|
* cookie, rotated server-side, password changed on another device).
|
|
*
|
|
* Set to `null` (or `undefined`) to disable. Tests that don't want
|
|
* the side effect should leave it unset.
|
|
*/
|
|
let on401Hook: (() => void) | null = null;
|
|
|
|
export function setOn401Hook(handler: (() => void) | null): void {
|
|
on401Hook = handler;
|
|
}
|
|
|
|
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
// Forward credentials (session cookie) explicitly so cross-origin
|
|
// deployments — those configured via CORS_ALLOWED_ORIGINS — keep
|
|
// working. For same-origin requests this is a no-op compared to the
|
|
// default 'same-origin', so the same-origin happy path is
|
|
// unchanged.
|
|
const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
|
|
if (!res.ok) {
|
|
let code = 'http_error';
|
|
let message = `${res.status} ${res.statusText}`;
|
|
const ct = res.headers.get('content-type') ?? '';
|
|
try {
|
|
if (ct.includes('application/json')) {
|
|
const body = (await res.json()) as ErrorEnvelope;
|
|
if (body?.error) {
|
|
if (typeof body.error.code === 'string' && body.error.code) {
|
|
code = body.error.code;
|
|
}
|
|
if (typeof body.error.message === 'string' && body.error.message) {
|
|
message = body.error.message;
|
|
}
|
|
}
|
|
} else {
|
|
const text = await res.text();
|
|
if (text) message = text;
|
|
}
|
|
} catch {
|
|
// Body wasn't parseable; keep the http_error fallback.
|
|
}
|
|
if (res.status === 401 && on401Hook) {
|
|
// Fire before throwing so the session store updates even
|
|
// if the caller swallows the ApiError (e.g. the *OrEmpty
|
|
// wrappers used by guest-rendering pages).
|
|
try {
|
|
on401Hook();
|
|
} catch (e) {
|
|
console.error('on401 hook threw:', e);
|
|
}
|
|
}
|
|
throw new ApiError(res.status, code, message);
|
|
}
|
|
// Any empty body (not just 204) returns undefined — the manga-add
|
|
// endpoint, for instance, signals create-vs-already-present via
|
|
// 201/200 with no body, and callers typed `request<void>` would
|
|
// otherwise blow up on `res.json()` parsing an empty string.
|
|
if (res.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
const text = await res.text();
|
|
if (!text) {
|
|
return undefined as T;
|
|
}
|
|
return JSON.parse(text) as T;
|
|
}
|
|
|
|
export type Manga = {
|
|
id: string;
|
|
title: string;
|
|
status: MangaStatus;
|
|
alt_titles: string[];
|
|
description: string | null;
|
|
cover_image_path: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type MangaStatus = 'ongoing' | 'completed';
|
|
|
|
export type Page = {
|
|
limit: number;
|
|
offset: number;
|
|
total: number | null;
|
|
};
|