Przejdź do treści
Blog

Mugshoty i screenshoty z FiveM jako linki z CDN, nie base64

Wgrywaj obrazki base64 z Lua po stronie serwera przez PerformHttpRequest, trzymaj w MySQL tylko link i daj każdej postaci jeden stały URL do mugshota.

Autor: Termi

7 min czytania
  • Lua
  • Uploads
  • MySQL
  • MDT
Na tej stronie

MDT, dowody osobiste i szafki z dowodami potrzebują zdjęć graczy. Pierwsza wersja zwykle łapie zdjęcie jako data URI w base64 i wpisuje ten string prosto do MySQL. Pierwszego dnia działa, dziewięćdziesiątego boli: base64 jest o jedną trzecią większe od samego obrazka, każde zapytanie, które wybiera tę kolumnę, ciągnie go ze sobą, a zrzuty bazy rosną z każdym aresztowaniem.

Rozwiązanie: wgraj obrazek raz i trzymaj tylko link. W tym poradniku robimy to z Lua po stronie serwera, przez PerformHttpRequest i endpoint base64 FiveDock, który przyjmuje taki sam request jak Fivemanage.

  • Małe wiersze. Link ma grubo poniżej stu znaków. Obrazek leży na CDN, który jest od tego, żeby serwować obrazki.
  • Szybsze UI. MDT wysyła do NUI URL zamiast wielkiego stringa, a przeglądarka sama ładuje i cache’uje obrazek.
  • Link działa wszędzie. URL zadziała w logu na Discordzie, na stronie serwera i w drugim resource. Blob base64 działa tylko tam, gdzie go wkleisz.

Token API tylko na serwerze

Upload wymaga tokenu API. Utwórz go w panelu na stronie Tokeny ze scope’em files:write; zaczyna się od fd_live_. To nie jest klucz agenta (fda_live_…), którego używa resource fivedock, i API do wgrywania nie przyjmuje kluczy agenta.

Wpisz token do server.cfg przez set. Nigdy przez setr, które replikuje wartość do każdego klienta, ani sets, które publikuje ją w informacjach o serwerze:

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

Token żyje wyłącznie na serwerze. Jeśli to klient ma coś wgrać, zobacz niżej upload prosto z NUI.

Upload z Lua po stronie serwera

POST https://api.fivedock.dev/api/v3/file/base64 przyjmuje JSON z obrazkiem jako data URI, nazwą pliku i opcjonalnie kluczem. Link wraca w data.url:

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"
  }
}

Oto kompletny skrypt serwerowy. Ma kształt typowej integracji z Fivemanage i dokłada klucz:

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)

Token idzie w nagłówku Authorization tak jak jest; Bearer fd_live_… też zadziała. Po udanym uploadzie w MySQL ląduje tylko link.

Jeden stały link na postać

To key robi tu całą robotę. Bez niego każdy upload dostaje nowy, losowy link, a stare zdjęcie dalej zajmuje miejsce. Z mugshots/<identifier>.webp:

  • link to https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp i nigdy się nie zmienia;
  • nowy mugshot trafia pod ten sam klucz i podmienia zdjęcie pod tym samym linkiem; poprzednie jest trzymane jeszcze 7 dni, potem znika;
  • każdy resource, który zna identyfikator, zbuduje link bez pytania bazy.

Linki z kluczem są cache’owane przez pięć minut, więc nowy mugshot dociera na wszystkie ekrany w ciągu mniej więcej pięciu minut.

Zasady dla kluczy

Klucz może zawierać litery, cyfry i . _ - ~ @ + : ( ) , = !, z / między folderami. Jawny klucz łamiący te zasady dostaje 400, dlatego skrypt najpierw czyści identyfikator. Typ pliku jest rozpoznawany po bajtach, nie po nazwie, ale i tak trzymaj uczciwe rozszerzenie: jeśli twoje przechwytywanie daje JPEG, użyj .jpg w kluczu i nazwie pliku.

Tabela

ON DUPLICATE KEY UPDATE działa tylko wtedy, gdy identifier jest kluczem głównym albo unikalnym. A skoro w wierszu siedzi teraz link, VARCHAR w zupełności wystarczy:

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

Skrypt używa oxmysql, więc resource ładuje jego bibliotekę przed skryptem:

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

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

Odbieranie obrazka od klienta

Mugshoty zwykle powstają po stronie klienta (często jako headshot peda narysowany na canvasie w NUI) i są wysyłane na serwer. To, co przychodzi, traktuj jak niezaufane: na serwerze decyduj, kto może robić mugshoty, identyfikator bierz z gracza po stronie serwera i odrzucaj wszystko, co nie jest rozsądnie małym obrazkiem:

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 i getCharacterId zależą od frameworka, więc piszesz je sam. Pchanie dużego stringa przez net event też nie jest za darmo; jeśli twoje obrazki mają więcej niż kilkaset kilobajtów, wgrywaj je z NUI, jak niżej.

