# 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. Die Prüfung `sicherung` sagt zusätzlich, an **wie vielen Orten** das letzte Archiv liegt (`an 3 Orten`, oder `nur an einem Ort`). Eine Sicherung, die es nur einmal gibt, ist grün und trotzdem eine, die ein Plattenausfall mitnimmt. ### 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 **sechs** Fesseln haben — alle im Dashboard einstellbar, auch nachträglich (`PATCH`, ohne dass sich der Schlüssel ändert): | Fessel | Wirkung | |---|---| | Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` | | darf löschen | ohne dieses Recht antwortet `DELETE` mit 403 | | größte Datei | schärfer als `MAX_UPLOAD_MB`; darüber 413 | | Kontingent | wieviel dieser Token **insgesamt** halten darf; darüber 413 | | erlaubte Arten | Bilder, Video, Ton, Dokumente, Anderes; sonst 403 | | gültig bis | danach 401 mit dem Datum im Satz | > **Das Kontingent ist etwas anderes als die Größe einer Datei.** Ein Token mit > „höchstens 2 MB je Bild" konnte die Platte trotzdem füllen — es brauchte nur > genug Bilder. Gezählt wird über das, was wirklich liegt, nicht über einen > mitlaufenden Zähler: > > ``` > Kontingent erschoepft: dieser Token haelt 12 KB von 20 KB, > und diese Datei braucht 12 KB. > ``` ### ⚠️ Bilder können kleiner zurückkommen, als sie hingingen Ein Token kann eine **größte Kantenlänge** tragen. Ist sie gesetzt und das Bild größer, rechnet der Dienst es herunter, **bevor** er es ablegt: ``` 3000 × 2000 hingeschickt → 512 × 341 abgelegt, 82 KB → 3 KB ``` Für ein Skript heißt das zweierlei: 1. **`size` in der Antwort ist die Größe der abgelegten Datei**, nicht die der geschickten. 2. **`sha256` ebenso.** Wer den Hash vor dem Upload selbst rechnet und mit der Antwort vergleicht, bekommt bei gesetzter Kantenlänge einen Unterschied — und der ist kein Fehler. Der Hash gehört zu dem, was liegt; alles andere wäre gelogen. Kleinere Bilder werden **nicht** aufgeblasen, und Video, Ton und Dokumente bleiben unberührt. Ohne gesetzte Kantenlänge passiert gar nichts — der Standard ist „ablegen, wie es kommt". --- ## 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 } ``` Trägt der Token eine Kantenlänge und wurde wirklich gerechnet, kommt ein Feld dazu — sonst steht es gar nicht erst da, denn ein Feld, das immer dasteht, sagt nichts: ```json "verkleinert": { "von": "3000x2000", "auf": "512x341" } ``` ### 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), **oder** größer als die Grenze dieses Tokens, **oder** sein Kontingent ist erschöpft — welches, steht im Satz | 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. --- ## Die Wege des Dashboards Diese brauchen eine **Sitzung** und keinen Token — sie sind für die Oberfläche gedacht. Hier stehen sie, weil man sie auch mit `curl` und einem Cookie benutzen kann, wenn man etwas einmalig braucht. | Weg | Wozu | |---|---| | `POST /api/dash/upload-url` | Der Dienst holt die Datei selbst von einer Adresse. Siehe unten. | | `POST /api/dash/fehlende` | Muster + Namensliste → was fehlt, was überzählig ist | | `POST /api/dash/media/ersetzen` | Suchen und Ersetzen über Pfade. **Ohne `ausfuehren: true` wird nur gerechnet und gezeigt.** | | `POST /api/dash/media/umbenennen` | Eine Datei; die Antwort nennt ihre Abrufzahl | | `POST /api/dash/folders/umbenennen` | Ein Ordner mit allem darin, in einer Transaktion | | `PATCH /api/dash/tokens/:id` | Grenzen ändern. Weggelassene Felder bleiben, `null` löscht eine Grenze. | | `GET /api/dash/verwaltung` | Wer hat wann was eingestellt | | `GET /api/dash/verlauf/export` | Verlauf als CSV; `?was=verwaltung` für den anderen. Semikolon + CRLF, höchstens 20 000 Zeilen. | | `GET /api/dash/einstellungen/export` | Alle Einstellungen als JSON. **Ohne Geheimnisse**, es sei denn `?geheim=1`. | | `POST /api/dash/einstellungen/import` | Zurück. Leere Werte überschreiben nichts. | | `GET/POST/DELETE /api/dash/freigaben` | Nur-Lesen-Links auf einen Ordner | | `POST /api/dash/maintenance/thumbs` \| `/webp` | Abgeleitetes nachziehen, **in Runden zu 300** | ### POST /api/dash/upload-url ```json { "url": "https://…/bild.png", "ordner": "items", "name": "optional.png" } ``` **Das ist die vorsichtigste Stelle im ganzen Dienst.** Ein Server, der eine vom Benutzer genannte Adresse abruft, ist ein Angriff mit eigenem Namen — deshalb wird geprüft, und zwar die **aufgelöste IP** und nicht der Name: | abgelehnt | Beispiel | |---|---| | alles außer http/https | `file://`, `gopher://` | | andere Anschlüsse als 80/443 | `http://host:9101/` | | Rückschleife und Heimnetz | `127.0.0.1`, `::1`, `10.x`, `172.16–31.x`, `192.168.x`, `169.254.x`, `0.0.0.0` | | öffentliche Namen auf private Adressen | `127.0.0.1.nip.io` | Der letzte Punkt ist der eigentliche: ein auflösbarer Name, der auf eine private Adresse zeigt, ist der Standardweg um jeden Namensfilter herum. **Und jede Umleitung wird einzeln neu geprüft** — `fetch` folgen zu lassen wäre genau dort die Lücke. --- ## Grenzen | | | |---|---| | Größte Datei | 64 MB (`MAX_UPLOAD_MB`), je Token weiter einschränkbar | | Kontingent je Token | einstellbar, ohne Grenze wenn leer | | Pfadlänge | 200 Zeichen, höchstens 8 Ebenen | | Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) | | Sitzung | 30 Tage | | Papierkorb | 30 Tage **und** höchstens 20 GB (`PAPIERKORB_MAX_MB`) — was zuerst greift, greift | Der Papierkorb hat zwei Grenzen, weil eine nicht reicht: wer 200 GB löscht, hält sie dreißig Tage lang doppelt. Beim Ausleeren fliegt erst, was zu alt ist, dann das **Älteste**, bis der Deckel wieder passt. Die Zahl steht in der Antwort von `GET /api/dash/papierkorb` als `maxBytes`. --- ## Meldungen nach Discord Vier Anlässe, alle einzeln abschaltbar: `sicherung`, `verwaiste`, `platte`, `upload`. **`upload` meldet nicht sofort.** Jeder Upload schiebt eine Frist von zwei Minuten nach hinten; erst wenn es ruhig bleibt, geht **eine** Nachricht raus — mit Anzahl, Summe, Absendern und den ersten fünf Pfaden. Das ist keine Bequemlichkeit, sondern der Grund, warum die Funktion überhaupt brauchbar ist: ein Serienlauf des Fotostudios legt 900 Bilder ab. 900 Nachrichten wären keine Meldung mehr, sondern ein Ausfall des Kanals — Discord drosselt Webhooks, und wer danach eine *echte* Meldung bekommt, sieht sie nicht mehr. --- ## 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.*