Pular para o conteúdo
Blog

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

9 min de leitura
  • Lua
  • Uploads
  • MySQL
  • MDT
Nesta página

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.

  • Um link tem bem menos de cem caracteres, então as linhas continuam pequenas. A imagem fica numa CDN feita para servir imagens.
  • A interface fica mais rápida: seu MDT manda uma URL para a NUI em vez de uma string enorme, e o navegador carrega e faz cache da imagem sozinho.
  • Uma URL funciona num log do Discord, no seu site e num segundo resource. Um blob base64 só funciona onde você o colar.

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:

server.cfg
# 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:

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

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:

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)

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:

  • o link é https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp e nunca muda;
  • tirar o mugshot de novo envia para a mesma key e troca a imagem por trás do mesmo link; a anterior fica guardada por 7 dias e depois é apagada;
  • qualquer resource que conheça o identificador monta o link sem consultar o banco.

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:

SQL
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:

fxmanifest.lua
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:

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 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:

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

Se 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:

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)

Saindo do Fivemanage

Se o seu MDT já envia para o Fivemanage, a mudança é pequena:

  • troque https://api.fivemanage.com/api/v3/file/base64 por https://api.fivedock.dev/api/v3/file/base64,
  • troque a API key do Fivemanage pelo seu token da FiveDock,
  • adicione key se quiser um link estável por personagem em vez de um arquivo novo a cada 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', '')

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:

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

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

  • 401: o token está faltando ou errado.
  • 403: o token não tem o scope files:write.
  • 400: a requisição está malformada, por exemplo uma key que quebra as regras.
  • 413: a imagem é grande demais. Uploads base64 aceitam até 16 MiB de imagem.
  • 415: tipo não aceito. PNG, JPEG, WebP, AVIF e GIF são aceitos.
  • 429: a organização passou do limite de armazenamento. Espere, respeite o Retry-After se ele vier, e tente de novo mais tarde.

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.

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á.

Todos os artigos