Screenshoty działają tak samo

Z screenshot-basic serwer może poprosić klienta o screenshot i dostaje z powrotem data URI, czyli dokładnie to, co przyjmuje endpoint base64. Tu jako zdjęcia dowodowe, z kluczem na sprawę i znacznik czasu:

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

Jeśli masz agenta FiveDock, exports.fivedock:screenshot robi screenshot i wgrywa go jednym wywołaniem, bez tokenu w twoim skrypcie (też wymaga uruchomionego screenshot-basic). Więcej w dokumentacji agenta:

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)

Przejście z Fivemanage

Jeśli twój MDT już wgrywa do Fivemanage, zmiana jest mała:

  • zamień https://api.fivemanage.com/api/v3/file/base64 na https://api.fivedock.dev/api/v3/file/base64,
  • zamień klucz API Fivemanage na token FiveDock,
  • dodaj key, jeśli chcesz mieć jeden stały link na postać zamiast nowego pliku przy każdym uploadzie.
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 i data.url w odpowiedzi są takie same, więc reszta kodu zostaje bez zmian. Dla linków, które już siedzą w bazie, poradnik migracji kopiuje twoje pliki z Fivemanage do FiveDock i daje listę stary link → nowy link, według której zaktualizujesz wiersze.

Upload prosto z NUI

Czasem obrazek powstaje w przeglądarce: aparat w telefonie albo zrzut z canvasa. Nie dawaj stronie NUI swojego tokenu, bo każdy gracz może odczytać wszystko, co zawiera strona NUI. Niech serwer wygeneruje link presigned i przekaże klientowi tylko jego. Link nie wymaga tokenu, można go ograniczyć do jednego uploadu, jednego typu pliku i jednego klucza, a wygasa sam:

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;
}

Klucz wybrał serwer, więc zna końcowy link i może go zapisać bez ufania temu, co zgłosi klient. Czas ważności, limity rozmiaru i pozostałe opcje znajdziesz w dokumentacji API.

Błędy, które warto obsłużyć

  • 401: brak tokenu albo zły token.
  • 403: token nie ma scope’a files:write.
  • 400: niepoprawny request, np. klucz łamiący zasady dla kluczy.
  • 413: obrazek jest za duży. Upload base64 przyjmuje do 16 MiB obrazka.
  • 415: nieobsługiwany typ. Obsługiwane są PNG, JPEG, WebP, AVIF i GIF.
  • 429: środowisko przekroczyło limit miejsca. Odpuść, uszanuj Retry-After, jeśli jest, i spróbuj później.

Nie ponawiaj w ciasnej pętli przy błędach. Nieudany mugshot może poczekać do następnego razu, gdy policjant zrobi zdjęcie; skrypt, który ponawia w nieskończoność, nie może.

Częste pytania

Czy trzymać obrazki base64 w MySQL?

Nie. String base64 jest o mniej więcej jedną trzecią większy od obrazka, który koduje. Każde zapytanie, które wybiera tę kolumnę, ciągnie go ze sobą, a zrzut bazy rośnie z każdym zdjęciem. Wgraj obrazek raz na CDN i trzymaj tylko link, który mieści się w VARCHAR(512).

Jak wgrać screenshot ze screenshot-basic z Lua po stronie serwera?

Wywołaj na serwerze requestClientScreenshot ze screenshot-basic bez opcji fileName, a callback dostanie screenshot jako data URI. Wyślij go jako JSON (base64, filename i opcjonalny key) na POST https://api.fivedock.dev/api/v3/file/base64 z tokenem API w nagłówku Authorization. Link z CDN wraca w data.url i to on ląduje w bazie.

Czy mogę wpisać klucz API do skryptu klienta albo strony NUI?

Nie. Każdy gracz może odczytać skrypty klienta i wszystko, co zawiera strona NUI, więc każdy, kto znajdzie klucz, może wgrywać pliki do twojego środowiska. Trzymaj token w server.cfg przez set i wgrywaj z Lua po stronie serwera. Jeśli obrazek powstaje w NUI, niech serwer wygeneruje link presigned na jeden upload i przekaże klientowi tylko ten link.

Wyślij z uploadem key, np. mugshots/<identifier>.webp. Plik jest wtedy dostępny pod https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp, a nowy mugshot wgrany pod ten sam klucz podmienia zdjęcie pod tym samym linkiem. Linki z kluczem są cache’owane przez pięć minut, więc nowe zdjęcie pojawi się wszędzie w ciągu mniej więcej pięciu minut.

Czy skrypt wgrywający base64 do Fivemanage zadziała z FiveDock?

Tak. FiveDock odpowiada na ten sam request POST /api/v3/file/base64 pod https://api.fivedock.dev i zwraca link w tym samym polu data.url. Zmień URL i zamień klucz Fivemanage na token API FiveDock (fd_live_…) ze scope’em files:write; reszta skryptu zostaje bez zmian.

Wszystkie artykuły