# Die API von d4rk_media Diese Seite ist für den Fall geschrieben, dass jemand ein Skript anbindet und etwas nicht tut, was es soll. Sie nennt deshalb nicht nur die Endpunkte, sondern auch **was schiefgehen kann und wie die Antwort dann aussieht**. Der Dienst läuft unter `https://media.d4rkst3r.de`. Dateien liegen darunter unter `/f/`. --- ## Der Grundsatz: der Pfad ist der Schlüssel `vehicles/adder.webp` bleibt `vehicles/adder.webp`. Die öffentliche Adresse entsteht daraus, indem der Ursprung davorgesetzt wird: ``` vehicles/adder.webp → https://media.d4rkst3r.de/f/vehicles/adder.webp ``` Deshalb kann die Vorlage in einem Handy-Skript schlicht `{model}.webp` lauten. **Die Adressen stehen nirgends in der Datenbank** — sie werden bei jeder Antwort neu gebaut. Ein Umzug auf einen anderen Namen ist eine geänderte Variable, kein Datenbankeingriff. ### Erlaubte Pfade Jeder Teil zwischen den Schrägstrichen muss mit einem **Buchstaben oder einer Ziffer** beginnen; danach sind zusätzlich Punkt, Strich und Unterstrich erlaubt. Höchstens 8 Ebenen, höchstens 200 Zeichen. Damit sind `..`, versteckte Dateien und alles mit Backslash von vornherein draußen. Was nicht passt, wird **abgelehnt und nicht zurechtgebogen** — sonst läge eine Datei unter einem anderen Namen als dem, den der Aufrufer erwartet, und niemand merkte es, bis das Bild fehlt. > Ein führender Unterstrich fällt durch. Das ist kein erfundener Fall: das > Fotostudio legt seine Leerbilder als `_plate` ab. ### Groß- und Kleinschreibung Was über das Dashboard oder ein Werkzeug hochgeladen wird, kommt **klein** an: `WEAPON_SMG.png` liegt als `weapon_smg.png`. Der Grund ist, dass ein Name zu einer Adresse wird und Adressen sich nicht auf die Schreibweise eines Dateisystems verlassen sollen. Für ein Skript heißt das: **die Vorlage arbeitet mit Kleinbuchstaben.** ```lua local url = ('https://media.d4rkst3r.de/f/items/%s.png'):format(item:lower()) ``` Über `/api/upload` mit eigenem `X-Path` gilt das nicht — dort steht der Pfad so, wie er geschickt wird. --- ## Dieselbe Adresse, zwei Fassungen Neben jedem PNG und JPEG kann eine **WebP-Fassung** liegen. Ausgeliefert wird sie unter **derselben Adresse** — welche der beiden kommt, entscheidet die Kopfzeile `Accept` des Aufrufers: ``` Accept: …image/webp… → Content-Type: image/webp (klein) kein Accept → Content-Type: image/png (das Original) ``` Nachgemessen an `probe/probe-webp.png`: 6448 Bytes für `curl`, 2758 Bytes für einen Browser. **Für ein Skript ändert sich nichts** — Lua schickt kein `Accept: image/webp` und bekommt weiterhin Byte für Byte das Original. Zwei Dinge, die daran hängen: - Die Antwort trägt `Vary: Accept`. Ohne die Zeile legte ein Zwischenspeicher die WebP-Fassung für alle ab, auch für die, die sie nicht lesen können. - Die beiden Fassungen haben **verschiedene ETags** (die WebP-Fassung endet auf `-w`). Sonst bekäme jemand auf seinen `If-None-Match` hin ein 304 für das falsche Bild. Erzeugt werden die Fassungen beim Hochladen; für alles, was vorher schon da war, gibt es im Dashboard unter **Speicher → Wartung** den Knopf „Sparsame Fassungen erzeugen". Wo WebP nicht kleiner ausfällt, entsteht keine — dann bleibt es beim Original. --- ## Umziehen von Fivemanage — ohne Lua anzufassen Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem Dutzend Ressourcen: Kamera, Handy, MDT, Fahrzeugstudio. Sie alle umzuschreiben heißt, fremde Skripte anzufassen — und beim nächsten Update wieder. **Der Dienst spricht deshalb ihre Sprache.** Der Umzug ist eine Zeile je Skript: `api.fivemanage.com` → `media.d4rkst3r.de`. Der Schlüssel bleibt derselbe Kopf, die Antwort dieselbe Form. Als Schlüssel setzt du einen **Token dieses Dienstes** ein. | Fivemanage | hier | Feld | Antwort | |---|---|---|---| | `POST /api/image` | ✅ gleich | `image` (oder `file`) | `{ "url": …, "id": …, "path": … }` | | `POST /api/video` | ✅ gleich | `video` | dito | | `POST /api/audio` | ✅ gleich | `audio` | dito | | `POST /api/v3/file` | ✅ gleich | `file`, `path?`, `filename?` | `{ "status": "ok", "data": { "id", "url" } }` | Der Schlüssel steht **nackt** im `Authorization`-Kopf, ohne `Bearer` — genau so wie bei Fivemanage. `Bearer …` geht weiterhin auch. ```lua -- vorher exports['screenshot-basic']:requestScreenshotUpload( 'https://api.fivemanage.com/api/image', 'image', { headers = { Authorization = apiKey } }, cb) -- nachher — eine Zeile exports['screenshot-basic']:requestScreenshotUpload( 'https://media.d4rkst3r.de/api/image', 'image', { headers = { Authorization = apiKey } }, cb) ``` **Wo die Dateien landen:** unter dem Präfix des Tokens (sonst `uploads/`), mit dem Anfang des SHA-256 im Namen — `screenshots/shot-841814b4089b.png`. Das ist Absicht: Bildschirmfotos heißen alle `screenshot.png`, und ohne eigenen Namen je Aufruf überschriebe das zweite das erste. Gleicher Inhalt ergibt denselben Namen, verschiedener Inhalt einen anderen — dieselbe Eigenschaft, die Fivemanages ID auch hat. **Was NICHT übernommen wird:** `metadata` wird angenommen und ignoriert (es gibt hier keine Spielerdaten an einer Datei), `retentionExempt` ebenso — dieser Dienst löscht nichts von selbst. Presigned URLs gibt es nicht: der Upload geht direkt hierher. > Beide Formen sind **aus echtem Code abgelesen**, nicht geraten: > [fivemanage/sdk](https://github.com/fivemanage/sdk) für v3 und > [Awleks/Devm-Camera](https://github.com/Awleks/Devm-Camera) für den älteren > Weg über `screenshot-basic`. --- ## Für eine Statusseite Zwei Adressen, beide öffentlich, beide ohne Anmeldung — und sie beantworten **verschiedene** Fragen. ``` GET /health -> 200 {"ok":true} GET /status -> 200 | 503 mit Begründung ``` `/health` beweist genau eines: **der Prozess nimmt Anfragen an.** Daran hängt auch der `HEALTHCHECK` des Containers, und dort ist eine billige Prüfung richtig — eine schwere, die bei einer langsamen Platte einmal ausfällt, ließe Docker den Container neu starten, also genau dann, wenn er unter Last steht. `/status` sieht wirklich nach: | Prüfung | wie | |---|---| | `datenbank` | eine echte Abfrage gegen eine echte Tabelle | | `platte` | schreiben **und wieder löschen** — der einzige Beweis | | `ausliefern` | eine zufällige Datei aus der Datenbank auf der Platte nachmessen | | `bestand` | kein Eintrag, aber Dateien da? Dann stimmt etwas nicht | | `platz` | ab 95 % belegt | | `sicherung` | älter als 26 Stunden oder fehlgeschlagen | ```json { "dienst": "d4rk_media", "ok": true, "stand": "gesund", "seit": 4271, "pruefungen": [ { "was": "datenbank", "ok": true }, … ] } ``` **Zwei Stufen, und der Unterschied ist der zwischen „tut es nicht" und „braucht Aufmerksamkeit".** Datenbank, Platte, Ausliefern und Bestand sind das Geschäft dieses Dienstes — fällt eines aus, ist er `gestoert` und die Antwort ist **503**. Eine alte Sicherung oder eine volle Platte machen ihn `beeintraechtigt`: er liefert weiter aus, die Antwort bleibt **200**. Wer auch das rot haben will, hängt `?streng=1` an — dann gibt alles außer `gesund` eine 503. ### In Uptime Kuma ``` Dienst läuft https://media.d4rkst3r.de/status Braucht Pflege https://media.d4rkst3r.de/status?streng=1 ``` > **Warum `bestand` dabei ist.** Nachgemessen an genau diesem Dienst: wird eine > Sicherung zurückgespielt, ohne `media.db-wal` daneben zu entfernen, spielt > SQLite das WAL der **alten** Datenbank über die neue. Ergebnis: 0 Einträge, > während 4452 Dateien danebenliegen. Der Dienst kommt hoch, `/health` ist > grün, die Mediathek ist leer. > > ``` > /health sagt: HTTP 200 > /status sagt: HTTP 503 kein Eintrag in der Datenbank, aber Dateien auf der Platte > ``` Was **nicht** hinausgeht: Dateizahlen, Größen, Pfade, Tokennamen, Benutzer. Die einzige Zahl ist die Laufzeit in Sekunden. --- ## Anmeldung: Token Alle Skript-Wege brauchen einen Token im Kopf: ``` Authorization: Bearer d4rk_… ``` Tokens werden im Dashboard angelegt. Der Klartext wird **genau einmal** gezeigt — danach steht nur noch sein Hash in der Datenbank, und auch der Dienst kann ihn nicht mehr herausgeben. Ein Token kann zwei Fesseln haben: | Fessel | Wirkung | |---|---| | Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` | | darf löschen | ohne dieses Recht antwortet `DELETE` mit 403 | --- ## POST /api/upload Nimmt die Datei in einer von **drei Rumpfformen** entgegen: 1. `multipart/form-data` mit dem Feld `file` 2. Base64-Text, wenn `X-Encoding: base64` gesetzt ist 3. rohe Bytes, sonst ### Kopfzeilen | Kopf | Bedeutung | |---|---| | `X-Path` | Wohin die Datei kommt. **Ohne ihn** wird aus dem SHA-256 ein Pfad gebaut — dann ist die Adresse nicht mehr vorhersagbar. | | `X-Encoding` | `base64`, wenn der Rumpf Base64-Text ist. Ein Data-URL-Vorspann (`data:image/webp;base64,`) wird abgeschnitten. | | `X-Overwrite` | `false` verweigert das Überschreiben und antwortet mit 409. Standard ist überschreiben. | | `X-Mime` | Überschreibt die Art. Sonst entscheidet die Endung. | ### Antwort ```json { "url": "https://media.d4rkst3r.de/f/vehicles/adder.webp", "path": "vehicles/adder.webp", "size": 132760, "sha256": "36cb4ddf…", "mime": "image/webp", "replaced": false } ``` ### Was schiefgehen kann | Antwort | Grund | |---|---| | 401 | Token fehlt oder ist unbekannt | | 403 | Der Token darf dort nicht schreiben (Präfix-Fessel) | | 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 | | 409 | `X-Overwrite: false` und der Pfad ist belegt | | 413 | größer als `MAX_UPLOAD_MB` (Standard 64) | Jede Fehlerantwort ist JSON mit einem Feld `error` und einem **Satz auf Deutsch**. Ein nackter 500 ohne Text ist genau der Fehler, wegen dem dieser Dienst gebaut wurde. --- ## Aus einer FiveM-Resource ```lua local TOKEN = 'd4rk_…' -- im Dashboard anlegen local function hochladen(pfad, bytes, cb) PerformHttpRequest('https://media.d4rkst3r.de/api/upload', function(status, body) if status ~= 200 then return cb(nil, body) end cb(json.decode(body).url) end, 'POST', B64.encode(bytes), { ['Authorization'] = 'Bearer ' .. TOKEN, ['X-Path'] = pfad, ['X-Encoding'] = 'base64', ['Content-Type'] = 'text/plain; charset=utf-8', }) end ``` **Warum Base64 und nicht die rohen Bytes.** `PerformHttpRequest` nimmt den Rumpf als Lua-Zeichenkette, und ob Nullbytes darin unverändert ankommen, hat hier niemand nachgemessen — ein PNG besteht zu gutem Teil daraus. Base64 kostet ein Drittel mehr auf der Leitung und nimmt die Frage aus dem Weg. Nachgemessen ist: Base64 kommt mit demselben SHA-256 an wie die rohen Bytes. **Warum vom Server und nicht aus der NUI.** Die NUI läuft unter `https`, und CEF hebt jede `http`-Adresse selbst dorthin an. Ein Upload aus dem Fenster heraus wäre entweder Mixed Content oder ginge ins Leere. --- ## GET /api/exists?pfad=<pfad> Für „nur fehlende" in einem Serienlauf: eine winzige Antwort gegen 130 KB Upload. > **Diese Form und nicht mehr `/api/exists/`** — der Grund ist > nachgemessen und unangenehm. Die alte Form endet auf `.webp`, und Nginx > Proxy Managers `assets.conf` greift auf **jede** URL mit Bildendung: > > ```nginx > location ~* ^.*\.(css|js|jpe?g|gif|png|webp|...)$ { > proxy_cache public-cache; > proxy_ignore_headers Set-Cookie Cache-Control Expires ...; > proxy_cache_valid any 30m; > } > ``` > > Der Proxy legt die berechtigte Antwort weg und liefert sie danach **an jeden > aus, auch ohne Token** — gemessen: 200 mit voller Auskunft, `Authorization` > gar nicht gesetzt. Unser eigenes `Cache-Control` hilft nicht, > `proxy_ignore_headers` wirft es weg. Und es geht auch andersherum: landet > zuerst ein `401` im Zwischenspeicher, bekommen ihn 30 Minuten lang alle. > > Die Abfrageform endet nie auf eine Bildendung und ist deshalb **von sich aus** > dicht — ohne von einer Zeile in einer fremden Oberfläche abzuhängen. Die alte > Form bleibt, weil Skripte sie benutzen; neu gebaut wird mit dieser. ```json { "exists": true, "path": "vehicles/adder.webp", "url": "…", "size": 132760, "sha256": "…", "updated_at": 1786466462488 } ``` > **Drei Zustände, nicht zwei.** Wer einen Netzfehler als „nicht da" durchgehen > lässt, lädt bei einem Husten alles ein zweites Mal hoch. Ein Aufrufer sollte > zwischen `exists: false` und „ich weiß es nicht" unterscheiden. --- ## DELETE /api/media/<pfad> Braucht einen Token mit dem Recht **darf löschen**. ```json { "deleted": "vehicles/adder.webp" } ``` Löschen ist endgültig: die Datei ist von der Platte weg, und es gibt keinen Papierkorb. Die tägliche Sicherung hilft nur bis zum letzten Lauf. --- ## Grenzen | | | |---|---| | Größte Datei | 64 MB (`MAX_UPLOAD_MB`) | | Pfadlänge | 200 Zeichen, höchstens 8 Ebenen | | Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) | | Sitzung | 30 Tage | --- ## Der Zwischenspeicher des Proxys Vor dem Dienst steht Nginx Proxy Manager. Dessen `assets.conf` greift über eine Regel auf **jede URL, die auf `.webp`, `.png`, `.js`, `.css` … endet** — und legt die Antwort für 30 Minuten weg, dabei ignoriert sie unsere eigenen Kopfzeilen. Das hatte zwei sichtbare Folgen, beide nachgemessen und beide behoben: - Ein **überschriebenes Bild** blieb bis zu 30 Minuten alt, obwohl Upload und Datenbank „fertig" sagten. - `/api/exists/…webp` wurde mit Antwort zwischengespeichert und danach **auch ohne Token** ausgeliefert. Behoben durch drei Blöcke in der *Custom Nginx Configuration* des Proxy-Hosts: ```nginx location ^~ /api/ { proxy_cache off; include conf.d/include/proxy.conf; } location ^~ /f/ { proxy_cache off; include conf.d/include/proxy.conf; } location ^~ /t/ { proxy_cache off; include conf.d/include/proxy.conf; } ``` Das `^~` ist der ganze Trick: eine gewöhnliche Präfix-Regel **verliert** gegen die Regex aus `assets.conf`. In einem Wegwerf-Nginx nachgemessen. --- ## Export Im Dashboard unter **API** — oder direkt, mit gültiger Sitzung: ``` /api/dash/export?format=json alles, was der Dienst weiß /api/dash/export?format=csv Semikolon und CRLF, für Excel /api/dash/export?format=lua fertige Tabelle Name → Adresse /api/dash/export?format=lua&folder=vehicles ``` Die Lua-Fassung liest man in einer Resource als `Urls.adder` aus. --- *Diese Seite wird aus `docs/` im Repo erzeugt — Änderungen hier gehen beim nächsten Lauf verloren.*