Zum Inhalt springen
Blog

FiveM Mugshots und Screenshots als CDN-Link statt base64

Lade base64-Bilder per PerformHttpRequest aus Server-Lua hoch, speichere nur den Link in MySQL und gib jedem Charakter eine feste Mugshot-URL.

Von Termi

8 Min. Lesezeit
  • Lua
  • Uploads
  • MySQL
  • MDT
Auf dieser Seite

MDTs, Ausweise und Beweisschränke brauchen Fotos von Spielern. Die erste Version schießt das Bild meist als base64-Data-URI und schreibt den String direkt in MySQL. Am ersten Tag klappt das, am neunzigsten tut es weh: base64 ist ein Drittel größer als das Bild selbst, jede Query, die die Spalte selektiert, schleppt es mit, und deine Datenbank-Dumps wachsen mit jeder Festnahme.

Die Lösung: das Bild einmal hochladen und nur den Link speichern. Diese Anleitung macht das aus Server-Lua mit PerformHttpRequest und dem base64-Endpoint von FiveDock, der denselben Request annimmt wie der von Fivemanage.

  • Ein Link ist deutlich kürzer als hundert Zeichen, die Zeilen bleiben also klein. Das Bild liegt auf einem CDN, das genau dafür gebaut ist.
  • Die UI ist schneller: Dein MDT schickt der NUI eine URL statt eines riesigen Strings, und der Browser lädt und cached das Bild selbst.
  • Eine URL funktioniert im Discord-Log, auf deiner Website und in einer zweiten Resource. Ein base64-Blob funktioniert nur dort, wo du ihn einfügst.

API-Token holen und auf dem Server lassen

Uploads brauchen einen API-Token. Erstell ihn im Panel auf der Seite Tokens mit dem Scope files:write; er beginnt mit fd_live_. Das ist nicht der Agent-Key (fda_live_…), den die Resource fivedock benutzt, und die Upload-API akzeptiert keine Agent-Keys.

Trag den Token mit set in die server.cfg ein. Nimm niemals setr, das den Wert an jeden Client repliziert, und auch nicht sets, das ihn in den Serverinfos veröffentlicht:

server.cfg
# the API token, server-only: set, never setr or sets
set fivedock_api_token "fd_live_…"

Wenn ein Client selbst hochladen muss, schau unten bei Upload direkt aus der NUI.

Upload aus Server-Lua

POST https://api.fivedock.dev/api/v3/file/base64 nimmt JSON mit dem Bild als Data-URI, einem Dateinamen und optional einem Key. Der Link kommt in data.url zurück:

request
{
  "base64": "data:image/webp;base64,UklGR…",
  "filename": "char1_abc123.webp",
  "key": "mugshots/char1_abc123.webp"
}
response (trimmed)
{
  "data": {
    "url": "https://cdn.fivedock.dev/o/my-city/mugshots/char1_abc123.webp"
  }
}

Hier ein vollständiges Server-Script. Es behält die Form einer typischen Fivemanage-Integration und ergänzt den Key:

server/mugshots.lua
local API_URL = 'https://api.fivedock.dev/api/v3/file/base64'
local API_TOKEN = GetConvar('fivedock_api_token', '')

if API_TOKEN == '' then
    print('^1fivedock_api_token is not set, mugshot uploads will fail^0')
end

-- "char1:abc123" becomes "char1_abc123": keep letters, digits, _ and -
local function cleanId(value)
    return (tostring(value):gsub('[^%w_%-]', '_'))
end

local function saveMugshot(identifier, mugshotImage)
    local cleanIdentifier = cleanId(identifier)

    PerformHttpRequest(API_URL, function(statusCode, text, headers, errorData)
        if statusCode == 200 or statusCode == 201 then
            local data = json.decode(text)
            if data and data.data and data.data.url then
                MySQL.insert.await('INSERT INTO `mdt_mugshots` (`identifier`, `image`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `image` = VALUES(`image`)', { identifier, data.data.url })
            end
        else
            print(('Error uploading mugshot (%s): %s'):format(statusCode, tostring(errorData or text)))
        end
    end, 'POST', json.encode({
        base64 = mugshotImage,
        filename = cleanIdentifier .. '.webp',
        key = 'mugshots/' .. cleanIdentifier .. '.webp',
    }), {
        ['Content-Type'] = 'application/json',
        ['Authorization'] = API_TOKEN,
    })
