Files
d4rk_media/server/ui/src/api.ts
T
D4rkst3randClaude Opus 5 72fd330ae6 feat: der Dienst nennt sich selbst -- und Cloudflare bricht die WebP-Auslieferung
DER NAME KAM VIERMAL FEST AUS DEM QUELLTEXT: Kopfzeile, Anmeldeseite, Fusszeile
einer Freigabe, Tab-Titel -- und zwar als "d4rk_media", was der Name des
Repositorys ist und nicht der des Dienstes. Wer ihn aendern wollte, musste vier
Stellen finden und eine davon uebersehen.

Er kommt jetzt vom Server, aus der Einstellung marke.name -- DERSELBEN, die
schon in der Fusszeile der Discord-Meldungen steht ("D4RKST3R // MEDIA"). Ein
zweiter Name fuer die Oberflaeche waere ein zweiter, der beim naechsten Mal
anders lautet als der erste.

Ausgeliefert wird er ueber /api/dash/auth/config (oeffentlich, die
Anmeldeseite hat noch keine Sitzung) und in der Freigabe-Antwort (die Seite ist
oeffentlich und holt sonst nichts, was ihn kennen koennte). Angezeigt wird
"<marke> Media" -- die Marke allein waere falsch, sie steht ueber allem, was
hier laeuft.

EINE STELLE BLEIBT ABSICHTLICH: der Schluessel im Speicher des Browsers
(merker.ts) heisst weiter "d4rk_media.". Wer ihn umbenennt, wirft still alle
gemerkten Ansichtseinstellungen weg, und niemand versteht, warum die Galerie
ploetzlich wieder bei 60 Kacheln steht. Steht als Kommentar daneben.

---

UND DER NEUE NAME HAENGT HINTER CLOUDFLARE, was die WebP-Auslieferung bricht.

NPM ist sauber, sogar besser als vorgeschlagen: beide Namen auf DEMSELBEN Host,
die drei ^~-Bloecke gelten damit fuer beide. Der DNS-Eintrag geht aber ueber
Cloudflare (188.114.97.3, Server: cloudflare), waehrend fivemanage direkt auf
88.218.224.10 zeigt.

Gemessen, dieselbe Datei:

    fivemanage (direkt)      ohne Accept  ->  image/png   12295 B   richtig
                              mit Accept  ->  image/webp   2270 B   richtig

    media (Cloudflare)     1. mit Accept  ->  image/webp   2270 B   MISS
                           2. mit Accept  ->  image/webp   2270 B   HIT
                           3. OHNE Accept ->  image/webp   2270 B   HIT   FALSCH

Und andersherum genauso -- wer zuerst kommt, praegt den Zwischenspeicher fuer
alle. Es sind unsere eigenen Bytes (2270 = unsere WebP-Fassung), also kein
Polish, sondern ein Cache-Schluessel ohne Accept. Vary: Accept wird an den
Client durchgereicht, aber nicht zum Unterscheiden benutzt.

Fuer ein Lua-Skript heisst das: items/foo.png liefert WebP-Bytes unter einem
.png-Namen, und das faellt erst auf, wenn irgendwo ein Bild nicht laedt.

PUBLIC_URL ist deshalb NICHT umgestellt. Genau davor stand die Warnung im
letzten Commit, und sie war keine Vorsicht, sondern eine Vorhersage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:43:29 +02:00

533 lines
18 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
/** 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<string, number>
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<MediaPage>(
`/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<Arten>(
`/api/dash/arten?query=${encodeURIComponent(query)}&folder=${encodeURIComponent(folder)}`,
),
folders: () => get<Folders>('/api/dash/folders'),
verschieben: (ids: number[], ziel: string) =>
post<VerschiebeErgebnis>('/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<EventsSeite>(
`/api/dash/events?kind=${encodeURIComponent(kind)}` +
`&actor=${encodeURIComponent(actor)}` +
`&query=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`,
),
einstellungen: () => get<Einstellungen>('/api/dash/settings'),
sicherungSpeichern: (sicherung: Record<string, string | number>) =>
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<string, string>) =>
post<{ ok: true; bereit: boolean; fehlt: string[] }>('/api/dash/settings', { discord }),
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
stand: () => get<Stand>('/api/dash/stand'),
speicher: () => get<Speicher>('/api/dash/speicher'),
papierkorb: () => get<PapierkorbSeite>('/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<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
webpNachziehen: () => post<WebpLauf>('/api/dash/maintenance/webp'),
verwaiste: () => get<Verwaiste>('/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<NewToken>('/api/dash/tokens', daten),
deleteToken: (id: number) => del<{ ok: true }>(`/api/dash/tokens/${id}`),
}