Saltar al contenido
Blog

Mugshots y screenshots de FiveM como enlaces CDN, no base64

Sube imágenes base64 desde Lua de servidor con PerformHttpRequest, guarda solo el enlace en MySQL y da a cada personaje una URL de mugshot estable.

Por Termi

9 min de lectura
  • Lua
  • Uploads
  • MySQL
  • MDT
En esta página

Los MDT, los DNI y las taquillas de pruebas necesitan fotos de jugadores. La primera versión suele capturar la imagen como data URI en base64 y escribir esa cadena directamente en MySQL. El primer día funciona y el día noventa duele: el base64 pesa un tercio más que la imagen, cada consulta que selecciona la columna lo arrastra, y los dumps de tu base de datos crecen con cada arresto.

La solución es subir la imagen una vez y guardar solo su enlace. Esta guía lo hace desde Lua de servidor con PerformHttpRequest y el endpoint base64 de FiveDock, que acepta la misma petición que el de Fivemanage.

  • Un enlace tiene bastante menos de cien caracteres, así que las filas se quedan pequeñas. La imagen vive en un CDN pensado para servir imágenes.
  • La interfaz va más rápido: tu MDT manda una URL a la NUI en vez de una cadena enorme, y el navegador carga y cachea la imagen por su cuenta.
  • Una URL sirve en un log de Discord, en tu web y en un segundo resource. Un blob base64 solo sirve donde lo pegues.

Consigue un token de API y déjalo en el servidor

Las subidas necesitan un token de API. Créalo en el panel, en la página Tokens, con el scope files:write; empieza por fd_live_. No es la clave de agente (fda_live_…) que usa el resource fivedock, y la API de subida no acepta claves de agente.

Pon el token en server.cfg con set. Nunca con setr, que replica el valor a todos los clientes, ni con sets, que lo publica en la información del servidor:

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

Si un cliente tiene que subir por sí mismo, mira más abajo subir directamente desde la NUI.

Subir desde Lua de servidor

POST https://api.fivedock.dev/api/v3/file/base64 recibe un JSON con la imagen como data URI, un nombre de archivo y, opcionalmente, una key. El enlace vuelve en 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"
  }
}

Este es un script de servidor completo. Mantiene la forma de una integración típica con Fivemanage y añade la 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)

El token va tal cual en la cabecera Authorization; Bearer fd_live_… también funciona. Cuando la subida sale bien, solo el enlace llega a MySQL.

Un enlace estable por personaje

La key es lo que mantiene estable el enlace. Sin ella, cada subida recibe un enlace aleatorio nuevo y la imagen anterior sigue ocupando espacio. Con mugshots/<identifier>.webp:

  • el enlace es https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp y nunca cambia;
  • repetir un mugshot sube a la misma key y reemplaza la imagen detrás del mismo enlace; la anterior se conserva 7 días y luego se purga;
  • cualquier resource que conozca el identificador puede construir el enlace sin preguntar a la base de datos.

Los enlaces con key se cachean cinco minutos, así que un mugshot nuevo llega a todas las pantallas en unos cinco minutos.

Reglas de las keys

Las keys pueden contener letras, dígitos y . _ - ~ @ + : ( ) , = !, con / entre carpetas. Una key explícita que incumple las reglas se rechaza con 400, por eso el script limpia antes el identificador. El tipo de archivo se detecta por los bytes, no por el nombre, pero mantén la extensión honesta de todos modos: si tu captura produce JPEG, usa .jpg en la key y en el nombre de archivo.

La tabla

ON DUPLICATE KEY UPDATE solo funciona cuando identifier es clave primaria o única. Y como ahora la fila guarda un enlace, un VARCHAR de sobra:

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

El script usa oxmysql, así que el resource carga su librería antes que el script:

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

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

Recibir la imagen desde un cliente

Los mugshots suelen capturarse en el cliente (a menudo un headshot de ped dibujado en un canvas dentro de la NUI) y enviarse al servidor. Trata lo que llega como no fiable: decide en el servidor quién puede hacer mugshots, saca el identificador del jugador en el servidor y descarta todo lo que no sea una imagen razonablemente pequeña:

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 y getCharacterId dependen de tu framework, así que te toca escribirlas a ti. Pasar una cadena grande por un net event tampoco sale gratis; si tus imágenes pasan de unos cientos de kilobytes, súbelas desde la NUI, como se muestra abajo.

Los screenshots funcionan igual

