Files
d4rk_media/ROADMAP.md
T
D4rkst3randClaude Opus 5 7b35ef5698 feat: Betrachter fuer Lesbares, und aus dem <video> wird ein Player
Bild, Video, Ton und PDF konnte der Betrachter schon. Was fehlte, war
ausgerechnet die Art, die man am ehesten nur lesen und nicht herunterladen
will: eine .md landete im Zweig "laesst sich hier nicht anzeigen".

LESBARES. Markdown gesetzt, .lua und .json eingefaerbt, CSV als Tabelle, alles
Uebrige mit Zeilennummern. Der Tokenizer ist DERSELBE wie fuer die Schnipsel
auf der API-Seite und nicht ein zweiter -- zwei waeren zwei, die auseinander
laufen. Die Zeilennummern liegen in einer eigenen Spalte: so nimmt ein
Markieren mit der Maus sie nicht mit.

Die CSV-Zerlegung kann Anfuehrungszeichen und doppelte darin. Das ist der Teil,
den ein split(';') falsch macht, und CSV aus Excel hat ihn regelmaessig; das
Trennzeichen wird aus der Kopfzeile geraten.

NICHT ALLES WIRD GEHOLT. Die Groesse steht in der Datenbank, also wissen wir
vorher, worauf wir uns einlassen: ueber 512 KB kommt per Bereichsanfrage nur
der Anfang, und das steht auch da. Ein Betrachter, der bei einem 400-MB-
Protokoll den Tab abschiesst, ist schlimmer als einer, der die Datei gar nicht
erst oeffnet. Gemessen: Range: bytes=0-49 -> 206, 50 Bytes.

MARKDOWN OHNE BIBLIOTHEK, und das ist kein Geiz. Der uebliche Weg heisst marked
plus DOMPurify und endet bei dangerouslySetInnerHTML -- damit haengt die
Sicherheit dieser Seite an der Frage, ob die Filterliste vollstaendig ist. Hier
entsteht NIE eine HTML-Zeichenkette: der Text wird zu React-Knoten, und ein
<script> in einer hochgeladenen .md erscheint als die acht Zeichen, die es ist.
Im ganzen ui/ steht kein einziges dangerouslySetInnerHTML.

Die eine Luecke, die React nicht schliesst, ist href: [klick](javascript:...)
kaeme durch. Zwoelf Faelle in Node durchgeprueft, alle bestanden -- http,
https, mailto und relative Ziele durch; javascript:, JavaScript:, mit
Leerzeichen davor, data:, vbscript:, file: abgelehnt und als Text stehen
gelassen.

DER PLAYER. Die Bedienelemente bleiben die des Browsers: ein selbstgebauter
Schieber sieht in jedem Browser anders falsch aus, kennt keine
Tastatursteuerung und keine Untertitel. Drumherum kam, was der Browser nicht
mitbringt.

Lautstaerke ueber Dateien hinweg -- <video> setzt sie bei jedem neuen Element
auf 1 zurueck, und wer sich durch zwanzig Clips klickt, stellt sie sonst
zwanzigmal leise, waehrend der einundzwanzigste ungefragt wieder hochfaehrt.

Weiter zum naechsten Stueck, abschaltbar, und nur wenn das Naechste auch etwas
zum Abspielen ist: nach einem Lied ungefragt bei einem Bild zu landen waere
schlechter als stehenzubleiben.

Tastatur an EINER Stelle: Leertaste haelt an, Pfeile springen fuenf Sekunden,
Umschalt+Pfeil wechselt die Datei. Bei allem anderen blaettern die Pfeile wie
bisher. Dass das so ist, steht als Zeile unter dem Bild -- eine unsichtbare
Sonderregel fuehlt sich an wie ein Fehler.

SERVER. Die Typtabelle kennt jetzt md, markdown, csv, lua, xml, yml, yaml,
toml, ini, cfg, sql und log -- alle als text/plain. Nicht als text/markdown
(braechte nichts) und erst recht nicht als text/html: wer einen Upload-Token
hat, koennte damit eine Seite unter unserem Namen veroeffentlichen. html, css
und js stehen deshalb weiterhin NICHT drin.

Der Betrachter entscheidet trotzdem nach der Endung und erst danach nach der
gemeldeten Art. Grund steht in der Datenbank: items/readme.md lag schon da und
trug application/octet-stream, weil sie vor der erweiterten Tabelle hochgeladen
wurde. Wer nur auf die Art schaut, zeigt die alte README nicht an und eine neue
schon -- und sucht den Unterschied an der falschen Stelle.

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

33 KiB
Raw Blame History

d4rk_media — Roadmap

Eigener Medien-Dienst für alle FiveM-Ressourcen: Dateien annehmen, ablegen, ausliefern, in einem Dashboard verwalten.

Warum nicht Fivemanage Lite. Der Stack lief am Ende — PostgreSQL, MinIO, Dashboard, alles grün. Was an ihm nicht zu reparieren war, ist die Qualität der Oberfläche: http-util.ts wirft jeden Fehler als new Error(…), die Hooks prüfen auf instanceof ApiError, und damit wird jeder Fehlschlag lautlos verschluckt. Create organization gibt weder Erfolg noch Misserfolg zurück — das Ergebnis waren 19 gleichnamige Organisationen, weil ein Knopf ohne Rückmeldung wie ein kaputter Knopf aussieht. Löschen geht nicht, die Route existiert nicht. Das ist kein Bedienfehler und keine fehlende Funktion, das ist 0.1.0-beta.

