// 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 /** Kam dieses Konto ueber Discord herein? */ discord: boolean discordName: string | null /** Fertige Adresse vom Dienst — das URL-Schema von Discord steht dort und * nicht hier. Null, wenn es kein Discord-Konto ist. */ avatar: string | null } 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 /** Wie oft die Datei oeffentlich geholt wurde. */ abrufe: number zuletzt_abgerufen: number | null /** Laenge in Sekunden — nur bei Video und Ton, sonst null. */ dauer: number | null } 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 PapierkorbEintrag = { id: number path: string size: number sha256: string mime: string geloescht: number von: string /** Liegt die Datei wirklich noch im Papierkorb? Der Eintrag steht in der * Datenbank, die Datei daneben auf der Platte — zwei Wahrheiten, die * auseinanderlaufen koennen. */ dateiDa: boolean } export type PapierkorbSeite = { items: PapierkorbEintrag[] n: number bytes: number haltbarTage: number /** Eintraege ohne Datei. Sollte 0 sein. */ verwaist: number } export type Speicher = { nachArt: { art: string; files: number; bytes: number }[] groesste: MediaItem[] aelteste: MediaItem[] doppelte: { sha256: string; n: number; bytes: number; pfade: string }[] verschwendet: number stufen: { tage: number; files: number; bytes: number }[] /** Was seit dem Einbau des Zaehlers NIE geholt wurde. */ nieGeholt: { n: number; bytes: number } beliebteste: MediaItem[] } export type TokenRow = { id: number name: string prefix: string can_delete: number created_at: number last_used_at: number | null /** Groesste erlaubte EINZELNE Datei. Null = die Grenze des Dienstes. */ max_bytes: number | null /** Wieviel dieser Token INSGESAMT halten darf. Null = unbegrenzt. */ quota_bytes: number | null /** Erlaubte Arten, mit Komma. Leer = alle. */ arten: string expires_at: number | null abgelaufen: boolean /** Wieviel mit DIESEM Token abgelegt wurde. */ dateien: number bytes: number /** Wann zuletzt etwas dabei herauskam — anders als `last_used_at`, das * auch ein Anklopfen ohne Ergebnis mitzaehlt. */ zuletztAbgelegt: 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 } /** Ein Ordner mit KUMULATIVEN Zahlen: "vehicles" enthält, was in * "vehicles/showroom" liegt. `tiefe` ist die Einrückung im Baum. */ export type FolderRow = { folder: string; files: number; bytes: number; tiefe: number } export type Folders = { items: FolderRow[] gesamt: { files: number; bytes: number } wurzel: { files: number; bytes: number } } /** Der billige Fuehler fuer die automatische Auffrischung. */ export type Stand = { medien: number; neuest: number; ereignis: number } export type Uebersicht = { files: number bytes: number heuteHoch: number wocheHoch: number publicUrl: string filePrefix: string letzte: MediaEvent[] bilder: MediaItem[] ordner: { folder: string; files: number; bytes: number }[] tokens: { id: number; name: string; prefix: string; can_delete: number; last_used_at: number | null }[] discord: boolean vorlagen: Vorlage[] } export type Vorlage = { name: string; muster: string } /** Ein Konto. `weg` sagt, worueber jemand hereinkommt — die wichtigere Auskunft * als der Name, denn ein Discord-Konto haengt an einer Rolle, die jemand * anders vergibt. */ export type BenutzerRow = { id: number username: string created_at: number zuletzt_da: number | null weg: 'discord' | 'passwort' discordName: string | null avatar: string | null sitzungen: number } /** Eine Freigabe: ein Ordner, den man ohne Anmeldung ansehen kann. * * Freigegeben ist die LISTE, nicht der Inhalt — die Dateien sind ohnehin * oeffentlich. Das steht so auch in der Karte, weil das Gegenteil zu glauben * gefaehrlich waere. */ export type FreigabeRow = { id: number schluessel: string ordner: string titel: string created_at: number aufrufe: number zuletzt: number | null url: string } export type VerschiebeErgebnis = { verschoben: number fehlgeschlagen: number ergebnisse: { path: string; ok: boolean; neu?: string; error?: string }[] } export type Art = '' | 'bild' | 'video' | 'ton' | 'dokument' | 'andere' export type Arten = Record export type Sortierung = 'geaendert' | 'angelegt' | 'pfad' | 'groesse' | 'abrufe' | 'geholt' export type Richtung = 'auf' | 'ab' export type Verwaiste = { dateiOhneDatensatz: { anzahl: number; beispiele: string[] } datensatzOhneDatei: { anzahl: number; beispiele: string[] } /** Vorschauen UND sparsame Fassungen, deren Original es nicht mehr gibt. * Die Beispiele tragen den Ordner vorn — thumbs/… oder webp/…. */ abgeleitetOhneBild: { anzahl: number; beispiele: string[] } } /** Was EIN STAPEL „sparsame Fassungen nachziehen" zurueckmeldet. * * `fertig` ist der Punkt: der Server rechnet hoechstens ein paar hundert * Stueck je Aufruf, weil der Proxy nach 90 Sekunden aufgibt. Solange `fertig` * falsch ist, steht in `offen`, wieviele noch fehlen. */ export type WebpLauf = { erzeugt: number vorhanden: number uebersprungen: number lohntNicht: number fehler: number gesamt: number offen: number fertig: boolean vorher: number nachher: number gespart: number } /** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */ export type LetzteSicherung = { at: number ok: boolean dateien?: number groesse?: number /** true = in der Nextcloud, false = nur lokal, undefined = unbekannt. */ hoch?: boolean fehler?: string } | null export type Einstellungen = { vorlagen: Vorlage[] meldungen: { hatWebhook: boolean; anlaesse: string[] } sicherung: { basis: string benutzer: string ordner: string behalten: number hatPasswort: boolean bereit: boolean letzte: LetzteSicherung } discord: { clientId: string guildId: string /** Wer EINE davon hat, darf herein. */ roleIds: string[] /** Nur OB eines hinterlegt ist — das Geheimnis selbst geht nie heraus. */ hatGeheimnis: boolean bereit: boolean redirectUri: string ausDerUmgebung: { clientId: boolean clientSecret: boolean guildId: boolean roleIds: boolean } } } export type EventKind = 'upload' | 'replace' | 'delete' | 'move' | 'rename' export type MediaEvent = { id: number at: number kind: EventKind path: string size: number | null actor_kind: 'token' | 'user' | 'unbekannt' actor: string /** Nur bei 'move': wo die Datei vorher lag. */ von: string | null } export type EventsSeite = { total: number limit: number offset: number items: MediaEvent[] akteure: { actor: string actor_kind: string n: number zuletzt: number avatar: string | null }[] /** Ab wann aufgezeichnet wird — null, solange nichts drinsteht. */ seit: number | null } export type UploadErgebnis = { name: string ok: boolean path?: string url?: string size?: number replaced?: boolean error?: string } export type ThumbLauf = { erzeugt: number vorhanden: number uebersprungen: number fehler: number gesamt: number /** Wieviele noch fehlen. Siehe WebpLauf — derselbe Grund. */ offen: number fertig: boolean /** Ton ohne eingebettetes Titelbild. KEIN Fehler — der Normalfall. */ ohneBild: 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'), /** Was die ANMELDESEITE wissen darf, bevor jemand angemeldet ist. */ authConfig: () => get<{ discord: boolean; marke: string }>('/api/dash/auth/config'), 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, folder: string, art: Art, limit: number, offset: number, sort: Sortierung = 'geaendert', dir: Richtung = 'ab', ) => get( `/api/dash/media?query=${encodeURIComponent(query)}` + `&folder=${encodeURIComponent(folder)}&art=${art}` + `&limit=${limit}&offset=${offset}&sort=${sort}&dir=${dir}`, ), /** Nur die IDs — fuer "alle Treffer waehlen", ohne 15 Seiten zu blaettern. * Nimmt DIESELBEN Einschraenkungen wie die Liste. */ medienIds: (query: string, folder: string, art: Art) => get<{ ids: number[]; gekappt: boolean }>( `/api/dash/media/ids?query=${encodeURIComponent(query)}` + `&folder=${encodeURIComponent(folder)}&art=${art}`, ), arten: (query: string, folder: string) => get( `/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`, ), folders: () => get('/api/dash/folders'), verschieben: (ids: number[], ziel: string) => post('/api/dash/media/move', { ids, ziel }), ordnerAnlegen: (pfad: string) => post<{ ok: true; pfad: string }>('/api/dash/folders', { pfad }), ordnerEntfernen: (pfad: string) => del<{ ok: true }>('/api/dash/folders/' + pfad.split('/').map(encodeURIComponent).join('/')), deleteMedia: (id: number) => del<{ deleted: string }>(`/api/dash/media/${id}`), deleteMediaBulk: (ids: number[]) => post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }), events: ( kind: string, actor: string, query: string, limit: number, offset: number, ) => get( `/api/dash/events?kind=${encodeURIComponent(kind)}` + `&actor=${encodeURIComponent(actor)}` + `&query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`, ), einstellungen: () => get('/api/dash/settings'), sicherungSpeichern: (sicherung: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { sicherung }), meldungenSpeichern: (meldungen: { webhook?: string; anlaesse?: string[] }) => post<{ ok: true; meldungen: { hatWebhook: boolean; anlaesse: string[] } }>( '/api/dash/settings', { meldungen }, ), meldenTesten: () => post<{ ok: true }>('/api/dash/maintenance/melden'), vorlagenSpeichern: (liste: Vorlage[]) => post<{ ok: true; vorlagen: Vorlage[] }>('/api/dash/settings', { vorlagen: liste }), einstellungenSpeichern: (discord: Record) => post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }), uebersicht: () => get('/api/dash/uebersicht'), stand: () => get('/api/dash/stand'), speicher: () => get('/api/dash/speicher'), papierkorb: () => get('/api/dash/papierkorb'), papierkorbZurueck: (ids: number[]) => post<{ zurueck: number fehlgeschlagen: number ergebnisse: { path: string; ok: boolean; error?: string }[] }>('/api/dash/papierkorb/zurueck', { ids }), papierkorbLeeren: (ids?: number[]) => post<{ geleert: number }>('/api/dash/papierkorb/leeren', ids ? { ids } : {}), stats: () => get('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), webpNachziehen: () => post('/api/dash/maintenance/webp'), verwaiste: () => get('/api/dash/maintenance/orphans'), verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') => post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>( '/api/dash/maintenance/orphans', { aktion }, ), benutzer: () => get<{ items: BenutzerRow[]; ich: number }>('/api/dash/benutzer'), benutzerWeg: (id: number) => del<{ ok: true; username: string }>(`/api/dash/benutzer/${id}`), freigaben: () => get<{ items: FreigabeRow[] }>('/api/dash/freigaben'), freigabeAnlegen: (ordner: string, titel: string) => post<{ id: number; schluessel: string; ordner: string; dateien: number; url: string }>( '/api/dash/freigaben', { ordner, titel }, ), freigabeWeg: (id: number) => del<{ ok: true }>(`/api/dash/freigaben/${id}`), tokens: () => get<{ items: TokenRow[]; ohneToken: { dateien: number; bytes: number } }>( '/api/dash/tokens', ), umbenennen: (id: number, name: string) => post<{ alt: string; neu: string; url: string; abrufe: number }>( '/api/dash/media/umbenennen', { id, name }, ), ordnerUmbenennen: (pfad: string, name: string) => post<{ alt: string; neu: string; dateien: number }>('/api/dash/folders/umbenennen', { pfad, name, }), createToken: (daten: { name: string prefix: string canDelete: boolean maxMb?: string quotaMb?: string arten?: string[] gueltigTage?: string }) => post('/api/dash/tokens', daten), deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`), }