Files
d4rk_media/server/ui/src/api.ts
T
D4rkst3randClaude Opus 5 aa2575bdf4 feat: Sicherung, Anmeldebremse, Vorschaubilder -- und Schritt 5
SICHERUNG (tools/sichern.ps1, taeglich 04:30 als geplante Aufgabe).
Die Datenbank wird nicht kopiert, sondern ueber SQLites eigene
Sicherungsschnittstelle herausgeholt (server/src/backup.ts): im WAL-Modus
liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu
kopieren ist nicht sicher, wenn waehrenddessen geschrieben wird. Die Bilder
kommen aus einem NUR LESEND eingehaengten Volume dazu.

Und sie prueft sich selbst: auspacken, Datenbank oeffnen, Medien/Token/
Benutzer zaehlen, mit dem laufenden Dienst vergleichen, sonst Fehler. Einmal
wirklich zurueckgespielt -- leeres Volume, zweiter Dienst, Anmeldung mit dem
echten Passwort, Bild abgerufen, Byte fuer Byte identisch. Eine Sicherung, die
nie zurueckgespielt wurde, ist eine Hoffnung.

ANMELDEBREMSE. Das Formular steht oeffentlich, und scrypt macht einen Versuch
teuer -- aber teuer ist nicht selten. Fuenf freie Versuche je Adresse, dann
Sperre ab 30 s mit Verdopplung bis 15 min, 429 samt Retry-After und einem Text,
der sagt wie lange. Erfolg setzt zurueck. Nur im Speicher: wer sich aussperrt,
startet den Container neu. Durchgemessen bis zur Erholung.

VORSCHAUBILDER. sharp erzeugt beim Upload eine 320er WebP-Fassung unter
/data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen: 131502 -> 13078 Bytes,
Faktor 10; eine Galerieseite faellt von 7,5 MB auf 766 KB. KEINE Spalte in der
Datenbank -- ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie
faellt bei 404 aufs Vollbild zurueck. Eine zweite Wahrheit, die auseinander-
laufen kann, gibt es damit gar nicht erst. Ein Knopf zieht Fehlendes nach und
nennt Zahlen statt "fertig".

SCHRITT 5. Die Fivemanage-Dateien liegen in legacy/ mit Erklaerung; unsere
docker-compose.media.yml heisst jetzt docker-compose.yml. Drei Volumes und drei
Abbilder geloescht, rund 670 MB -- nachgezaehlt war vorher, dass kein Bild
darin lag.

Nebenbefund: `npm i sharp` scheitert auf diesem Rechner nicht an sharp, sondern
an better-sqlite3 -- der Host laeuft auf Node 24 (ABI 137), node-gyp uebernimmt
und findet keine Bauwerkzeuge. Genau die Falle aus dem Dockerfile. Eingetragen
wurde mit --package-lock-only, gebaut wird im Abbild auf Node 22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:00:14 +02:00

179 lines
5.9 KiB
TypeScript

// 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<T>(path: string, init: RequestInit = {}): Promise<T> {
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 = <T,>(path: string) => request<T>(path)
const post = <T,>(path: string, body?: unknown) =>
request<T>(path, { method: 'POST', body: body === undefined ? undefined : JSON.stringify(body) })
const del = <T,>(path: string) => request<T>(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<MediaPage>(
`/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<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
createToken: (name: string, prefix: string, canDelete: boolean) =>
post<NewToken>('/api/dash/tokens', { name, prefix, canDelete }),
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
}