end

exports('saveMugshot', saveMugshot)

Der Token kommt unverändert in den Authorization-Header; Bearer fd_live_… funktioniert auch. Nach einem erfolgreichen Upload landet nur der Link in MySQL.

Ein stabiler Link pro Charakter

Der key hält den Link stabil. Ohne ihn bekommt jeder Upload einen neuen zufälligen Link, und das alte Bild belegt weiter Platz. Mit mugshots/<identifier>.webp gilt:

  • Der Link lautet https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp und ändert sich nie;
  • Ein neues Mugshot wird auf denselben Key hochgeladen und ersetzt das Bild hinter demselben Link; das vorherige bleibt 7 Tage erhalten und wird dann gelöscht;
  • Jede Resource, die den Identifier kennt, kann den Link bauen, ohne die Datenbank zu fragen.

Keyed Links werden fünf Minuten gecached, ein neues Mugshot erscheint also nach etwa fünf Minuten überall.

Regeln für Keys

Keys dürfen Buchstaben, Ziffern und . _ - ~ @ + : ( ) , = ! enthalten, mit / zwischen Ordnern. Ein expliziter Key, der die Regeln verletzt, wird mit 400 abgelehnt, deshalb säubert das Script den Identifier vorher. Dateitypen werden an den Bytes erkannt, nicht am Namen, aber halte die Endung trotzdem korrekt: Liefert deine Aufnahme JPEG, nimm .jpg im Key und im Dateinamen.

Die Tabelle

ON DUPLICATE KEY UPDATE funktioniert nur, wenn identifier ein Primary oder Unique Key ist. Und weil die Zeile jetzt einen Link enthält, reicht ein VARCHAR völlig:

SQL
CREATE TABLE IF NOT EXISTS `mdt_mugshots` (
  `identifier` VARCHAR(64) NOT NULL,
  `image` VARCHAR(512) NOT NULL,
  PRIMARY KEY (`identifier`)
);

Das Script nutzt oxmysql, die Resource lädt deshalb dessen Library vor dem Script:

fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
lua54 'yes'

server_scripts {
    '@oxmysql/lib/MySQL.lua',
    'server/mugshots.lua',
}

Das Bild von einem Client empfangen

Mugshots entstehen meist auf dem Client (oft ein Ped-Headshot, der in der NUI auf ein Canvas gezeichnet wird) und gehen dann an den Server. Behandle alles, was ankommt, als nicht vertrauenswürdig: Entscheide auf dem Server, wer Mugshots machen darf, nimm den Identifier vom serverseitigen Spieler und wirf alles weg, was kein halbwegs kleines Bild ist:

server/mugshots.lua
-- about 1.5 MB of image once decoded; a headshot is a few dozen KB
local MAX_IMAGE_LENGTH = 2 * 1024 * 1024

RegisterNetEvent('mdt:saveMugshot', function(targetId, mugshotImage)
    local src = source

    -- yours to write: e.g. is the sender on duty as police?
    if not canTakeMugshots(src) then return end

    if type(mugshotImage) ~= 'string'
        or #mugshotImage > MAX_IMAGE_LENGTH
        or not mugshotImage:find('^data:image/') then
        return
    end

    -- yours to write: ESX xPlayer.identifier, QBCore Player.PlayerData.citizenid
    local identifier = getCharacterId(targetId)
    if identifier then
        saveMugshot(identifier, mugshotImage)
    end
end)

canTakeMugshots und getCharacterId hängen von deinem Framework ab, die musst du selbst schreiben. Auch einen großen String durch ein Net-Event zu schieben ist nicht gratis; sind deine Bilder größer als ein paar hundert Kilobyte, lade sie wie unten gezeigt aus der NUI hoch.