Dazu kam: von dem, was Lite über eine Dateiablage hinaus kann — Mandanten, strukturiertes Logging, ClickHouse — haben wir nichts gebraucht und alles mitgeschleppt.


Der Zuschnitt

Ein Container. SQLite statt PostgreSQL, Dateien auf einer Platte statt MinIO. Das ist nicht die kleine Lösung, sondern die, die man nicht pflegt: kein zweiter Dienst, kein Passwort dazwischen, und eine Sicherung ist ein cp -a über ein Volume. Wenn daraus je Millionen Zeilen werden, wird server/src/db.ts ausgetauscht — die Aufrufer merken davon nichts.

Ein Name. Dashboard und API liegen an der Wurzel, die Dateien unter /f/:

https://fivemanage.d4rkst3r.de/          Dashboard und API
https://fivemanage.d4rkst3r.de/f/vehicles/adder.webp

Ein DNS-Eintrag, ein Host im Proxy. Aufgegeben ist damit eine Eigenschaft, die zwei Namen mitbrächten: die öffentliche Adresse trägt jetzt auch das Anmeldeformular.

Und der Name ist fivemanage, obwohl hier lange das Gegenteil stand. Der ursprüngliche Satz war: fivecdn und fivemanage fallen weg, wir betreiben kein Fivemanage und tragen deshalb nicht dessen Namen. Das Argument stimmt immer noch — es ist nur billiger als das, was dagegen steht, und das ist nachgemessen: der Name war schon fertig verkabelt. DNS-Eintrag da, Host in NPM da, Let's-Encrypt-Zertifikat gültig bis 09.11.2026, und er zeigte auf host.docker.internal:9101 — einen Port, der frei wurde, als die Lite-Container gelöscht wurden. Unser Stack auf 9101 heißt: kein DNS-Eintrag, kein Klick in NPM, kein neues Zertifikat.

Der Preis ist ein Name, der nach einem fremden Produkt klingt. Der Wechsel bleibt billig — die Adressen stehen nirgends in der Datenbank, sie werden bei jeder Antwort aus PUBLIC_URL gebaut. Ein späterer Umzug auf media.d4rkst3r.de ist ein DNS-Eintrag, ein NPM-Host und eine geänderte Variable.

Zwei Namen kann der Dienst weiterhin — FILES_HOST setzen, dann gibt es unter diesem Namen ausschließlich Dateien und die URLs kommen ohne /f/ aus. Beides ist gemessen. Der Wechsel ist billig: die Adressen stehen nirgends in der Datenbank, sie werden bei jeder Antwort aus PUBLIC_URL gebaut.

Der Pfad bleibt in beiden Fällen der Schlüssel. vehicles/adder.webp bleibt vehicles/adder.webp, die Vorlage im Handy also {model}.webp. Nur der Teil davor ändert sich, und der steht in einer Variablen.

Der Pfad ist der Schlüssel. X-Path: vehicles/adder.webp legt die Datei genau dort ab. Vorhersagbare URLs sind für das Fotostudio die ganze Voraussetzung — bei Lite hätten wir die zurückgegebenen Adressen in einer urls.json mitschleppen müssen.

Nicht nachgebaut wird das Logging. Genau dafür schleppt Fivemanage ClickHouse mit, und genau da ist der Wartungsaufwand echt. Wenn Logs kommen sollen, ist das ein eigenes Projekt mit eigener Entscheidung.


Stand

Fertig — Server-Grundgerüst

server/src/config.ts     Einstellungen, alle aus der Umgebung
server/src/db.ts         SQLite: users, sessions, tokens, media
server/src/auth.ts       scrypt, Sitzungen, Token-Hashes
server/src/storage.ts    Pfadprüfung, atomares Schreiben, MIME
server/src/routes/upload.ts   die API für Skripte
server/src/routes/dash.ts     die API fürs Dashboard
server/src/index.ts      Host-Weiche, Dateiauslieferung, Start

Die API für Skripte:

POST   /api/upload          Bearer-Token
                            X-Path        vehicles/adder.webp   (optional)
                            X-Encoding    base64                (für Lua)
                            X-Overwrite   false                 (Standard: ja)
                            → { url, path, size, sha256, mime, replaced }

DELETE /api/media/<pfad>    Token braucht can_delete
GET    /api/exists/<pfad>   für "nur fehlende" im Serienlauf

Drei Rumpfformen — multipart/form-data, Base64, rohe Bytes. Base64 ist für FiveM: PerformHttpRequest schickt einen Lua-String, und im Fotostudio kommt das Bild ohnehin als Data-URL aus dem NUI.

Die API fürs Dashboard: Anmeldung, Passwortwechsel, Medienliste mit Suche und Paginierung, Einzel- und Sammellöschung, Statistik nach Ordnern, Token-Verwaltung.

Fertig — Schritt 1, der Smoke-Test

Gestartet, durchgemessen, repariert. Der Durchstich steht: anmelden → Token anlegen → Bild hochladen → unter der zurückgegebenen URL abrufen, Byte für Byte identisch, auch unter dem Dateihost.

So läuft er lokal:

cp server/.env.example server/.env
npm --prefix server ci && npm --prefix server run dev

