// Der EINZIGE Ort, an dem fetch steht. // // Und die Stelle, an der der Fivemanage-Anlauf gescheitert ist: dessen // http-util.ts warf jeden Fehlschlag als `new Error(…)`, die Hooks prüften auf // `instanceof ApiError` — und damit wurde jeder Fehler lautlos verschluckt. // Deshalb gibt es hier genau eine Fehlerklasse, und `request` wirft // ausschließlich diese. Wer sie fängt, hat immer einen Text zum Anzeigen. export class ApiError extends Error { readonly status: number constructor(message: string, status: number) { super(message) this.name = 'ApiError' this.status = status } } /** Kein Netz, kein Server, DNS tot — alles, was nie beim Dienst ankam. */ export class NetworkError extends ApiError { constructor(message: string) { super(message, 0) this.name = 'NetworkError' } } async function request(path: string, init: RequestInit = {}): Promise { let response: Response try { response = await fetch(path, { // Das Sitzungs-Cookie ist httpOnly; ohne diese Zeile schickt der // Browser es nicht mit, und jede Antwort wäre 401. credentials: 'same-origin', ...init, headers: { ...(init.body ? { 'content-type': 'application/json' } : {}), ...init.headers, }, }) } catch (err) { // fetch wirft nur, wenn die Anfrage gar nicht stattgefunden hat. Das // ist ein anderer Fall als "der Dienst sagt nein", und er verdient // einen anderen Satz. throw new NetworkError( `Der Dienst ist nicht erreichbar (${err instanceof Error ? err.message : 'unbekannt'})`, ) } const text = await response.text() let data: unknown = null if (text) { try { data = JSON.parse(text) } catch { // Der Dienst antwortet auf allen API-Wegen mit JSON. Kommt hier // etwas anderes an, ist der Proxy davor oder die Sitzung schuld — // dann steht der Anfang der Antwort in der Meldung, statt dass // "unerwarteter Fehler" auf dem Bildschirm steht. if (!response.ok) { throw new ApiError( `HTTP ${response.status}: ${text.slice(0, 140)}`, response.status, ) } throw new ApiError('Die Antwort war kein JSON', response.status) } } if (!response.ok) { const message = data && typeof data === 'object' && typeof (data as any).error === 'string' ? (data as any).error : `HTTP ${response.status}` throw new ApiError(message, response.status) } return data as T } const get = (path: string) => request(path) const post = (path: string, body?: unknown) => request(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) }) const del = (path: string) => request(path, { method: 'DELETE' }) // -- Die Formen, die der Dienst wirklich liefert ------------------------------- // // Abgeschrieben aus routes/dash.ts, nicht erfunden. Wo der Server // Datenbankspalten durchreicht, heißen sie hier genauso (can_delete, // created_at) — ein hübscherer Name auf halbem Weg wäre eine Übersetzung, die // beim nächsten Feld vergessen wird. export type User = { id: number; username: string } export type MediaItem = { id: number path: string size: number sha256: string mime: string token_id: number | null created_at: number updated_at: number url: string } export type MediaPage = { total: number limit: number offset: number items: MediaItem[] } export type Stats = { files: number bytes: number tokens: number folders: { folder: string; files: number; bytes: number }[] publicUrl: string } export type TokenRow = { id: number name: string prefix: string can_delete: number created_at: number last_used_at: number | null } /** Der Klartext kommt genau einmal — beim Anlegen. Danach steht nur noch der * Hash in der Datenbank, und auch der Dienst kann ihn nicht mehr zeigen. */ export type NewToken = { id: number name: string prefix: string canDelete: boolean token: string } export type ThumbLauf = { erzeugt: number vorhanden: number uebersprungen: number fehler: number gesamt: number } /** Die Adresse der Vorschau zu einem Pfad. * * Ob es sie gibt, weiss nur das Dateisystem — deshalb faellt die Galerie bei * 404 auf das Vollbild zurueck, statt hier zu fragen. */ export const vorschauUrl = (path: string) => '/t/' + path.split('/').map(encodeURIComponent).join('/') export const api = { login: (username: string, password: string) => post<{ user: User }>('/api/dash/auth/login', { username, password }), logout: () => post<{ ok: true }>('/api/dash/auth/logout'), me: () => get<{ user: User }>('/api/dash/auth/me'), changePassword: (current: string, next: string) => post<{ ok: true }>('/api/dash/auth/password', { current, next }), media: (query: string, limit: number, offset: number) => get( `/api/dash/media?query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`, ), deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`), deleteMediaBulk: (ids: number[]) => post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }), stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'), createToken: (name: string, prefix: string, canDelete: boolean) => post('/api/dash/tokens', { name, prefix, canDelete }), deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`), }