Con screenshot-basic, el servidor puede pedirle un screenshot a un cliente y recibe una data URI, que es justo lo que acepta el endpoint base64. Aquí como fotos de pruebas, una key por caso y marca de tiempo:

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

Si tienes el agente de FiveDock, exports.fivedock:screenshot hace el screenshot y lo sube en una sola llamada, sin token en tu script (también necesita screenshot-basic iniciado). Mira la documentación del 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)

Pasarte desde Fivemanage

Si tu MDT ya sube a Fivemanage, el cambio es pequeño:

  • cambia https://api.fivemanage.com/api/v3/file/base64 por https://api.fivedock.dev/api/v3/file/base64,
  • cambia la API key de Fivemanage por tu token de FiveDock,
  • añade key si quieres un enlace estable por personaje en vez de un archivo nuevo en cada subida.
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', '')

La petición y el data.url de la respuesta son los mismos, así que el resto de tu código se queda como está. Para los enlaces que ya están en tu base de datos, la guía de migración copia tus archivos de Fivemanage a FiveDock y te da una lista de enlace antiguo → enlace nuevo para actualizar las filas.

Subir directamente desde la NUI

A veces la imagen nace en el navegador: una app de cámara en un resource de móvil, o una captura de canvas. No le des tu token a la página NUI, porque cualquier jugador puede leer todo lo que contiene. Deja que el servidor genere un enlace presigned y entrégale al cliente solo ese. El enlace no necesita token, se puede limitar a una subida, un tipo de archivo y una key, y caduca solo:

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 el servidor eligió la key, ya conoce el enlace final y puede guardarlo sin fiarse de lo que le devuelva el cliente. La caducidad, los límites de tamaño y el resto de opciones están en la referencia de la API.

Errores que conviene manejar

  • 401: falta el token o es incorrecto.
  • 403: el token no tiene el scope files:write.
  • 400: la petición está mal formada, por ejemplo una key que incumple las reglas.
  • 413: la imagen es demasiado grande. Las subidas base64 admiten hasta 16 MiB de imagen.
  • 415: tipo no aceptado. PNG, JPEG, WebP, AVIF y GIF sí lo son.
  • 429: la organización ha superado su límite de almacenamiento. Espera, respeta Retry-After si viene, y vuelve a intentarlo más tarde.

No reintentes en bucle cerrado ante los errores. Un mugshot fallido puede esperar a la próxima vez que un agente haga uno; un script que reintenta sin parar, no.

Preguntas frecuentes

¿Debo guardar imágenes base64 en MySQL?

No. Una cadena base64 pesa aproximadamente un tercio más que la imagen que codifica. Cada consulta que selecciona la columna la arrastra, y el dump de la base de datos crece con cada imagen. Sube la imagen una vez a un CDN y guarda solo su enlace, que cabe en un VARCHAR(512).

¿Cómo subo un screenshot de screenshot-basic desde Lua de servidor?

En el servidor, llama a requestClientScreenshot de screenshot-basic sin la opción fileName, y el callback recibe el screenshot como data URI. Envíalo como JSON (base64, filename y una key opcional) a POST https://api.fivedock.dev/api/v3/file/base64 con tu token de API en la cabecera Authorization. El enlace del CDN vuelve en data.url, y ese enlace es lo que guardas.

¿Puedo poner mi API key de subida en un script de cliente o en una página NUI?

No. Cualquier jugador puede leer los scripts de cliente y todo lo que contiene una página NUI, así que quien encuentre la key puede subir archivos a tu organización. Deja el token en server.cfg con set y sube desde Lua de servidor. Si la imagen nace en la NUI, haz que el servidor genere un enlace presigned para una sola subida y entrega al cliente solo ese enlace.

Envía una key con la subida, por ejemplo mugshots/<identifier>.webp. El archivo se sirve entonces en https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp, y un mugshot nuevo subido a la misma key reemplaza la imagen detrás de ese enlace. Los enlaces con key se cachean cinco minutos, así que la imagen nueva se ve en todas partes en unos cinco minutos.

¿Funciona con FiveDock un script de subida base64 hecho para Fivemanage?

Sí. FiveDock responde a la misma petición POST /api/v3/file/base64 en https://api.fivedock.dev y devuelve el enlace en el mismo campo data.url. Cambia la URL y sustituye la key de Fivemanage por un token de API de FiveDock (fd_live_…) con el scope files:write; el resto del script se queda como está.

Todos los artículos