Screenshots funktionieren genauso

Mit screenshot-basic kann der Server einen Client um einen Screenshot bitten und bekommt eine Data-URI zurück, genau das, was der base64-Endpoint annimmt. Hier als Beweisfotos, ein Key pro Fall und Zeitstempel:

server/evidence.lua
local function saveEvidence(playerId, caseId)
    exports['screenshot-basic']:requestClientScreenshot(playerId, {
        encoding = 'webp',
        quality = 0.85,
    }, function(err, data)
        if err then
            print(('Screenshot failed: %s'):format(err))
            return
        end

        local key = ('evidence/%s/%d.webp'):format(cleanId(caseId), os.time())

        PerformHttpRequest(API_URL, function(statusCode, text, headers, errorData)
            if statusCode ~= 200 and statusCode ~= 201 then
                print(('Error uploading evidence (%s): %s'):format(statusCode, tostring(errorData or text)))
                return
            end

            local body = json.decode(text)
            local url = body and body.data and body.data.url
            if url then
                MySQL.insert.await('INSERT INTO `mdt_evidence` (`case_id`, `image`) VALUES (?, ?)', { caseId, url })
            end
        end, 'POST', json.encode({ base64 = data, filename = 'evidence.webp', key = key }), {
            ['Content-Type'] = 'application/json',
            ['Authorization'] = API_TOKEN,
        })
    end)
end

Wenn du den FiveDock-Agent laufen hast, macht exports.fivedock:screenshot Screenshot und Upload in einem Aufruf, ohne Token in deinem Script (screenshot-basic muss dafür ebenfalls gestartet sein). Mehr in den Agent-Docs:

Lua
exports.fivedock:screenshot(playerId, { folder = '/evidence', encoding = 'webp', quality = 0.85 }, function(err, file)
    if err then return print(err) end
    print(file.url)
end)

Von Fivemanage umziehen

Wenn dein MDT schon zu Fivemanage hochlädt, ist die Änderung klein:

  • ersetze https://api.fivemanage.com/api/v3/file/base64 durch https://api.fivedock.dev/api/v3/file/base64,
  • ersetze den Fivemanage-API-Key durch deinen FiveDock-Token,
  • ergänze key, wenn du einen stabilen Link pro Charakter willst statt einer neuen Datei pro Upload.
Lua
-- was: "https://api.fivemanage.com/api/v3/file/base64"
local url = "https://api.fivedock.dev/api/v3/file/base64"
-- was: your Fivemanage API key
local apiKey = GetConvar('fivedock_api_token', '')

Request und data.url in der Antwort sind gleich, der Rest deines Codes bleibt also, wie er ist. Für Links, die schon in deiner Datenbank stehen, kopiert die Migrationsanleitung deine Fivemanage-Dateien zu FiveDock und gibt dir eine Liste alter Link → neuer Link, mit der du die Zeilen aktualisierst.

Upload direkt aus der NUI

Manchmal entsteht das Bild im Browser: eine Kamera-App in einer Phone-Resource oder ein Canvas-Capture. Gib der NUI-Seite nicht deinen Token, denn jeder Spieler kann alles lesen, was eine NUI-Seite enthält. Lass den Server einen Presigned-Link erzeugen und gib nur den an den Client. Der Link braucht keinen Token, kann auf einen Upload, einen Dateityp und einen Key begrenzt werden und läuft von selbst ab:

server/mugshots.lua
local PRESIGN_URL = 'https://api.fivedock.dev/api/v3/file/presigned-url'

