1. Es gibt keine zweite Maschine. Gebaut wurde auf DEM Server -- hier laufen Gitea, Portainer, NPM und die Nextcloud. Die Notiz "auf dieser Maschine gibt es weder Docker noch WSL" stammt aus der Zeit vor Docker Desktop. Zum Betrieb fehlt damit kein Umzug, sondern nur der oeffentliche Auftritt: DNS, ein Host in NPM mit TLS, ein echtes Passwort. 2. Die Portliste war unvollstaendig -- 8443 (Nextcloud AIO) fehlte. Neu gemessen aus "docker ps", nicht aus der Erinnerung. 9100/9101 sind frei, seit die Lite-Container weg sind; wir bleiben trotzdem auf 9102. 3. "Bis dahin bleibt der Lite-Stack als Vergleichsmassstab laufen" gilt nicht mehr: die Container sind geloescht. Was liegen blieb, sind drei Volumes und die Abbilder. Alle drei Volumes schreibgeschuetzt durchgezaehlt -- KEIN einziges Bild, minio besteht aus 244 KB reiner .minio.sys. Loeschen kostet also nichts; es ist nur noch nicht getan. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
329 lines
16 KiB
Markdown
329 lines
16 KiB
Markdown
# 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.** `fivecdn.d4rkst3r.de` und `fivemanage.d4rkst3r.de` fallen weg —
|
|
wir betreiben kein Fivemanage, also tragen unsere Adressen auch nicht dessen
|
|
Namen. Dashboard und API liegen an der Wurzel, die Dateien unter `/f/`:
|
|
|
|
```
|
|
https://media.d4rkst3r.de/ Dashboard und API
|
|
https://media.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.
|
|
|
|
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:**
|
|
|
|
```bash
|
|
cp server/.env.example server/.env
|
|
```
|
|
|
|
```bash
|
|
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:**
|
|
|
|
```bash
|
|
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=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.
|
|
|
|
Host-Port bleibt **9102**. 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. **9100 und 9101 sind frei**, seit die Lite-Container weg
|
|
sind; wir bleiben trotzdem auf 9102, weil die Zahl in dieser Datei, im Compose
|
|
und in der Erinnerung schon zusammengehört.
|
|
|
|
### ⬜ Als Nächstes
|
|
|
|
**3 · `server/upload.lua` im Fotostudio.** `Upload.put(pfad, bytes) → url`.
|
|
Token in `config.upload.lua`, server-only, gitignored, mit committetem
|
|
`.example` — nie im NUI, dort hat es jeder Spieler im Speicher. An den
|
|
Serienlauf hängen, damit freigestellte Bilder nach der Freigabe sofort
|
|
hochgehen.
|
|
|
|
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 ein `docker cp` vor größeren Änderungen?
|
|
**Was dabei feststeht:** wegen WAL gehören `media.db-wal` und `media.db-shm`
|
|
mit ins Kopieren, sonst fehlen die letzten Uploads. Sauberer wäre
|
|
`sqlite3 media.db ".backup …"` oder ein `wal_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:
|
|
|
|
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.
|