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
- Lua
- Uploads
- MySQL
- MDT
Na tej stronie
- Dlaczego nie trzymać base64 w bazie
- Token API tylko na serwerze
- Upload z Lua po stronie serwera
- Jeden stały link na postać
- Tabela
- Odbieranie obrazka od klienta
- Screenshoty działają tak samo
- Przejście z Fivemanage
- Upload prosto z NUI
- Błędy, które warto obsłużyć
- Częste pytania
- Czy trzymać obrazki base64 w MySQL?
- Jak wgrać screenshot ze screenshot-basic z Lua po stronie serwera?
- Czy mogę wpisać klucz API do skryptu klienta albo strony NUI?
- Jak dać każdej postaci jeden stały link do mugshota?
- Czy skrypt wgrywający base64 do Fivemanage zadziała z FiveDock?
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.
Dlaczego nie trzymać base64 w bazie
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:
# 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:
{
"base64": "data:image/webp;base64,UklGR…",
"filename": "char1_abc123.webp",
"key": "mugshots/char1_abc123.webp"
}{
"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:
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:
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:
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:
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:
-- 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:
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)
endJeś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:
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:
-- 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:
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)// 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ć
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.
Jak dać każdej postaci jeden stały link do mugshota?
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.