RegisterNetEvent('mdt:requestMugshotLink', function(targetId)
    local src = source
    if not canTakeMugshots(src) then return end

    local identifier = getCharacterId(targetId)
    if not identifier then return end

    local query = ('?singleUse=true&fileType=image&key=mugshots/%s.webp'):format(cleanId(identifier))

    PerformHttpRequest(PRESIGN_URL .. query, function(statusCode, text)
        if statusCode ~= 200 then return end
        local body = json.decode(text)
        TriggerClientEvent('mdt:mugshotLink', src, body.data.presignedUrl)
    end, 'GET', '', { ['Authorization'] = API_TOKEN })
end)
html/app.js
// NUI: upload a canvas with the one-time link the server sent
async function uploadMugshot(presignedUrl, canvas) {
  const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', 0.85));
  const body = new FormData();
  body.append('file', blob, 'mugshot.webp');

  const response = await fetch(presignedUrl, { method: 'POST', body });
  if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
  return (await response.json()).data.url;
}

Weil der Server den Key gewählt hat, kennt er den fertigen Link schon und kann ihn speichern, ohne dem zu vertrauen, was der Client zurückmeldet. Ablauf, Größenlimits und die übrigen Optionen stehen in der API-Referenz.

Fehler, die du behandeln solltest

  • 401: Der Token fehlt oder ist falsch.
  • 403: Dem Token fehlt der Scope files:write.
  • 400: Der Request ist fehlerhaft, zum Beispiel ein Key, der die Key-Regeln verletzt.
  • 413: Das Bild ist zu groß. base64-Uploads nehmen Bilder bis 16 MiB.
  • 415: Kein akzeptierter Typ. PNG, JPEG, WebP, AVIF und GIF sind erlaubt.
  • 429: Die Organisation liegt über ihrem Speicherlimit. Warte, beachte Retry-After, falls vorhanden, und versuch es später erneut.

Wiederhole bei Fehlern nicht in einer engen Schleife. Ein fehlgeschlagenes Mugshot kann auf das nächste Mal warten, wenn ein Officer eins macht; ein Script, das ewig wiederholt, kann das nicht.

Häufige Fragen

Sollte ich base64-Bilder in MySQL speichern?

Nein. Ein base64-String ist etwa ein Drittel größer als das Bild, das er kodiert. Jede Query, die die Spalte selektiert, trägt ihn mit, und der Datenbank-Dump wächst mit jedem Bild. Lade das Bild einmal auf ein CDN hoch und speichere nur den Link, der in ein VARCHAR(512) passt.

Wie lade ich einen screenshot-basic-Screenshot aus Server-Lua hoch?

Ruf auf dem Server requestClientScreenshot von screenshot-basic ohne die Option fileName auf, dann bekommt der Callback den Screenshot als Data-URI. Schick ihn als JSON (base64, filename und ein optionaler key) per POST https://api.fivedock.dev/api/v3/file/base64 mit deinem API-Token im Authorization-Header. Der CDN-Link kommt in data.url zurück, und diesen Link speicherst du.

Darf ich meinen Upload-API-Key in ein Client-Script oder eine NUI-Seite schreiben?

Nein. Jeder Spieler kann Client-Scripts und alles lesen, was eine NUI-Seite enthält, also kann jeder, der den Key findet, in deine Organisation hochladen. Lass den Token mit set in der server.cfg und lade aus Server-Lua hoch. Entsteht das Bild in der NUI, lass den Server einen Presigned-Link für einen Upload erzeugen und gib nur diesen Link an den Client.

Schick beim Upload einen key mit, etwa mugshots/<identifier>.webp. Die Datei wird dann unter https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp ausgeliefert, und ein neues Mugshot auf demselben Key ersetzt das Bild hinter diesem Link. Keyed Links werden fünf Minuten gecached, das neue Bild erscheint also nach etwa fünf Minuten überall.

Funktioniert ein Fivemanage-base64-Upload-Script mit FiveDock?

Ja. FiveDock beantwortet denselben POST /api/v3/file/base64-Request auf https://api.fivedock.dev und liefert den Link im selben Feld data.url. Tausch die URL aus und ersetze den Fivemanage-Key durch einen FiveDock-API-Token (fd_live_…) mit dem Scope files:write; der Rest des Scripts bleibt, wie er ist.

Alle Artikel