Der Stolperstein, den es nicht mehr gibt. Anfangs war der Standard für FILES_HOST der Host aus PUBLIC_URL. Bei zwei Namen ist das richtig, bei einem tödlich: dann galt jede Anfrage als Anfrage an den Dateiwirt, /health gab 404 und die Anmeldung 405 „hier gibt es nur Dateien". Nichts war kaputt, und niemand kommt darauf. Seit der Entscheidung für einen Namen ist der Ein-Namen-Betrieb der Standard und der zweite Name die Ansage — lokal läuft es damit ohne Sonderbehandlung. Welche Betriebsart gilt, sagt der Dienst in der zweiten Startzeile.

Was der Test gefunden hat — alles im Grundgerüst, alles repariert:

  1. Die Token-Prüfung hing an '*' und galt damit auch für /api/dash/*, das daneben liegt. Das Dashboard bekam „Token fehlt oder ist unbekannt" auf die Anmeldung, obwohl es nie einen Token haben kann. Jetzt hängt sie an den drei eigenen Pfaden.
  2. DELETE /api/media/… und GET /api/exists/… sahen am Ziel vorbei. c.req.path trägt den Einhängepunkt mit, das replace(/^\/media\//) schnitt ihn nicht weg — aus vehicles/adder.png wurde api/media/vehicles/adder.png. Löschen fand nie etwas, exists meldete immer false. Jetzt :pfad{.+}; Hono liefert den Parameter fertig dekodiert (nachgemessen), ein zweites decodeURIComponent wäre eine Dekodierung zu viel gewesen.
  3. Verzeichnisdurchstieg in der SPA-Rückfallroute. GET /..%5Cpackage.json hat unter Windows die Datei ausgeliefert: Hono reicht %5C durch, path.join behandelt den Backslash dort als Trenner, und eine Eindämmung gab es nicht. Unter Linux trägt genau dieser Angriff nicht — Glück, keine Abwehr. Jetzt dieselbe resolve-Prüfung wie in storage.ts.
  4. Die Auskunft „Oberfläche ist nicht gebaut" war unerreichbar. createReadStream meldet eine fehlende Datei asynchron; das try/catch darum fing nichts. Ergebnis war ein leerer 200 samt ENOENT im Log. Jetzt wird vorher nachgesehen.
  5. CSS und JS kamen als application/octet-stream — die MIME-Tabelle kennt nur Medientypen. Das Dashboard hätte weder Stylesheet noch Modul geladen. Die Oberfläche hat jetzt eine eigene Tabelle: in der geteilten fehlt html mit Absicht, sonst könnte jeder mit einem Upload-Token eine Seite unter fivecdn.d4rkst3r.de veröffentlichen. Nachgemessen: eine hochgeladene .html kommt weiterhin als octet-stream mit nosniff.
  6. Kaputtes JSON endete als nackter „Internal Server Error". Ausgerechnet das — ein 500 ohne ein Wort dazu ist der Fehler, wegen dem wir hier neu bauen. Jetzt 400 mit Text.

Was sonst nachgemessen wurde und trägt: alle drei Rumpfformen (multipart, Base64, rohe Bytes) liefern denselben SHA-256, auch mit data:-Vorspann · der Hash-Pfad ohne X-Path · Präfix-Fessel und can_delete je Token · .., versteckte Dateien und leerer Rumpf werden abgelehnt · X-Overwrite: false gibt 409, Überschreiben replaced: true · 413 vor dem Einlesen · ETag/304, HEAD, Bereichsanfragen und 416 · unter dem Dateihost gibt es weder API noch /f/-Präfix · Medienliste, Suche, Statistik nach Ordnern.

Nicht getestet: das Dashboard (gibt es noch nicht) und echte Last.

Fertig — ein Name statt zwei

PUBLIC_URL ist jetzt nur noch der Ursprung; das /f hängt der Dienst selbst an. Wer es mitschriebe, bekäme Adressen mit /f/f/ — und das fiele erst auf, wenn das erste Bild fehlt. Prefix und Route werden deshalb an genau einer Stelle entschieden (config.filePrefix), damit die zurückgegebene Adresse und die Route, die sie ausliefert, nicht auseinanderlaufen können.

Beide Betriebsarten sind durchgemessen: mit einem Namen kommt …/f/vehicles/adder.png zurück und liefert die Datei; mit gesetztem FILES_HOST kommt sie ohne Präfix, liegt unter dem Dateihost, ist am Dashboard-Namen zusätzlich unter /f/ erreichbar, und unter dem Dateihost gibt es weiterhin keine API.

Fertig — Schritt 2, das Abbild ist gebaut und gelaufen

Gebaut, gestartet, durchgemessen — auf einer Maschine mit Docker (Desktop 29.6.2, WSL2). Derselbe Durchstich wie in Schritt 1, diesmal gegen den Container statt gegen tsx: 24 Prüfungen, keine durchgefallen. Am Grundgerüst war nichts zu ändern; der Bau hat keinen einzigen Befund erzeugt.

So läuft er:

export PUBLIC_URL=http://localhost:9102 ADMIN_PASSWORD=…
docker compose up -d --build

Was jetzt gemessen ist und vorher nur behauptet war:

  • npm ci --omit=dev zieht die fertige Binärdatei. Im Abbild liegt better-sqlite3/build/Release/better_sqlite3.node, datiert Mai 2025 — heruntergeladen, nicht übersetzt; ein Makefile oder obj.target von node-gyp steht nirgends daneben. require('better-sqlite3') läuft im Laufzeit-Abbild, Node 22.23.2, ABI 127. Die Festnagelung trägt.
  • Der Bau läuft durch, alle drei Stufen. Das Abbild: 373 MB auf der Platte, 88,4 MB Inhalt.
  • Der Healthcheck greift. Docker meldet healthy, Exitcode 0, kein Fehlschlag — ohne curl im Abbild, fetch aus Node reicht.
  • Das Volume greift. Container weggeworfen, einen neuen an dasselbe Volume gehängt: die Datei aus dem ersten kommt weiter unter ihrer URL, und admin wird nicht noch einmal angelegt. Nebenbei bestätigt, was im Compose steht: ein geändertes ADMIN_PASSWORD setzt nichts zurück — die alte Anmeldung gilt (200), die neue nicht (401).
  • Compose bricht ohne Variablen ab, wie das :? verspricht, und zwar mit dem Text, der danebensteht — erst PUBLIC_URL, dann ADMIN_PASSWORD. Mit beiden fährt der Stack hoch und bindet 0.0.0.0:9102->8080.
  • Secure steht wirklich im Cookie, wenn NODE_ENV=production gilt: sid=…; Path=/; HttpOnly; Secure; SameSite=Lax. Ohne Cookie oder mit einem erfundenen gibt jede Dashboard-Route 401 — auch /auth/me.

Neu aufgefallen, weil der Container es sichtbar macht: im Volume liegen vier Dinge, nicht zwei — files/, media.db, media.db-shm und media.db-wal. SQLite läuft im WAL-Modus (db.ts), und das WAL war nach wenigen Uploads 152 KB groß, mit Daten, die in media.db noch nicht standen. Eine Sicherung, die nur media.db mitnimmt, ist deshalb unvollständig. Siehe Offene Entscheidungen.

Beim Ausprobieren: NODE_ENV=production setzt das Sitzungs-Cookie auf Secure. Wer den Container ohne HTTPS aufruft, bekommt auf die Anmeldung 200 — und ist trotzdem nicht angemeldet, weil der Browser das Cookie still verwirft; die nächste Anfrage ist 401. Im Browser gegen eine LAN-Adresse nachgemessen. Die naheliegende Gegenprobe über localhost führt in die Irre: das gilt Browsern als sicherer Kontext und funktioniert. Und curl taugt hier nicht als Zeuge — der schickt ein Secure-Cookie auch über http und meldet fröhlich Erfolg. Was zählt, ist der Browser.

In Portainer aus diesem Repo deploybar — Compose path nimmt EINE Datei, Ergänzungen werden stillschweigend ignoriert. Das hat uns bei Lite einen Nachmittag gekostet.

Und noch eine Annahme, die beim Nachsehen fiel: es gibt gar keine zweite Maschine. Gebaut und gelaufen ist auf dem Server — hier laufen Gitea, Portainer, NPM und die Nextcloud. Die alte Notiz „auf dieser Maschine gibt es weder Docker noch WSL" galt vor der Installation von Docker Desktop und ist überholt. Was zum Betrieb fehlt, ist deshalb nicht der Umzug auf einen anderen Rechner, sondern nur der öffentliche Auftritt: DNS-Eintrag für media.d4rkst3r.de, ein Host in NPM mit TLS, ein echtes Passwort.

Gemessen am 11.08.2026 belegt: 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080, 8443, 8090, 9000, 9443, 11000 — 8443 fehlte in der alten Liste. Der Host-Port ist 9101, nicht die 9102 aus dem Compose-Standard: darauf zeigt der fertige NPM-Host (siehe oben). Gesetzt wird er über HOST_PORT in der .env, der Standard im Compose bleibt 9102.

Fertig — der Dienst steht öffentlich

Seit dem 11.08.2026 läuft er unter https://fivemanage.d4rkst3r.de, hinter NPM mit Let's-Encrypt-Zertifikat, Container auf 9101. Die Werte stehen in einer .env neben dem Compose (gitignored); dieselben Werte sind in Portainer die Stack-Variablen.

Durchgemessen über die öffentliche Adresse, nicht über localhost:

  • Anmeldung durch echtes HTTPS — das ist der Fall, den curl bisher nicht bezeugen konnte: Secure am Cookie stört jetzt nicht mehr, weil die Verbindung wirklich TLS ist. 200, Cookie da, /auth/me 200.
  • Token mit Präfix-Fessel vehicles/ und ohne Löschrecht angelegt. Upload nach anderswo/403 mit Text, Löschen → 403 mit Text.
  • Upload und Abruf über TLS und Proxy: Byte für Byte identisch.

Der 502, der keiner war. Der erste Aufruf gab 502 in 78 ms. Die naheliegende Erklärung — „ein Moment, gleich geht's" — war falsch: der NPM-Host zeigte in dem Moment auf 9102, unser Container liegt auf 9101, also sofortige Ablehnung. Kein Timeout, keine DNS-Frage, kein Zufall. Ein 502 binnen Millisekunden ist immer ein Connection refused, und dann stimmt eine Portnummer nicht.

Fertig — Schritt 3, der Fotostudio lädt selbst hoch

Am 11.08.2026 im Spiel gelaufen. /studio shot adder, und die Konsole sagte in zwei Zeilen, was passiert ist:

[photostudio] Upload nach https://fivemanage.d4rkst3r.de — Dienst antwortet.
[photostudio] adder freigestellt abgelegt (129 KB)
[photostudio] adder hochgeladen: https://fivemanage.d4rkst3r.de/f/vehicles/adder.webp

Von der Dienstseite gegengeprüft, statt der Logzeile zu glauben: 132 908 Bytes hier wie dort, derselbe SHA-256 (f14dfc5b…5515e), ausgeliefert als image/webp, und die ersten Bytes sind RIFF….WEBP — ein echtes WebP und kein falsch benanntes PNG.

Damit ist auch die letzte offene Frage aus dem Prüfstand beantwortet: PerformHttpRequest verhält sich in FiveM wie die Fälschung, Kopfzeilen und base64-Rumpf kommen unverändert an, und der Rückruf erreicht den Event-Handler.

Was noch niemand gemessen hat: ein ganzer Serienlauf über 900 Fahrzeuge — also 900 Uploads hintereinander, mit dem, was dabei an Fristen und Gleichzeitigkeit auftreten kann. Und Upload.put, die wartende Form, ist ungenutzt: der Lauf nimmt Upload.send. Sie steht da, weil die ROADMAP sie so benannt hat, und ist damit ungeprüfter Code.

Der Quelltext liegt in d4rk_photostudio (eigenes Repo, D:\FXServer\txData\…):

server/upload.lua              Upload.send(pfad, bytes, cb)  und  Upload.put(pfad, bytes) → url
config.upload.example.lua      committet
config.upload.lua              gitignored, enthaelt den Token

Hängt an d4rk_photostudio:saveKeyed — also genau an der Freigabe: das Bild geht erst auf die Platte, dann in den Dienst, und ein Fehlschlag bricht nichts ab, sondern landet im Protokoll des Laufs. Ein Lauf über 900 Fahrzeuge, der an einem Netzhuster stirbt, wäre der teuerste denkbare Fehler.

Gemessen ist alles, was sich ohne FiveM messen lässt — und das ist mehr, als es zunächst schien: upload.lua wurde in einer echten Lua-5.3-VM (fengari, über Node) mit gefälschten FiveM-Funktionen ausgeführt. 30 Prüfungen, keine durchgefallen: fehlende, kaputte und unvollständige config.upload.lua; Adresse, Methode und alle vier Kopfzeilen; der Fehlertext des Dienstes statt einer nackten Zahl; Status 0; und dass die Frist den Rückruf nicht ein zweites Mal auslöst. Der Rumpf, den die VM erzeugt hat, ging danach unverändert an den echten Dienst — hochgeladen, abgerufen, Byte für Byte identisch.

Damit ist erreicht, worum es ursprünglich ging. Ein Bild entsteht im Spiel, wird freigestellt, landet auf der Platte und liegt Sekunden später unter einer vorhersagbaren Adresse — ohne Fivemanage, ohne PostgreSQL, ohne MinIO, ohne einen einzigen Handgriff von Hand dazwischen.

Fertig — Schritt 4, das Dashboard

Liegt in server/ui, gebaut nach server/web (gitignored), im Abbild von einer eigenen Docker-Stufe. React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), Lucide, Zustand — kein Konsta, wie geplant.

Anmeldung · Galerie mit Vorschau, Suche, Mehrfachauswahl, Blättern · Einzel- und Sammellöschung mit Nachfrage, die die Pfade zeigt · Speicherverbrauch nach Ordnern · Token-Verwaltung mit Klartext genau einmal · Passwortwechsel.

Vite 5 bleibt, aber @vitejs/plugin-react ist auf ^4 festgenagelt. Die 6 verlangt vite ^8. In den peerDependencies nachgesehen, nicht geraten: @tailwindcss/vite@4 nimmt ^5.2 || ^6 || ^7 || ^8, und plugin-react@4 nimmt ^4.2 || ^5 || ^6 || ^7. Vite 5.4.21 liegt in beiden Schnittmengen.

Wie die eine Regel durchgesetzt wird, und nicht nur gemeint ist. In api.ts steht der einzige fetch und genau eine Fehlerklasse — der Fivemanage-Fehler war, zwei zu haben (new Error geworfen, instanceof ApiError geprüft, jeder Fehlschlag verschluckt). In store.ts steht run(): es nimmt die Aktion und den Erfolgstext, meldet bei Erfolg diesen und bei Fehlschlag den Text des Dienstes. Wer darüber geht, kann keinen stillen Knopf bauen. Jeder Knopf, der wartet, zeigt einen Kreisel und ist gesperrt — der zweite Klick eine Sekunde später war der Weg zu neunzehn gleichnamigen Organisationen.

Nachgemessen am laufenden Container: / liefert die Oberfläche als text/html, assets/*.js als text/javascript, assets/*.css als text/css (die MIME-Falle aus Schritt 1 trägt also), und /tokens, /speicher, /konto geben dieselbe index.html zurück — die SPA-Rückfallroute tut, wofür sie gebaut wurde.

⚠️ Der Proxy hält Bilder fest — 30 Minuten lang

Beim Nachmessen der Zwischenspeicher-Kopfzeilen gefunden, und es trifft genau den Fotostudio-Ablauf. NPMs assets.conf greift über eine Regex auf jede URL, die auf .css .js .png .webp … endet — also auch auf /f/vehicles/adder.webp. Sie setzt proxy_cache public-cache, proxy_cache_valid any 30m und wirft unsere Kopfzeilen weg (proxy_ignore_headers Cache-Control, proxy_hide_header Cache-Control).

Reproduziert, nicht vermutet:

Bild hochladen         → abrufen: "ERSTE-FASSUNG"
dieselbe Datei neu     → abrufen über NPM:   "ERSTE-FASSUNG"   ← alt
                         abrufen am Dienst:  "ZWEITE-FASSUNG"  ← neu

Ein neu fotografiertes Fahrzeug sieht bis zu 30 Minuten lang aus wie das alte, obwohl Upload, Protokoll und Datenbank alle „fertig" sagen. Das ist die Sorte Fehler, die man drei Ebenen tiefer sucht.

Behoben am 11.08.2026 und nachgemessen: die Datei trägt jetzt wieder unsere eigene Kopfzeile (cache-control: public, max-age=300, kein Expires von NPM mehr), und ein überschriebenes Bild kommt sofort durch. Der Schnipsel steht im Feld Custom Nginx Configuration des Proxy-Hosts — NPM setzt ihn dort innerhalb des server-Blocks ein ({{ advanced_config }}, Zeile 28 von 52 in der Vorlage), eine location ist dort also gültig:

# Dateien nicht zwischenspeichern -- sie koennen sich unter derselben
# Adresse aendern. Das ^~ ist der ganze Trick: eine gewoehnliche
# Praefix-Location VERLIERT gegen die Regex aus assets.conf.
location ^~ /f/ {
    proxy_cache off;
    include conf.d/include/proxy.conf;
}

Dass ^~ nötig ist und ein blankes location /f/ nicht reicht, ist in einem Wegwerf-nginx nachgemessen: ohne ^~ gewinnt die Regex, mit ^~ das Präfix.

Fertig — Sicherung, Anmeldebremse, Vorschaubilder

Sicherung. tools/sichern.ps1, täglich um 04:30 als geplante Aufgabe (nur bei angemeldetem Benutzer — Docker Desktop läuft ohnehin nur dann).

Die Datenbank wird nicht kopiert, sondern über SQLites eigene Sicherungsschnittstelle herausgeholt (server/src/backup.ts, db.backup()). Der Grund steht weiter oben: im WAL-Modus liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu kopieren ist nicht sicher, wenn währenddessen geschrieben wird. Die Bilder kommen aus einem nur lesend eingehängten Volume dazu, alles in ein tar.gz.

Und die Sicherung prüft sich selbst: sie wird ausgepackt, die Datenbank geöffnet, Medien, Token und Benutzer gezählt und mit dem laufenden Dienst verglichen. Stimmt es nicht, endet das Skript mit Fehler.

Einmal wirklich zurückgespielt, nicht nur behauptet: in ein leeres Wegwerf-Volume ausgepackt, ein zweiter Dienst darauf gestartet, mit dem echten Passwort angemeldet, 21 Dateien vorgefunden, ein Bild abgerufen — Byte für Byte identisch mit dem Betrieb.

Nicht mitgesichert werden die Vorschaubilder: sie sind abgeleitet und lassen sich mit einem Knopf neu rechnen.

Anmeldebremse. Das Formular steht öffentlich; scrypt macht einen Versuch teuer, aber teuer ist nicht selten. Jetzt: fünf freie Versuche je Adresse, dann Sperre ab 30 Sekunden mit Verdopplung bis 15 Minuten, 429 samt Retry-After und einem Text, der sagt, wie lange noch. Ein Erfolg setzt den Zähler zurück. Bewusst nur im Speicher — wer sich aussperrt, startet den Container neu.

Durchgemessen: Versuche 15 geben 401, ab dem sechsten 429 mit „noch 30 Sekunden warten", nach Ablauf geht das richtige Passwort, und danach gibt ein Fehlversuch wieder 401 statt 429.

Die Adresse kommt aus X-Real-IP, den NPM mit $remote_addr überschreibt. Wer den Container direkt auf seinem Port erreicht, kann sie sich ausdenken — genau deshalb gehört 9101 hinter die Firewall.

Vorschaubilder. sharp erzeugt beim Upload eine 320 Pixel breite WebP-Fassung unter /data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen an vehicles/sultan.webp: 131 502 → 13 078 Bytes, Faktor 10. Eine Galerieseite mit 60 Kacheln fällt damit von 7,5 MB auf 766 KB.

Keine Spalte in der Datenbank: ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie fällt bei 404 auf das Vollbild zurück. Eine zweite Wahrheit, die auseinanderlaufen kann, gibt es damit gar nicht erst. Was vor dieser Funktion hochgeladen wurde, zieht ein Knopf unter Speicher nach — er nennt Zahlen, nicht nur „fertig" (21 erzeugt, 0 Fehler beim ersten Lauf).

Das Abbild wächst dadurch von 373 auf 457 MB. sharp bringt fertige Binärdateien für linux-x64 mit; im Abbild nachgemessen: libvips 8.18.3, ein 800×600-PNG wird zu einem 320er WebP.

Nebenbefund beim Einbauen: npm i sharp scheitert auf diesem Rechner — aber nicht an sharp, sondern an better-sqlite3. Der Host läuft auf Node 24 (ABI 137), und dafür gibt es keine fertige Binärdatei; node-gyp übernimmt und findet keine Bauwerkzeuge. Genau die Falle, die im Dockerfile steht. Umgehung: npm i sharp --package-lock-only trägt es nur ein, gebaut wird im Abbild auf Node 22.

Fertig — Schritt 5, der Umzug

Die Fivemanage-Dateien liegen in legacy/ mit einer Erklärung daneben; unsere docker-compose.media.yml heißt jetzt schlicht docker-compose.yml (in Portainer also Compose path: docker-compose.yml). Gelöscht: drei Volumes und drei Abbilder, rund 670 MB. Nachgezählt war vorher, dass kein einziges Bild darin lag.

Was noch bei dir liegt — an NPM komme ich ohne Zugang nicht.

Nachgemessen am 11.08.2026, nicht vermutet:

Pfad gemessene Cache-Control heißt
/f/… public, max-age=300 (unsere) Block sitzt
/t/… max-age=17293 (NPMs) Block fehlt

Also noch einzutragen:

location ^~ /t/ { proxy_cache off; include conf.d/include/proxy.conf; }

Ohne ihn zeigt die Galerie nach einem erneuten Upload stundenlang die alte Vorschau — die Datei selbst ist längst neu, nur das Kachelbild nicht. Kein Datenverlust, aber genau die Sorte Abweichung, die man dem Dienst anlastet.

Außerdem: den Proxy-Host fivecdn.d4rkst3r.de löschen — er zeigt auf den toten Port 9100 und gibt 502.

Fertig — Papierkorb, Abrufzähler, sparsame Fassungen, Archive

Vier Dinge, gemessen und im Betrieb:

Papierkorb. Löschen war endgültig, und das bei einem Knopf „alle 3861 wählen" direkt neben „löschen". Jetzt wandert die Datei nach /data/papierkorb und der Datensatz in eine eigene Tabelle; nach 30 Tagen räumt der Dienst stündlich selbst auf. Ende zu Ende geprüft: löschen → öffentlich 404, Eintrag im Papierkorb, zurückholen → 200 samt neu gerechneter Vorschau.

Abrufzähler. media.abrufe und zuletzt_abgerufen. Gezählt wird im Speicher und alle 30 Sekunden weggeschrieben — ein UPDATE je Kachel wären bei einer Galerieseite 120 Schreibvorgänge. Der 304 zählt mit (der Aufrufer wollte die Datei), HEAD nicht. Was vor dem Einbau lag, steht als „nie geholt" da, auch wenn es täglich benutzt wurde — das sagt die Karte im Speicherbericht auch dazu.

Sparsame Fassungen ohne Adressänderung. Neben jedem PNG/JPEG liegt eine WebP-Fassung, ausgeliefert unter derselben Adresse je nach Accept. Nachgemessen: 6448 → 2758 Bytes für einen Browser, unverändert 6448 für curl. Mit Vary: Accept und getrennten ETags (…-w). Details in docs/API.md.

Über den ganzen Bestand nachgerechnet: 3606 Fassungen, 199,3 MB → 33,1 MB, gespart 166,2 MB (83 %). Bei 29 lohnt WebP nicht — dort bleibt es beim Original, und das ist kein Fehler, sondern die Regel „nur wenn es kleiner wird".

Und ein Knopf, der gelogen hat. Der erste Nachrüstlauf über alle Dateien brauchte mehr als 90 Sekunden — genau da gibt Nginx Proxy Manager auf (proxy_read_timeout). Ergebnis: 504 im Browser, während die Arbeit im Hintergrund weiterlief und fertig wurde. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer als einer ohne Rückmeldung.

Behoben, indem beide Wartungsknöpfe jetzt in Runden zu 300 Stück laufen: der Server meldet offen und fertig, die Oberfläche ruft erneut auf und zeigt dabei „280 erzeugt, noch 129 offen …". Nachgemessen: 7,8 s je Runde statt 90+ am Stück. Der Vorschau-Knopf hatte dieselbe Wand und wurde mitgezogen, obwohl er noch nicht dagegengelaufen war.

ZIP hochladen. Ein Archiv wird ausgepackt statt abgelegt, die Ordner darin bleiben erhalten und hängen sich hinter den gewählten Zielordner. fflate, also reines JavaScript — dieses Projekt hat schon einen halben Abend an einer ABI-Nummer verloren. Grenzen: 5000 Einträge, 256 MB entpackt, geprüft vor dem Entpacken (Zip-Bombe). Zip-Slip nachgemessen: ../../../../etc/passwd und ..\..\windows\hosts abgelehnt, die harmlose Datei im selben Archiv abgelegt.

Fertig — Betrachter für Lesbares, und ein richtiger Player

Bild, Video, Ton und PDF konnte der Betrachter schon. Was fehlte, war ausgerechnet die Art, die man am ehesten nur lesen will: eine .md landete im Zweig „lässt sich hier nicht anzeigen".

Jetzt lesbar: Markdown gesetzt, .lua/.json eingefärbt (derselbe Tokenizer wie auf der API-Seite, nicht ein zweiter), CSV als Tabelle — mit Anführungszeichen und doppelten darin, das ist der Teil, den ein split(';') falsch macht —, alles Übrige mit Zeilennummern in einer eigenen Spalte, damit ein Kopieren sie nicht mitnimmt.

Nicht alles wird geholt. Die Größe steht in der Datenbank, also wissen wir vorher, worauf wir uns einlassen: über 512 KB kommt per Bereichsanfrage nur der Anfang, und das steht auch da. Gemessen: Range: bytes=0-49206, 50 Bytes.

Markdown ohne Bibliothek. marked + DOMPurify enden bei dangerouslySetInnerHTML, und die Sicherheit hängt dann an der Frage, ob die Filterliste vollständig ist. Hier entsteht nie eine HTML-Zeichenkette — Text wird zu React-Knoten. Im ganzen ui/ steht kein einziges dangerouslySetInnerHTML (außer in den zwei Kommentarzeilen, die erklären, warum).

Die eine Lücke, die React nicht schließt, ist href: [klick](javascript:…) käme durch. Zwölf Fälle in Node durchgeprüft, alle bestanden — http, https, mailto und relative Ziele durch; javascript:, JavaScript:, mit Leerzeichen davor, data:, vbscript:, file: abgelehnt und als Text stehen gelassen.

Der Player. Die Bedienelemente bleiben die des Browsers — ein selbstgebauter Schieber sieht in jedem Browser anders falsch aus und kann keine Tastatur. Drumherum kam, was der Browser nicht mitbringt:

  • Lautstärke über Dateien hinweg. <video> setzt sie bei jedem neuen Element auf 1 zurück; wer zwanzig Clips durchsieht, stellt sie sonst zwanzigmal leise — und beim einundzwanzigsten fährt sie ungefragt hoch.
  • Weiter zum nächsten Stück, abschaltbar. Und nur, wenn das Nächste auch etwas zum Abspielen ist: nach einem Lied ungefragt bei einem Bild zu landen wäre schlechter als stehenzubleiben.
  • Tastatur: Leertaste hält an, ←/→ springen fünf Sekunden, Umschalt+←/→ wechselt die Datei. Bei allem anderen blättern die Pfeile wie bisher. Die Belegung steht als Zeile unter dem Bild — eine unsichtbare Sonderregel fühlt sich an wie ein Fehler.

Server: die Typtabelle kennt jetzt md, csv, lua, xml, yml, toml, ini, cfg, sql, logalle als text/plain. Nicht als text/html, und html/css/js stehen weiterhin nicht drin: wer einen Upload-Token hat, könnte sonst eine Seite unter unserem Namen veröffentlichen.

Der Betrachter entscheidet trotzdem nach der Endung und erst danach nach der gemeldeten Art. Grund: items/readme.md lag schon da und trug application/octet-stream, weil sie vor der erweiterten Tabelle hochgeladen wurde. Wer nur auf die Art schaut, zeigt die alte README nicht an und eine neue schon — und sucht den Unterschied an der falschen Stelle.

Als Nächstes

Der Vergleichsmaßstab ist weg. Am 11.08.2026 sind die Lite-Container gelöscht worden — mit ihnen die Möglichkeit, im Zweifel nachzusehen, wie es dort aussah. Was noch liegt und eine Entscheidung braucht:

Volumes   fivemanager_db  fivemanager_minio  fivemanager_pgdata
Abbilder  ghcr.io/fivemanage/lite:0.1.0-beta.23  minio/minio  minio/mc

Nachgesehen, bevor jemand fragt: es ist nichts drin. Alle drei Volumes schreibgeschützt eingehängt und durchgezählt — kein einziges Bild. fivemanager_minio sind 244 KB und besteht ausschließlich aus .minio.sys; der Eimer media hat Metadaten, aber kein einziges Objekt. Die anderen beiden sind leere Datenbanken (MySQL 196 MB, PostgreSQL 47 MB — das ist ihr Leergewicht). Löschen kostet nichts, es ist nur noch nicht getan.

Später, wenn es sich lohnt

  • Vorschaubilder serverseitig (spart dem Dashboard das Laden von 900 Vollbildern)
  • Namensräume je Ressource, mit eigenem Token und eigener Statistik
  • Spieler-Metadaten an einem Upload (wer, wann, welcher Server)
  • Aufräumregeln: was älter als X ist und zu keinem Datensatz gehört
  • Mehrere Benutzer statt nur admin

Offene Entscheidungen

  • Sicherung. Entschieden und gebauttools/sichern.ps1, täglich um 04:30, siehe oben. Weder Restic noch docker cp, sondern SQLites eigene Sicherungsschnittstelle plus ein tar.gz aus einem nur lesend eingehängten Volume. Die Vermutung von damals war richtig: ein blosses Kopieren wäre wegen des WAL still unvollständig gewesen. Offen bleibt das Ziel: die Archive liegen auf derselben Platte wie die Daten. Gegen einen Plattenausfall hilft das nicht — dafür müssten sie in die Nextcloud oder auf eine andere Maschine.
  • Bilder aus dem Spiel (Screenshots, Clips von Spielern) — dafür brauchte es Größenbegrenzungen je Token und vermutlich eine Warteschlange. Erst planen, wenn es ansteht.

Was aus dem Fivemanage-Anlauf hängen bleibt

Steht ausführlich im README, hier nur die Kurzfassung — es sind alles Dinge, die uns wieder begegnen:

  1. Portainer nimmt unter Compose path eine Datei. Ergänzungen werden stillschweigend übergangen: alle Container gesund, alles 502.
  2. Host-Ports an 0.0.0.0 binden, nicht an 127.0.0.1. NPM liegt im Standard-Bridge-Netz und kommt über die Docker-Brücke, nicht über Loopback. Zu macht der Port die Firewall.
  3. Belegt auf diesem Server: 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080, 8090, 9000, 9443, 11000. Unsere: 9100 und 9101.
  4. 9101:8080 ist keine Kollision. Nur die linke Zahl muss einmalig sein.
  5. Ein Dienst, der ohne Konfiguration hochfährt, kostet mehr Zeit als einer, der sich weigert. Deshalb bricht config.ts beim Start ab.