Ich hatte "gleiches Haus" geschrieben und damit die Platte am Router als
halben Schritt abgetan. Falsch: der Rechner ist ein Windows-Root-Server im
Rechenzentrum, bedient von zu Hause ueber mRemoteNG. Ein Ziel beim Betreiber
daheim sind also zwei Gebaeude, zwei Netze, zwei Stromkreise, zwei Anbieter --
besser wird ein Sicherungsziel kaum.
Gemessen, was dafuer schon dasteht:
sshd Running, Automatic, 0.0.0.0:22 und [::]:22
Firewall "OpenSSH SSH Server (sshd)" -> Allow
sshd_config Subsystem sftp sftp-server.exe
je Nacht d4rk_media 335,9 MB + d4rkbot 0,9 MB = 336,9 MB
Auf der Serverseite fehlt nichts ausser einem Konto, das nur lesen darf. Der
Rest ist ein Skript auf dem Heimrechner, das HOLT statt zu bekommen -- ein
Ziel, in das der Server schreiben darf, ist auch eines, das ein uebernommener
Server loeschen kann.
Fund am Rande: in sshd_config steht kein PasswordAuthentication no. Der
OpenSSH-Standard ist yes -- auf Port 22, offen ins Internet, mit AllowGroups
einschliesslich administrators.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1297 lines
63 KiB
Markdown
1297 lines
63 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.** 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:**
|
||
|
||
```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 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:
|
||
|
||
```nginx
|
||
# 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 1–5 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.
|
||
|
||
**Der Proxy ist erledigt.** `docs/npm-advanced.conf` ist eingetragen und
|
||
gespeichert; `tools/proxy-pruefen.ps1` misst nach, statt vermuten zu lassen:
|
||
|
||
```
|
||
[ok] /f/ Cache-Control public, max-age=300
|
||
[ok] /t/ Cache-Control public, max-age=60
|
||
[ok] /api/ mit Token -> 200 HTTP 200
|
||
[ok] /api/ dieselbe Adresse ohne Token -> 401 HTTP 401
|
||
[ok] /api/exists?pfad ohne Token -> 401 HTTP 401
|
||
```
|
||
|
||
Und die drei Folgen, die daran hingen, gegengeprüft:
|
||
|
||
| | vorher | jetzt |
|
||
|---|---|---|
|
||
| `/api/exists/…webp` **ohne** Token | `200` mit voller Auskunft | `401` |
|
||
| dieselbe Adresse **mit** gültigem Token | `401` (zwischengespeichert) | `200` |
|
||
| Vorschau nach erneutem Upload | bis zu 4 h alt | `max-age=60` |
|
||
| WebP-Aushandlung auf `/f/` | `Vary` wurde entfernt | `vary: Accept` steht |
|
||
|
||
Die WebP-Aushandlung am selben Bild nachgemessen: ohne `Accept` kommen
|
||
`image/png` mit 12 295 B, mit `Accept: image/webp` kommen `image/webp` mit
|
||
2 270 B — **82 % weniger, gleiche Adresse.**
|
||
|
||
> Was die Sache gelehrt hat, steht in `docs/npm-advanced.conf` als Kommentar:
|
||
> `assets.conf` greift nach der **Endung** der Adresse, nicht nach ihrer
|
||
> Bedeutung. Alles, was auf `.webp`, `.png`, `.js` endet, fällt hinein — auch
|
||
> eine API-Antwort. Und `proxy_cache_key` kennt den `Authorization`-Kopf nicht,
|
||
> also wird dieselbe Antwort an alle ausgeliefert, in beide Richtungen.
|
||
>
|
||
> Deshalb bleibt `/api/exists?pfad=…` die empfohlene Form: sie ist dicht, **ohne**
|
||
> von dieser Proxy-Einstellung abzuhängen. Eine Zugangsprüfung, die an einer
|
||
> Zeile in einer fremden Oberfläche hängt, ist keine.
|
||
|
||
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-49` → `206`, 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`, `log` — **alle 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.
|
||
|
||
### ✅ Fertig — Vorschau für Video und Ton, und zwei Ordner-Lücken
|
||
|
||
Mit den ersten echten Clips fiel auf, was bei 3600 Bildern nie auffiel: alles,
|
||
was kein Bild ist, bekam ein **Fragezeichen** auf die Kachel — also ausgerechnet
|
||
das Zeichen für „unbekannt" auf einer völlig gesunden MP4.
|
||
|
||
**Jetzt:** Standbild aus dem Clip (ein Zehntel hinein, weil viele mit Schwarz
|
||
beginnen), bei Ton das eingebettete Titelbild, dazu ein Abspielzeichen und die
|
||
**Laufzeit** in der Ecke. Fehlt eine Vorschau, kommt ein Zeichen, das die *Art*
|
||
benennt, statt eines Fragezeichens.
|
||
|
||
**Der Preis: ffmpeg im Abbild** — und der wurde zweimal gemessen und zweimal
|
||
gedrückt:
|
||
|
||
| Weg | Abbild |
|
||
|---|---|
|
||
| vorher | 460 MB |
|
||
| `apt-get install ffmpeg` | **1,11 GB** |
|
||
| statisches ffmpeg **+** ffprobe | 835 MB |
|
||
| statisches ffmpeg **allein** | **648 MB** |
|
||
|
||
Debians Paket schaltet jeden Filter frei und zieht die Abhängigkeiten mit:
|
||
`libllvm15` (114 MB), Mesa samt `libgl1-mesa-dri`, `libz3`, und `libflite1` —
|
||
ein **Sprachsynthesizer**. Alles für Filter, die ein Dienst ohne Bildschirm nie
|
||
anfasst; `--no-install-recommends` half nicht, es sind harte Abhängigkeiten.
|
||
|
||
`ffprobe` flog danach auch noch raus: 135 MB für eine einzige Aufgabe, die
|
||
Länge zu nennen — die steht auch im Kopf, den `ffmpeg` beim Öffnen druckt.
|
||
Gegengeprüft an beiden Dateien, auf zwei Nachkommastellen identisch:
|
||
|
||
```
|
||
ffprobe 29.767967 | ffmpeg Duration: 00:00:29.77
|
||
ffprobe 645.642449 | ffmpeg Duration: 00:10:45.64
|
||
```
|
||
|
||
**Ein Fehler, der zweimal derselbe war.** „Keine Bildspur" ist bei Ton der
|
||
Normalfall — die meisten MP3 tragen kein Titelbild. Der erste Anlauf erkannte
|
||
das am *Wortlaut* der Fehlermeldung, und der ist zwischen den Fassungen anders:
|
||
|
||
```
|
||
ffmpeg 5.1 "Stream map '0:v:0' matches no streams."
|
||
ffmpeg 7.1 "Failed to set value '0:v:0' for option 'map'"
|
||
```
|
||
|
||
Ergebnis: eine gesunde MP3 wurde als **„1 fehlgeschlagen"** gezählt. Gelesen
|
||
wird jetzt die *Spurenliste* (`Stream #0:1: Video:`), die seit jeher so
|
||
dasteht — und die kommt aus demselben Aufruf wie die Länge, also ein Blick in
|
||
die Datei statt drei. Gemessen: `fehler: 0, ohneBild: 1`.
|
||
|
||
Beide Wege durchgeprüft — Video: 320×180, Durchschnittsfarbe RGB(97,140,155),
|
||
also echtes Bild und kein schwarzer Vorspann. Ton mit angehängtem Titelbild:
|
||
Vorschau erzeugt, `dauer: 8.05` → Kachel zeigt `0:08`.
|
||
|
||
**Und zwei Ordner-Lücken, beide vom Benutzer gefunden:**
|
||
|
||
1. **„Wie lege ich neue Ordner an?"** — Gar nicht, außer man wusste es: der
|
||
Knopf saß **nur im Verschieben-Dialog**. Wer einen leeren Ordner wollte,
|
||
musste also erst Dateien auswählen und „Verschieben" drücken. Eine Funktion,
|
||
die man nur über eine andere Funktion erreicht, ist für den, der sie sucht,
|
||
nicht vorhanden. Jetzt steht sie unter dem Ordnerbaum — mit der Angabe, *in*
|
||
welchem Ordner sie entsteht.
|
||
2. **Zielordner beim Hochladen.** Vorher galt stillschweigend der in der
|
||
Galerie angeklickte Ordner. Richtig, solange man es weiß — und ein
|
||
Fallstrick, sobald man es nicht weiß: wer nach einer Suche hochlädt, steht
|
||
in „Alle" und legt an der Wurzel ab. Der angeklickte Ordner bleibt die
|
||
Voreinstellung, ist jetzt aber sichtbar und änderbar, samt „neuer Ordner"
|
||
direkt daneben.
|
||
|
||
### ✅ Fertig — Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen
|
||
|
||
**Umbenennen** gab es gar nicht — verschieben ja, umbenennen nirgends. Jetzt für
|
||
Datei *und* Ordner. Die Warnung ist dabei der eigentliche Teil: beim Umbenennen
|
||
ändert sich **die Adresse**, und die steht womöglich in einem Skript, das
|
||
niemand mehr im Kopf hat. Der Dialog zeigt deshalb die Abrufzahl („diese Datei
|
||
wurde 4000× geholt") und beim Ordner die Zahl der betroffenen Dateien, **bevor**
|
||
gedrückt wird.
|
||
|
||
Am echten Fall geprüft — der Kollisionsfund von oben: `items/coiloverss.png`
|
||
(hielt die `+`-Fassung) → `items/coiloverss-plus.png`, alte Adresse 404, neue
|
||
200 mit 4375 B, Vorschau mitgewandert. Ordner: `probe` → `beispiele`, 5 Dateien,
|
||
alle Datenbankpfade in einer Transaktion umgeschrieben.
|
||
|
||
> **Und der Fallstrick beim Ordner:** `thumbPath` hängt `.webp` an. Für eine
|
||
> Datei richtig, für einen Ordner Unsinn — der Vorschauordner heißt
|
||
> `thumbs/vehicles` und nicht `thumbs/vehicles.webp`. Dafür gibt es jetzt
|
||
> `moveFolder`.
|
||
|
||
**Die 93 Kollisionen sind erledigt.** `tools/kollisionen.ps1` rechnet dieselbe
|
||
Zähmung auf der Quelle nach und zeigt, was zusammenfällt. Ergebnis: **91 von 93
|
||
sind dieselbe Datei zweimal** — `WEAPON_SMG.png` und `weapon_smg.png`, byteweise
|
||
gleich groß. Da fehlte nichts. **Echt verloren waren zwei**, beide mit `+` im
|
||
Namen (das `+` wird zum Strich und dann abgeschnitten). Beide nachgeholt.
|
||
|
||
**Fivemanage-Sprache.** `POST /api/image|video|audio` (v1) und `POST /api/v3/file`
|
||
(v3), Schlüssel nackt im `Authorization`-Kopf. Der Umzug fremder Skripte ist
|
||
damit eine Zeile: die Adresse. Beide Formen sind aus echtem Code abgelesen —
|
||
`fivemanage/sdk` und `Awleks/Devm-Camera` —, nicht geraten. Gemessen: v1 →
|
||
`{url,id,path}`, v3 → `{status:"ok",data:{id,url}}`, ohne Schlüssel 401, Video
|
||
an `/api/image` → 415 mit Grund.
|
||
|
||
> **Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar
|
||
> warnt.** Die Token-Wache hing an `use('*')` — und der Einhängepunkt ist
|
||
> `/api`, also galt sie auch für `/api/dash` daneben. Das Dashboard bekam 401
|
||
> auf die *Anmeldung*. Wortwörtlich derselbe Fehler steht seit Monaten in
|
||
> `upload.ts` als Kommentar, weil er dort schon einmal passiert ist. Gemerkt hat
|
||
> es der Gegentest, nicht der Kopf.
|
||
|
||
**Freigabe-Links.** `/s/<schlüssel>` zeigt einen Ordner ohne Anmeldung — ein
|
||
Katalog für Spieler. Was dabei ausdrücklich dabeisteht, in der Karte *und* in
|
||
der Rückfrage vor dem Zurückziehen: **freigegeben wird die Liste, nicht der
|
||
Inhalt.** Die Dateien sind ohnehin öffentlich; ein zurückgezogener Link macht
|
||
sie nicht wieder privat, er nimmt nur die Übersicht weg. Ohne diesen Satz zieht
|
||
jemand einen Link zurück und glaubt, etwas sei verschwunden.
|
||
|
||
Die Antwort ist abgemessen: Name, Größe, Art, Adresse, Länge. **Nicht**: Hash,
|
||
Token, Zeitpunkte, Abrufzahlen, IDs. Ein unbekannter Schlüssel und ein
|
||
zurückgezogener geben dieselbe Antwort — ein Unterschied wäre eine Auskunft an
|
||
jemanden, der Schlüssel durchprobiert.
|
||
|
||
**Zahlen, die schon da waren.** Sortieren nach Abrufen und nach „zuletzt geholt"
|
||
(die Kachel zeigt dann auch diese Zahl statt Größe und Datum — nach etwas zu
|
||
ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begründung). Und je
|
||
Token, was damit abgelegt wurde: `media.token_id` wird seit dem ersten Tag
|
||
geschrieben und war **nirgends** zu sehen. Gemessen: `d4rk_photostudio` hält 810
|
||
Dateien / 99,5 MB, 3640 Dateien liegen ohne Token da (aus dem Dashboard).
|
||
|
||
### ✅ Fertig — Konten, Kontingent, Plattenwächter, Umbenennen im Verlauf
|
||
|
||
**Konten sichtbar.** Es gab zwei — `admin` und eines aus Discord — und **keine
|
||
Stelle, an der man sie sehen konnte**. Weil jeder mit der Rolle auf dem
|
||
Discord-Server beim ersten Anmelden still ein Konto bekommt, wären aus zwei
|
||
unbemerkt zwanzig geworden. Jetzt: Liste unter *Einstellungen → Konten* mit
|
||
Weg (Discord/Passwort), angelegt, zuletzt da, offene Sitzungen — und ein
|
||
Entfernen, das die Sitzungen mitnimmt.
|
||
|
||
Zwei Sperren, beide mit Grund: das **eigene** Konto lässt sich hier nicht
|
||
entfernen (der eine Klick, nach dem niemand mehr hereinkommt), und das
|
||
**letzte** auch nicht. Und die Rückfrage sagt bei einem Discord-Konto dazu, dass
|
||
die nächste Anmeldung ein neues anlegt, solange die Rolle bleibt — sonst hält
|
||
jemand das Entfernen für eine Aussperrung.
|
||
|
||
**Kontingent je Token.** `max_bytes` gab es längst, aber es galt **je Datei**:
|
||
ein Token mit „höchstens 2 MB je Bild" konnte trotzdem die Platte füllen, es
|
||
brauchte nur genug Bilder. `quota_bytes` gilt für alles zusammen, gezählt über
|
||
`media.token_id` — also über das, was wirklich liegt, statt über einen
|
||
mitlaufenden Zähler, der auseinanderlaufen kann. Auf **beiden** Wegen geprüft
|
||
(eigene API und Fivemanage-Weg): eine Grenze, die nur an einer von zwei Türen
|
||
hängt, ist keine.
|
||
|
||
> Die erste Fassung meldete *„hält 0.0 MB von 0.0 MB, und diese Datei braucht
|
||
> 0.0 MB"* — formal richtig, praktisch die Meldung, die genau die Frage nicht
|
||
> beantwortet, wegen der man sie liest. Die Einheit wächst jetzt mit:
|
||
> **„hält 12 KB von 20 KB, und diese Datei braucht 12 KB."**
|
||
|
||
**Plattenwächter.** Meldet nach Discord bei 80 % (knapp), 90 % (eng) und 95 %
|
||
(kritisch) — je Stufe genau einmal, und wenn wieder Luft ist, meldet der nächste
|
||
Engpass erneut. Der Grund steht in der Meldung: eine volle Platte ist der eine
|
||
Zustand, in dem gleichzeitig nichts mehr hereinkommt **und die Sicherung nicht
|
||
mehr schreiben kann**.
|
||
|
||
Gemessen — und die Abweichung zu `df` ist gewollt:
|
||
|
||
```
|
||
gesamt 1006,9 GB
|
||
frei (bavail) 909,5 GB -> 9,7 % belegt <- was der Wächter sieht
|
||
frei (bfree) 960,7 GB -> 4,6 % belegt
|
||
df sagt 5 %
|
||
```
|
||
|
||
`bavail` zieht die ext4-Reserve ab, an die der Dienst als Nicht-root ohnehin
|
||
nicht herankommt. Er warnt damit etwas früher — die richtige Richtung.
|
||
|
||
> Dabei fiel ein **schlafender Fehler** auf: der Merker gegen Wiederholungen
|
||
> lag unter **einem** Schlüssel für alle Wachhunde. Mit zwei davon hätten sie
|
||
> sich gegenseitig überschrieben — einer meldet, der andere hält daraufhin
|
||
> seinen Zustand für neu. Der Merker hängt jetzt am Anlass.
|
||
|
||
**Umbenennen im Verlauf.** Stand dort als „verschoben". Technisch stimmt das,
|
||
für den Leser nicht: verschoben heißt „liegt woanders", umbenannt heißt „heißt
|
||
anders, liegt noch da" — und genau diesen Unterschied schlägt man im Verlauf
|
||
nach. Eigene Art mit eigenem Zeichen; die Filterleiste kennt jetzt auch
|
||
`move` und `rename`, die vorher gar nicht filterbar waren.
|
||
|
||
**Und der Einzeiler im Fotostudio** ist gesetzt: `Upload.exists` fragt über
|
||
`/api/exists?pfad=…` statt über den Pfad, mit dem Grund als Kommentar daneben.
|
||
Syntaktisch gegengeprüft (`luac -p`).
|
||
|
||
### ✅ Fertig — die Sicherung ist zurückgespielt worden
|
||
|
||
Bis hierhin war sie **geprüft**, nicht **erprobt**. `sichern.ps1` packt das
|
||
Archiv aus, öffnet die Datenbank, zählt, lädt hoch, holt zurück und vergleicht
|
||
die Prüfsumme — das beantwortet „ist die Datei heil?". Es beantwortet nicht
|
||
„kommt daraus wieder ein Dienst?".
|
||
|
||
Dazwischen liegen Handgriffe, die man sonst zum ersten Mal an dem Abend macht,
|
||
an dem man sie unter Druck macht: wie das Volume heißt, dass der Container
|
||
stehen muss, wem die Dateien gehören müssen, dass `thumbs/` und `webp/`
|
||
abgeleitet sind.
|
||
|
||
`tools/zurueckspielen.ps1` macht beides — und die **Übung ist der Standard**:
|
||
Wegwerf-Volume, Archiv hinein, ein *zweiter* Container davor, drei zufällige
|
||
Dateien **durch den Dienst** geholt und ihr SHA-256 gegen die Datenbank
|
||
geprüft, danach alles abgeräumt. Der laufende Dienst wird nicht angefasst.
|
||
|
||
Am 11.08.2026 gelaufen, mit dem Archiv von 23:07:
|
||
|
||
```
|
||
4452 Medieneinträge, 316,3 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
|
||
4452 Dateien -- gleich viele wie Einträge
|
||
ok items/velum2.png 113567 B
|
||
ok items/flesh-dolphin-2star.png 3623 B
|
||
ok items/burgermeat.png 4269 B
|
||
|
||
Die Uebung ist bestanden.
|
||
```
|
||
|
||
Der Ernstfall (`-Echt`) verlangt ein getipptes `ZURUECKSPIELEN`, hält den
|
||
Container an, ersetzt `media.db`, `files/` und `papierkorb/`, **löscht
|
||
`thumbs/` und `webp/`** (Abgeleitetes stehenzulassen wäre schlimmer: die
|
||
Galerie zeigte die Vorschau einer Datei, die dort nicht mehr liegt), startet
|
||
wieder und zählt selbst nach.
|
||
|
||
### ✅ Fertig — /status für die Statusseite, und ein Fehler, den es dabei fand
|
||
|
||
**Es gab schon `/health`**, und es bleibt, wie es ist:
|
||
|
||
```ts
|
||
app.get('/health', (c) => c.json({ ok: true }))
|
||
```
|
||
|
||
Diese Zeile beweist genau eines: der Prozess nimmt Anfragen an. Daran hängt der
|
||
`HEALTHCHECK` des Containers, und **dort** ist billig richtig — eine schwere
|
||
Prüfung, die bei einer langsamen Platte einmal ausfällt, ließe Docker den
|
||
Container neu starten, also genau dann, wenn er unter Last steht.
|
||
|
||
Für eine Statusseite ist das zu dünn: sie stünde auf Grün, während die Platte
|
||
voll ist. `/status` sieht deshalb wirklich nach — Datenbank (echte Abfrage),
|
||
Platte (schreiben *und wieder löschen*), Ausliefern (zufällige Datei nachmessen),
|
||
Bestand, Platz, Sicherung. **200** wenn der Dienst sein Geschäft tut, **503**
|
||
wenn nicht; `?streng=1` lässt auch eine Beeinträchtigung rot werden.
|
||
|
||
Öffentlich, aber wortkarg: keine Dateizahlen, Größen, Pfade, Tokennamen oder
|
||
Benutzer. Und mit einer 10-Sekunden-Bremse — ein öffentlicher Endpunkt, der auf
|
||
die Platte schreibt, wäre sonst ein Verstärker.
|
||
|
||
**Beide Wege gemessen**, der weiche am laufenden Dienst (Sicherungszeitpunkt auf
|
||
40 Stunden gesetzt und wieder zurück):
|
||
|
||
```
|
||
stand: beeintraechtigt · /status -> 200 · /status?streng=1 -> 503
|
||
```
|
||
|
||
#### Und dabei fiel ein Fehler in meinem eigenen Zurückspiel-Skript auf
|
||
|
||
Der harte Weg sollte an einem Wegwerf-Container geprüft werden. Der traf
|
||
zufällig auf ein **gebrauchtes** Volume — und zeigte:
|
||
|
||
| | Zeilen in `media.db` |
|
||
|---|---|
|
||
| mit den alten `media.db-wal` / `-shm` daneben | **0** |
|
||
| dieselbe Datei ohne sie | **4452** |
|
||
| Dateien auf der Platte | **4452** |
|
||
|
||
`zurueckspielen.ps1` entfernte `media.db`, aber **nicht die beiden
|
||
WAL-Begleiter**. Die liegen bei einem echten Zurückspielen immer da — der
|
||
laufende Dienst arbeitet im WAL-Modus. SQLite spielt das WAL der *alten*
|
||
Datenbank über die *neue*, und heraus kommt der schlimmste denkbare Zustand:
|
||
**der Dienst kommt hoch, `/health` ist grün, die Mediathek ist leer** — während
|
||
alle Dateien danebenliegen.
|
||
|
||
Zwei Konsequenzen:
|
||
|
||
1. Die Aufräumzeile steht jetzt an **einer** Stelle und nimmt `media.db-wal`,
|
||
`media.db-shm` und `*.tmp` mit. Übung und Ernstfall fahren denselben Befehl —
|
||
zwei Fassungen wären zwei, von denen die geübte die harmlosere ist.
|
||
2. **Die Übung täuscht jetzt eine bestehende Installation vor**, bevor sie
|
||
zurückspielt. In ein leeres Volume zu spielen probt den Fall, der nie
|
||
eintritt.
|
||
|
||
```
|
||
==> Eine bestehende Installation vortaeuschen
|
||
im Volume liegt jetzt: files media.db media.db-shm media.db-wal
|
||
==> Zurueckspielen -- mit DEMSELBEN Befehl wie der Ernstfall
|
||
4452 Medieneintraege · 4452 Dateien -- gleich viele
|
||
ok items/shushi.png · items/weedbud_1.png · items/cc-castella.png
|
||
Die Uebung ist bestanden.
|
||
```
|
||
|
||
3. Und `/status` erkennt diesen Zustand jetzt selbst. Am kaputten Container
|
||
gemessen:
|
||
|
||
```
|
||
/health sagt: HTTP 200
|
||
/status sagt: HTTP 503 kein Eintrag in der Datenbank, aber Dateien auf der Platte
|
||
```
|
||
|
||
Genau dafür gibt es die Übung: gefunden beim Üben, nicht im Ernstfall.
|
||
|
||
### ⬜ Entschieden, noch nicht umgesetzt — der Name wird `media.d4rkst3r.de`
|
||
|
||
Der alte Name bleibt daneben stehen und zeigt auf denselben Dienst. Kein
|
||
Umleiten, kein Stichtag: **beide Namen funktionieren**, und die Skripte lassen
|
||
sich in Ruhe umstellen statt an einem Abend alle gleichzeitig.
|
||
|
||
Möglich ist das, weil `FILES_HOST` leer ist — der Dienst achtet gar nicht auf
|
||
den Hostnamen und antwortet unter jedem. Nachgemessen: `GET /health` mit
|
||
`Host: media.d4rkst3r.de` an Port 9101 gibt bereits **200**.
|
||
|
||
#### Was der Wechsel kostet — gemessen, nicht geschätzt
|
||
|
||
**In diesem Dienst: eine Variable.** Von 4630 Medienpfaden und 15
|
||
Einstellungen enthält **keine einzige** den Hostnamen. Die Adressen werden bei
|
||
jeder Antwort aus `PUBLIC_URL` gebaut.
|
||
|
||
Außerhalb, vollständig durchsucht:
|
||
|
||
| Wo | Stellen |
|
||
|---|---|
|
||
| `d4rk_phone/config.lua` | `imageUrl`, `brandLogoUrl` |
|
||
| `d4rk_photostudio/config.upload.lua` (+ `.example`) | `base` |
|
||
| `d4rk_photostudio/settings.json` | zwei Bildquellen |
|
||
| d4rkbot, überwachte Dienste | zwei Einträge |
|
||
| Freigabe-Links | zwei, bereits herausgegeben |
|
||
|
||
#### Was du tun musst
|
||
|
||
**1. DNS.** Ein A-Eintrag `media` → `88.218.224.10`, also dieselbe Adresse wie
|
||
`fivemanage`. Der Platzhalter `*.d4rkst3r.de` hilft **nicht**: er zeigt auf
|
||
`185.194.237.210` und damit woandershin.
|
||
|
||
> **Und nicht über Cloudflare leiten.** `hub.d4rkst3r.de` antwortet von
|
||
> `188.114.96.3` (Cloudflare), `fivemanage.d4rkst3r.de` direkt von
|
||
> `88.218.224.10`. Der neue Name gehört zur zweiten Sorte — graue Wolke. Sonst
|
||
> steht ein **zweiter** Zwischenspeicher davor, der nach Dateiendung greift, und
|
||
> wir haben genau die Probleme wieder, die der NPM-Block gerade gelöst hat:
|
||
> alte Vorschauen und ein weggeworfenes `Vary: Accept`.
|
||
|
||
**2. NPM.** Neuer Proxy-Host `media.d4rkst3r.de` → `host.docker.internal:9101`,
|
||
Zertifikat holen — **und den Inhalt von `docs/npm-advanced.conf` auch dort
|
||
einfügen.** Ein neuer Host bringt seine eigene `assets.conf` mit.
|
||
|
||
**3. Sagen.** Dann setze ich `PUBLIC_URL` um und ziehe Dokumentation, Fotostudio,
|
||
Handy und die Watchdog-Einträge nach.
|
||
|
||
#### ✅ Umgestellt am 12.08.2026
|
||
|
||
`PUBLIC_URL=https://media.d4rkst3r.de`. **Beide Namen liefern weiter aus** —
|
||
`FILES_HOST` ist leer, der Dienst achtet gar nicht auf den Hostnamen, und in
|
||
NPM stehen beide auf demselben Host. Nachgemessen: unter beiden Namen `health
|
||
200` und dasselbe Bild.
|
||
|
||
Nachgezogen: **7 Stellen** in den Ressourcen (`d4rk_phone/config.lua` ×2,
|
||
`config.upload.lua`, `config.upload.example.lua` ×2, `settings.json` ×2),
|
||
**2 Watchdog-Einträge** im Bot, **16 Stellen** in Dokumentation und Werkzeugen.
|
||
Die Freigabe-Links zeigen von selbst auf den neuen Namen — sie werden aus
|
||
`PUBLIC_URL` gebaut — und die alten funktionieren weiter.
|
||
|
||
> **Der Lua-Syntaxtest schlug bei `d4rk_phone/config.lua` fehl**, und zwar an
|
||
> Zeile 93: `` customModel = `prop_player_phone_02` ``. Das ist kein Schaden,
|
||
> sondern eine **CfxLua-Erweiterung** — Backticks sind dort ein joaat-Hash und
|
||
> in Standard-Lua 5.4 ein Syntaxfehler. 85 davon stecken in der Datei. `luac -p`
|
||
> taugt für CfxLua-Dateien also nicht; geprüft wurde stattdessen, dass genau die
|
||
> zwei URL-Zeilen anders sind.
|
||
|
||
#### ⚠️ Und die Falle auf dem Weg dorthin: Cloudflare
|
||
|
||
Am 12.08.2026 eingerichtet. NPM ist dabei sogar besser gelöst als vorgeschlagen:
|
||
**beide Namen auf demselben Host** (`server_name fivemanage.d4rkst3r.de
|
||
media.d4rkst3r.de;`), die drei `^~`-Blöcke gelten damit für beide.
|
||
|
||
Der DNS-Eintrag geht aber über Cloudflare, und **das bricht die
|
||
WebP-Auslieferung.** Gemessen, dieselbe Datei, dieselbe Sekunde:
|
||
|
||
```
|
||
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, prägt den Zwischenspeicher für alle.
|
||
Es sind **unsere eigenen Bytes** (2270 B = unsere WebP-Fassung), also kein
|
||
Cloudflare-Polish, sondern ein **Cache-Schlüssel ohne `Accept`**. `Vary: Accept`
|
||
wird an den Client durchgereicht, aber nicht zum Unterscheiden benutzt —
|
||
Cloudflare variiert standardmäßig nur über `Accept-Encoding`.
|
||
|
||
Für ein Lua-Skript heißt das: `items/foo.png` liefert WebP-Bytes unter einem
|
||
`.png`-Namen, und das fällt erst auf, wenn irgendwo ein Bild nicht lädt.
|
||
|
||
**Gelöst durch die graue Wolke** — Cloudflare-Proxy aus, DNS-only, wie bei
|
||
`fivemanage`. Danach gemessen, dieselben Dateien:
|
||
|
||
```
|
||
media (grau) ohne Accept -> image/png 12295 B
|
||
mit Accept -> image/webp 2270 B
|
||
Server: openresty · vary: Accept · dreimal hintereinander stabil
|
||
Bytes identisch mit dem alten Namen (sha256 stimmt überein)
|
||
```
|
||
|
||
**Was das Umstellen der URLs in den Skripten NICHT gelöst hätte:** das Problem
|
||
saß am Host, nicht am Aufrufer. Hätten alle Skripte auf `media` gezeigt und
|
||
`media` wäre orange geblieben, bekämen *alle* die falschen Bytes — die
|
||
Umstellung hätte es verteilt statt behoben. Wer Cloudflare behalten will,
|
||
braucht dort eine Cache Rule, die `/f/*` und `/t/*` ausnimmt.
|
||
|
||
Gegenprüfen lässt sich beides mit dem, was schon da ist:
|
||
|
||
```powershell
|
||
pwsh -File tools\proxy-pruefen.ps1 -Basis https://media.d4rkst3r.de -Token d4rk_...
|
||
```
|
||
|
||
### ✅ Fertig — Verlauf als CSV, Meldung bei Upload, Deckel auf dem Papierkorb
|
||
|
||
Abschnitt 3 aus `docs/ideen.md`, der Rest der Liste bis auf die Sicherungsziele.
|
||
|
||
**Verlauf als CSV.** `GET /api/dash/verlauf/export`, wahlweise
|
||
`?was=verwaltung`. Der Knopf sitzt in der Reiterleiste und holt den Verlauf,
|
||
**der gerade offen ist** — zwei Knöpfe nebeneinander wären die Frage „welchen
|
||
von beiden?" an einer Stelle, wo die Antwort schon auf dem Bildschirm steht.
|
||
Semikolon und CRLF wie beim Bestands-Export; das war dort nachgemessen worden,
|
||
hier wäre es ein zweiter Anlauf gewesen. Gemessen an der ausgelieferten Datei:
|
||
**5364 Zeilen, 5364 CRLF, kein einziges nacktes LF**, Dateiname
|
||
`d4rk_media-dateien-2026-08-12.csv`.
|
||
|
||
**Deckel auf dem Papierkorb.** 30 Tage **und** 20 GB — was zuerst greift,
|
||
greift. Beim Ausleeren fliegt erst das zu Alte, dann das **Älteste**, bis die
|
||
Grenze wieder passt; dessen Versehen wäre am ehesten schon aufgefallen. Die
|
||
Zahl steht als `maxBytes` in der Antwort von `/api/dash/papierkorb`, damit die
|
||
Oberfläche sie nicht ein zweites Mal hinschreibt — gemessen: `21474836480`.
|
||
|
||
Bei 910 GB frei ist das keine Grenze, sondern eine Zusicherung: der Papierkorb
|
||
kann nicht mehr unbemerkt zur zweiten Ablage werden.
|
||
|
||
**Meldung bei Upload — die Drossel ist der eigentliche Inhalt.** Jeder Upload
|
||
schiebt eine Frist von zwei Minuten nach hinten; erst nach der Ruhe geht *eine*
|
||
Nachricht raus, mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
|
||
|
||
Ohne das wäre die Funktion ein Schaden: 900 Fahrzeugbilder ergäben 900
|
||
Nachrichten, Discord drosselt Webhooks, und wer danach eine echte Meldung
|
||
bekommt, sieht sie nicht mehr.
|
||
|
||
Und **derselbe Fehler wie bei `platte` lauerte schon wieder**: die Liste der
|
||
Anlässe steht an zwei Stellen — im Server und in der Oberfläche —, und der
|
||
Server verwirft beim Speichern alles, was in *seiner* Liste fehlt. Ein
|
||
Kreuzchen, das sich setzen lässt und beim nächsten Laden weg ist. Diesmal
|
||
beide Stellen angefasst und nachgemessen: **vier geschickt, vier
|
||
gespeichert.** In beiden steht jetzt ein Verweis auf die andere.
|
||
|
||
### ✅ Fertig — die Sicherung liegt an drei Orten statt an zweien
|
||
|
||
Der letzte offene Punkt aus `docs/ideen.md`. Ein **zweites Laufwerk**,
|
||
einstellbar im Panel unter Einstellungen → Sicherung; `tools/sichern.ps1` legt
|
||
das Archiv dort ab, vergleicht die Prüfsumme und dünnt auch dort aus.
|
||
|
||
**Warum das nicht dasselbe ist wie die Nextcloud.** Die hilft, wenn der ganze
|
||
Rechner weg ist — aber wer 335 MB, später 100 GB, zurückholen muss, lädt sie
|
||
über die Leitung. Das zweite Laufwerk ist in Minuten zurückgespielt. Zwei
|
||
Fragen, zwei Antworten.
|
||
|
||
**Und es warnt, wenn es dieselbe Platte ist.** Das ist der eigentliche Inhalt:
|
||
ein zweiter Ordner auf `C:` sieht im Panel genauso grün aus wie eine echte
|
||
zweite Platte und hilft gegen gar nichts. Verglichen wird die *physische*
|
||
Platte und nicht der Laufwerksbuchstabe — zwei Partitionen derselben NVMe
|
||
sterben zusammen. Nachgemessen, dieser Rechner:
|
||
|
||
```
|
||
Platte 0 SAMSUNG MZVL21T0HDLU-00B07 954 GB -> C: (Daten + C:\backup)
|
||
Platte 1 SAMSUNG MZVL21T0HDLU-00B07 954 GB -> D: (das zweite Ziel)
|
||
|
||
C:\backup gegen D:\backup -> Nr. 0 gegen Nr. 1 -> "andere Platte"
|
||
C:\backup gegen C:\Users -> Nr. 0 gegen Nr. 0 -> Warnung
|
||
```
|
||
|
||
Ein echter Lauf, kein Trockentest: 4630 Einträge, 335,9 MB, auf beiden lokalen
|
||
Zielen und in der Nextcloud dieselbe Prüfsumme `FFCE4D0A7310…`. Der Bericht in
|
||
`sicherung.letzte` trägt jetzt `zweit: true`; fehlt das Feld, ist **kein**
|
||
zweites Ziel eingerichtet — es steht absichtlich nicht als `false` da, denn das
|
||
läse sich wie „hat nicht geklappt".
|
||
|
||
Die **Statusseite** zählt die Orte mit: `sicherung ok an 3 Orten` statt nur
|
||
`ok`. Eine Sicherung, die es nur einmal gibt, ist grün und trotzdem eine, die
|
||
ein Plattenausfall mitnimmt — das gehört dorthin, wo jemand hinsieht.
|
||
|
||
**Und die Übung ist aus der neuen Kopie gefahren worden, nicht nur aus der
|
||
alten** — sonst wäre das zweite Ziel eine Datei und kein Sicherungsziel:
|
||
|
||
```
|
||
pwsh -File tools\zurueckspielen.ps1 -Ordner D:ackup\d4rk_media
|
||
|
||
4630 Medieneintraege, 340.5 MB, 2 Token, 2 Benutzer, 0 im Papierkorb
|
||
4630 Dateien -- gleich viele wie Eintraege
|
||
drei zufaellige Dateien DURCH den Dienst geholt: identische Bytes
|
||
Die Uebung ist bestanden.
|
||
```
|
||
|
||
`tools/zurueckspielen.ps1` sagt im Kopf, was zu tippen ist, wenn genau
|
||
diese Platte das Problem ist. Das ist der Fall, für den das Ganze da ist, und
|
||
niemand soll ihn um vier Uhr nachts erst herleiten müssen.
|
||
|
||
### ⚠️ Repariert — die geplante Aufgabe zeigte auf einen Pfad mit Versionsnummer
|
||
|
||
Gefunden beim Nachsehen, ob die Sicherung überhaupt läuft. Sie lief (12.08.2026
|
||
04:30:01, Ergebnis 0) — aber sie startete `pwsh.exe` so:
|
||
|
||
```
|
||
C:\Program Files\WindowsApps\Microsoft.PowerShell_7.6.4.0_x64__8wekyb3d8bbwe\pwsh.exe
|
||
```
|
||
|
||
**Die Versionsnummer steht im Pfad.** Beim nächsten PowerShell-Update heißt der
|
||
Ordner anders, die Aufgabe findet ihre `pwsh.exe` nicht mehr, und die Sicherung
|
||
hört auf zu laufen — an einem Tag, an dem niemand etwas an ihr geändert hat.
|
||
Der Wächter hätte es nach 26 Stunden gemeldet; angenehmer ist es, wenn es gar
|
||
nicht erst passiert.
|
||
|
||
Ersetzt durch den stabilen Ausführungsalias, der Updates überlebt:
|
||
|
||
```
|
||
C:\Users\Darkster\AppData\Local\Microsoft\WindowsApps\pwsh.exe
|
||
```
|
||
|
||
Der geht, weil die Aufgabe als `Darkster` und `Interactive` läuft — ein Alias
|
||
unter `LOCALAPPDATA` wäre für einen Dienst unter SYSTEM wertlos gewesen. Vorher
|
||
nachgemessen: derselbe Prozess, dieselbe Fassung 7.6.4, Rückgabewert 0.
|
||
|
||
Danach die Aufgabe von Hand gestartet und den ganzen Weg gemessen, nicht nur
|
||
den Start:
|
||
|
||
```
|
||
Zustand: Ready Ergebnis: 0
|
||
sicherung.letzte: ok: true | zweit: true | hoch: true | 4630 Dateien
|
||
```
|
||
|
||
Die Argumente sind unverändert geblieben; die Aufgabe heißt weiterhin
|
||
`d4rk_media Sicherung` und läuft weiterhin täglich um 04:30.
|
||
|
||
### ✅ Fertig — ein Rundgang durch den laufenden Dienst
|
||
|
||
`python tools/rundgang.py` — 24 Prüfungen über HTTP gegen den Dienst, so wie er
|
||
gerade läuft. Nur lesend: kein Upload, kein Löschen, keine Einstellung wird
|
||
angefasst.
|
||
|
||
**Wozu, wo doch jede Änderung einzeln gemessen wird: weil sie einzeln gemessen
|
||
wird.** An einem Tag mit sechs Neustarts fasst niemand mehr alles an, und die
|
||
Sachen, die keiner mehr anfasst, sind genau die, die kaputtgehen — ein WebP,
|
||
das nach einem Umbau nicht mehr ausgehandelt wird, meldet sich nicht.
|
||
|
||
Zuerst kommt, was **ohne Anmeldung dicht sein muss** (acht Wege, alle 401).
|
||
Das ist die Sorte Fehler, die man nicht sieht: eine Route, die versehentlich
|
||
vor dem Wächter registriert wird, funktioniert prächtig — für alle. Genau das
|
||
ist in diesem Repo schon zweimal passiert.
|
||
|
||
Dann: Bestand, `/f/` mit WebP-Aushandlung an derselben Adresse und `Vary:
|
||
Accept`, beide CSV-Exporte auf nackte LF, Papierkorb-Deckel, Sicherungsziele
|
||
und die Statusseite.
|
||
|
||
**Zwei Fehlschläge beim Bauen, beide im Test und nicht im Dienst** — und
|
||
deshalb stehen sie als Kommentar drin:
|
||
|
||
- Ohne Filter zog er `vehicles/asbo.webp` und verglich WebP mit WebP. Gleiche
|
||
Größe, Test rot, Dienst richtig.
|
||
- Der Filter hieß dann `q=` statt `query=`. Unbekannte Parameter ignoriert der
|
||
Dienst und liefert alles — was wie ein kaputter Filter aussieht und ein
|
||
kaputter Aufruf ist.
|
||
|
||
Ein Test, der das Falsche misst, ist schlimmer als keiner: er wird geglaubt.
|
||
|
||
Stand heute: **24 in Ordnung, 0 nicht.**
|
||
|
||
### ⚠️ Richtigstellung: „drei Orte" sind zwei Platten, und keiner davon ist auswärts
|
||
|
||
Am 12.08.2026 nachgemessen, nachdem die Frage aufkam, wie man Sicherungen aus
|
||
dem Haus bekommt. Das Ergebnis widerspricht dem, was zwei Abschnitte weiter
|
||
oben steht.
|
||
|
||
**Alle Docker-Volumes liegen in einer einzigen Datei:**
|
||
|
||
```
|
||
C:\Users\Darkster\AppData\Local\Docker\wsl\disk\docker_data.vhdx 51,5 GB
|
||
```
|
||
|
||
Das ist Platte 0. Darin liegen die Daten von d4rk_media, die Datenbank des Bots
|
||
— **und die Nextcloud**. `cdn.d4rkst3r.de` und `media.d4rkst3r.de` lösen beide
|
||
auf `88.218.224.10` auf, und die vierzehn `nextcloud-aio-*` Container laufen auf
|
||
genau dieser Maschine.
|
||
|
||
| Ziel | Platte | wirklich getrennt? |
|
||
|---|---|---|
|
||
| `C:\backup\d4rk_media` | 0 | nein, dieselbe wie die Daten |
|
||
| `D:\backup\d4rk_media` | 1 | **ja** |
|
||
| Nextcloud `cdn.d4rkst3r.de` | 0 | **nein — dieselbe Platte wie die Daten** |
|
||
|
||
**Die Nextcloud-Kopie ist die am wenigsten getrennte von allen dreien.** Sie
|
||
stand hier bisher als „die einzige Trennung" — das war falsch, und zwar aus dem
|
||
üblichen Grund: eine Adresse mit eigenem Namen und eigenem Zertifikat *sieht*
|
||
wie ein anderer Ort aus. Nachgesehen hatte das niemand.
|
||
|
||
Was die drei Ziele wirklich abdecken:
|
||
|
||
- **Eine Platte stirbt** → `D:` fängt es auf. Das ist echt und war den Aufwand
|
||
wert.
|
||
- **Der Rechner stirbt, brennt, wird gestohlen oder verschlüsselt** → alle drei
|
||
Kopien sind gleichzeitig weg.
|
||
|
||
Die Statusseite sagt weiterhin `sicherung ok an 3 Orten`. Das ist wörtlich
|
||
richtig — sie zählt Ablageorte, nicht Platten und nicht Gebäude. Wer daraus
|
||
liest „also ist es sicher", liest mehr hinein, als dasteht.
|
||
|
||
**Und wo der Rechner steht, gehört dazu:** ein Windows-Root-Server **im
|
||
Rechenzentrum**, bedient von zu Hause über mRemoteNG. Das macht ein Ziel beim
|
||
Betreiber daheim nicht zu einem halben Schritt, sondern zum richtigen — zwei
|
||
Gebäude, zwei Netze, zwei Anbieter.
|
||
|
||
Dazu steht auf der Serverseite schon alles bereit: `sshd` läuft (Automatic,
|
||
`0.0.0.0:22`), die Firewall lässt ihn durch, das SFTP-Subsystem ist an. Es
|
||
fehlt ein Konto, das nur lesen darf — und ein Skript auf dem Heimrechner, das
|
||
**holt**. Kandidaten und Begründung in `docs/ideen.md`.
|
||
|
||
### ⬜ 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 gebaut** — `tools/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.
|