Mugshots e screenshots no FiveM como links de CDN, não base64
Envie imagens base64 do Lua do servidor com PerformHttpRequest, guarde só o link no MySQL e dê a cada personagem uma URL de mugshot estável.
Por Termi
- Lua
- Uploads
- MySQL
- MDT
Nesta página
- Por que não guardar base64 no banco
- Pegue um token de API e mantenha no servidor
- Upload a partir do Lua do servidor
- Um link estável por personagem
- A tabela
- Receber a imagem de um cliente
- Screenshots funcionam do mesmo jeito
- Saindo do Fivemanage
- Upload direto da NUI
- Erros que vale tratar
- Perguntas frequentes
- Devo guardar imagens base64 no MySQL?
- Como faço upload de um screenshot do screenshot-basic pelo Lua do servidor?
- Posso colocar minha API key de upload num script de cliente ou numa página NUI?
- Como dar a cada personagem um link de mugshot que nunca muda?
- Um script de upload base64 do Fivemanage funciona com a FiveDock?
MDTs, RGs e armários de evidências precisam de fotos dos jogadores. A primeira versão costuma capturar a imagem como data URI em base64 e gravar essa string direto no MySQL. No primeiro dia funciona, no nonagésimo dói: o base64 é um terço maior que a imagem, toda query que seleciona a coluna carrega isso junto, e os dumps do seu banco crescem a cada prisão.
A solução é enviar a imagem uma vez e guardar só o link dela. Este guia faz isso com Lua no servidor, usando PerformHttpRequest e o endpoint base64 da FiveDock, que aceita a mesma requisição do Fivemanage.
Por que não guardar base64 no banco
Pegue um token de API e mantenha no servidor
Os uploads precisam de um token de API. Crie um no painel, na página Tokens, com o scope files:write; ele começa com fd_live_. Não é a chave de agente (fda_live_…) que o resource fivedock usa, e a API de upload não aceita chaves de agente.
Coloque o token no server.cfg com set. Nunca com setr, que replica o valor para todos os clientes, nem com sets, que o publica nas informações do servidor:
# the API token, server-only: set, never setr or sets
set fivedock_api_token "fd_live_…"Se um cliente precisar fazer o upload sozinho, veja mais abaixo upload direto da NUI.
Upload a partir do Lua do servidor
POST https://api.fivedock.dev/api/v3/file/base64 recebe um JSON com a imagem como data URI, um nome de arquivo e, opcionalmente, uma key. O link volta em 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"
}
}Aqui está um script de servidor completo. Ele mantém o formato de uma integração típica com o Fivemanage e acrescenta a key:
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)O token vai do jeito que está no header Authorization; Bearer fd_live_… também funciona. Quando o upload dá certo, só o link chega ao MySQL.
Um link estável por personagem
A key é o que mantém o link estável. Sem ela, cada upload ganha um link aleatório novo e a imagem antiga continua ocupando espaço. Com mugshots/<identifier>.webp:
Links com key ficam em cache por cinco minutos, então um mugshot novo chega a todas as telas em cerca de cinco minutos.
Regras das keys
As keys podem ter letras, dígitos e . _ - ~ @ + : ( ) , = !, com / entre pastas. Uma key explícita que quebra as regras é recusada com 400, e é por isso que o script limpa o identificador antes. O tipo do arquivo é detectado pelos bytes, não pelo nome, mas mantenha a extensão certa mesmo assim: se a sua captura gera JPEG, use .jpg na key e no nome do arquivo.
A tabela
ON DUPLICATE KEY UPDATE só funciona quando identifier é chave primária ou única. E como a linha agora guarda um link, um VARCHAR é mais que suficiente:
CREATE TABLE IF NOT EXISTS `mdt_mugshots` (
`identifier` VARCHAR(64) NOT NULL,
`image` VARCHAR(512) NOT NULL,
PRIMARY KEY (`identifier`)
);O script usa oxmysql, então o resource carrega a biblioteca dele antes do script:
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/mugshots.lua',
}Receber a imagem de um cliente
Mugshots costumam ser capturados no cliente (muitas vezes um headshot de ped desenhado num canvas na NUI) e enviados ao servidor. Trate o que chega como não confiável: decida no servidor quem pode tirar mugshots, pegue o identificador do jogador no servidor e descarte tudo que não seja uma imagem razoavelmente pequena:
-- 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 e getCharacterId dependem do seu framework, então ficam por sua conta. Passar uma string grande por um net event também não é de graça; se as suas imagens passam de algumas centenas de kilobytes, faça o upload pela NUI, como mostrado abaixo.
Screenshots funcionam do mesmo jeito
Com o screenshot-basic, o servidor pode pedir um screenshot a um cliente e recebe uma data URI, que é exatamente o que o endpoint base64 aceita. Aqui como fotos de evidência, uma key por caso e timestamp:
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)
endSe você roda o agente da FiveDock, exports.fivedock:screenshot tira o screenshot e envia numa chamada só, sem token no seu script (o screenshot-basic também precisa estar iniciado). Veja a documentação do agente:
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)Saindo do Fivemanage
Se o seu MDT já envia para o Fivemanage, a mudança é pequena:
-- 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', '')A requisição e o data.url da resposta são os mesmos, então o resto do seu código fica como está. Para os links que já estão no seu banco, o guia de migração copia seus arquivos do Fivemanage para a FiveDock e entrega uma lista de link antigo → link novo para atualizar as linhas.
Upload direto da NUI
Às vezes a imagem nasce no navegador: um app de câmera num resource de celular, ou uma captura de canvas. Não entregue seu token à página da NUI, porque todo jogador consegue ler tudo que há numa página NUI. Deixe o servidor gerar um link presigned e passe só ele ao cliente. O link não precisa de token, pode ser limitado a um upload, um tipo de arquivo e uma key, e expira sozinho:
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;
}Como o servidor escolheu a key, ele já conhece o link final e pode guardá-lo sem confiar no que o cliente devolve. Expiração, limites de tamanho e as outras opções estão na referência da API.
Erros que vale tratar
Não fique tentando de novo num loop apertado quando der erro. Um mugshot que falhou pode esperar a próxima vez que um policial tirar um; um script que repete para sempre não pode.
Perguntas frequentes
Devo guardar imagens base64 no MySQL?
Não. Uma string base64 é cerca de um terço maior que a imagem que ela codifica. Toda query que seleciona a coluna carrega isso junto, e o dump do banco cresce a cada imagem. Envie a imagem uma vez para uma CDN e guarde só o link dela, que cabe num VARCHAR(512).
Como faço upload de um screenshot do screenshot-basic pelo Lua do servidor?
No servidor, chame requestClientScreenshot do screenshot-basic sem a opção fileName, e o callback recebe o screenshot como data URI. Envie como JSON (base64, filename e uma key opcional) para POST https://api.fivedock.dev/api/v3/file/base64 com o seu token de API no header Authorization. O link da CDN volta em data.url, e é esse link que você guarda.
Posso colocar minha API key de upload num script de cliente ou numa página NUI?
Não. Todo jogador pode ler os scripts de cliente e tudo que há numa página NUI, então quem achar a key consegue fazer upload na sua organização. Mantenha o token no server.cfg com set e faça o upload pelo Lua do servidor. Se a imagem nasce na NUI, faça o servidor gerar um link presigned para um único upload e passe só esse link ao cliente.
Como dar a cada personagem um link de mugshot que nunca muda?
Mande uma key junto com o upload, como mugshots/<identifier>.webp. O arquivo passa a ser servido em https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp, e um mugshot novo enviado para a mesma key troca a imagem por trás desse link. Links com key ficam em cache por cinco minutos, então a imagem nova aparece em todo lugar em cerca de cinco minutos.
Um script de upload base64 do Fivemanage funciona com a FiveDock?
Funciona. A FiveDock responde à mesma requisição POST /api/v3/file/base64 em https://api.fivedock.dev e devolve o link no mesmo campo data.url. Troque a URL e substitua a key do Fivemanage por um token de API da FiveDock (fd_live_…) com o scope files:write; o resto do script fica como está.