Der Stack laeuft unter https://fivemanage.d4rkst3r.de, hinter NPM mit Let's-Encrypt-Zertifikat, Container auf Port 9101. DER NAME IST EINE UMKEHR, und sie steht ausfuehrlich im ROADMAP: hier stand, dass fivecdn und fivemanage wegfallen, weil wir kein Fivemanage betreiben. Das Argument gilt weiter -- es ist nur teurer als das, was dagegen steht. Der Name war bereits fertig verkabelt: DNS da, NPM-Host da, Zertifikat gueltig bis 09.11.2026, Ziel host.docker.internal:9101, und dieser Port wurde frei, als die Lite-Container gingen. Unser Stack auf 9101 kostet damit null Klicks. Der Wechsel auf media.d4rkst3r.de bleibt billig, weil keine Adresse in der Datenbank steht. Ueber die oeffentliche Adresse durchgemessen: Anmeldung durch echtes HTTPS (der Fall, den curl bisher nicht bezeugen konnte), Token mit Praefix-Fessel, 403 mit Text fuer anderswo/ und fuers Loeschen, Upload und Abruf Byte fuer Byte identisch. Und ein Befund aus dem Fotostudio-Anschluss: die Pfadmeldung war irrefuehrend. SEGMENT verlangt als ERSTES Zeichen einen Buchstaben oder eine Ziffer, die Meldung sprach aber nur von "nicht mit Punkt beginnend" -- ein fuehrender Unterstrich faellt ebenso durch. Kein erfundener Fall: das Fotostudio legt seine Leerbilder als "_plate" ab. Jetzt nennt die Meldung die Regel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
20 KiB
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:fivecdnundfivemanagefallen 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 aufhost.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_URLgebaut. Ein späterer Umzug aufmedia.d4rkst3r.deist 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_HOSTder Host ausPUBLIC_URL. Bei zwei Namen ist das richtig, bei einem tödlich: dann galt jede Anfrage als Anfrage an den Dateiwirt,/healthgab 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:
- 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. DELETE /api/media/…undGET /api/exists/…sahen am Ziel vorbei.c.req.pathträgt den Einhängepunkt mit, dasreplace(/^\/media\//)schnitt ihn nicht weg — ausvehicles/adder.pngwurdeapi/media/vehicles/adder.png. Löschen fand nie etwas,existsmeldete immerfalse. Jetzt:pfad{.+}; Hono liefert den Parameter fertig dekodiert (nachgemessen), ein zweitesdecodeURIComponentwäre eine Dekodierung zu viel gewesen.- Verzeichnisdurchstieg in der SPA-Rückfallroute.
GET /..%5Cpackage.jsonhat unter Windows die Datei ausgeliefert: Hono reicht%5Cdurch,path.joinbehandelt 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 dieselberesolve-Prüfung wie instorage.ts. - Die Auskunft „Oberfläche ist nicht gebaut" war unerreichbar.
createReadStreammeldet eine fehlende Datei asynchron; dastry/catchdarum fing nichts. Ergebnis war ein leerer 200 samt ENOENT im Log. Jetzt wird vorher nachgesehen. - 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 fehlthtmlmit Absicht, sonst könnte jeder mit einem Upload-Token eine Seite unterfivecdn.d4rkst3r.deveröffentlichen. Nachgemessen: eine hochgeladene.htmlkommt weiterhin alsoctet-streammitnosniff. - 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 -f docker-compose.media.yml up -d --build
Was jetzt gemessen ist und vorher nur behauptet war:
npm ci --omit=devzieht die fertige Binärdatei. Im Abbild liegtbetter-sqlite3/build/Release/better_sqlite3.node, datiert Mai 2025 — heruntergeladen, nicht übersetzt; einMakefileoderobj.targetvonnode-gypsteht 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 — ohnecurlim Abbild,fetchaus 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
adminwird nicht noch einmal angelegt. Nebenbei bestätigt, was im Compose steht: ein geändertesADMIN_PASSWORDsetzt 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 — erstPUBLIC_URL, dannADMIN_PASSWORD. Mit beiden fährt der Stack hoch und bindet0.0.0.0:9102->8080. Securesteht wirklich im Cookie, wennNODE_ENV=productiongilt: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=productionsetzt das Sitzungs-Cookie aufSecure. 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 überlocalhostführt in die Irre: das gilt Browsern als sicherer Kontext und funktioniert. Undcurltaugt hier nicht als Zeuge — der schickt ein Secure-Cookie auch überhttpund 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
curlbisher nicht bezeugen konnte:Secuream Cookie stört jetzt nicht mehr, weil die Verbindung wirklich TLS ist. 200, Cookie da,/auth/me200. - Token mit Präfix-Fessel
vehicles/und ohne Löschrecht angelegt. Upload nachanderswo/→ 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.
⬜ Als Nächstes
3 · server/upload.lua im Fotostudio — geschrieben, im Spiel noch nicht
gelaufen. 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.
Was offen bleibt, bis es einmal im Spiel lief: ob PerformHttpRequest,
SetTimeout und promise sich in FiveM so verhalten wie die Fälschungen, und
ob der Event-Handler den Rückruf sauber erreicht. Dafür fehlt genau ein
Handgriff, und er braucht die Serverkonsole:
refresh
ensure d4rk_photostudio (bzw. restart d4rk_photostudio)
Beim Start sagt die Resource in einer Zeile, ob der Dienst antwortet. Danach
/studio shot <modell> im Spiel — das Bild muss unter
https://fivemanage.d4rkst3r.de/f/vehicles/<modell>.webp stehen.
Nach diesem Schritt ist das erreicht, worum es ursprünglich ging.
4 · Dashboard. React 18, TypeScript, Vite 5, Tailwind 4 (CSS-first), Lucide, Zustand. Kein Konsta — das ist für die Handy-Oberfläche richtig, für ein Dashboard am Monitor nicht.
Anmeldung · Galerie mit Vorschau, Suche, Mehrfachauswahl · Löschen · Speicherverbrauch nach Ordnern · Token-Verwaltung, Klartext genau einmal.
Die eine Regel: jeder Knopf gibt Rückmeldung, auch der erfolgreiche. Das ist der Fehler, an dem wir bei Fivemanage einen Nachmittag verloren haben, und er ist billig zu vermeiden.
5 · Umzug. Root-docker-compose.yml wird unsere; die Fivemanage-Dateien
wandern nach legacy/.
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. Ein Volume mit Bildern und
media.db. Restic gegen die Nextcloud? Oder reicht eindocker cpvor größeren Änderungen? Was dabei feststeht: wegen WAL gehörenmedia.db-walundmedia.db-shmmit ins Kopieren, sonst fehlen die letzten Uploads. Sauberer wäresqlite3 media.db ".backup …"oder einwal_checkpoint(TRUNCATE)davor — beides ungeprüft, weil noch nicht entschieden ist, wie gesichert wird. - 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:
- Portainer nimmt unter
Compose patheine Datei. Ergänzungen werden stillschweigend übergangen: alle Container gesund, alles 502. - Host-Ports an
0.0.0.0binden, nicht an127.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. - Belegt auf diesem Server: 80, 81, 443, 2224, 3000, 3080, 3478, 8000, 8080, 8090, 9000, 9443, 11000. Unsere: 9100 und 9101.
9101:8080ist keine Kollision. Nur die linke Zahl muss einmalig sein.- Ein Dienst, der ohne Konfiguration hochfährt, kostet mehr Zeit als
einer, der sich weigert. Deshalb bricht
config.tsbeim Start ab.