Aller au contenu
Blog

Mugshots et screenshots FiveM en liens CDN, pas en base64

Envoyez des images base64 depuis le Lua serveur avec PerformHttpRequest, gardez seulement le lien en MySQL et donnez à chaque perso une URL de mugshot stable.

Par Termi

9 min de lecture
  • Lua
  • Uploads
  • MySQL
  • MDT
Sur cette page

Les MDT, les cartes d’identité et les casiers à preuves ont tous besoin de photos de joueurs. La première version capture en général l’image en data URI base64 et écrit cette chaîne directement dans MySQL. Ça marche le premier jour et ça fait mal le quatre-vingt-dixième : le base64 pèse un tiers de plus que l’image, chaque requête qui sélectionne la colonne la traîne avec elle, et vos dumps de base grossissent à chaque arrestation.

La solution : envoyer l’image une fois et ne stocker que son lien. Ce guide le fait depuis du Lua côté serveur, avec PerformHttpRequest et l’endpoint base64 de FiveDock, qui accepte la même requête que celui de Fivemanage.

  • Un lien fait bien moins de cent caractères, les lignes restent donc petites. L’image vit sur un CDN conçu pour servir des images.
  • L’interface est plus rapide : votre MDT envoie une URL à la NUI au lieu d’une énorme chaîne, et le navigateur charge et met l’image en cache lui-même.
  • Une URL fonctionne dans un log Discord, sur votre site et dans une deuxième resource. Un blob base64 ne fonctionne que là où vous le collez.

Créer un token API et le garder côté serveur

Les uploads demandent un token API. Créez-le dans le panel, sur la page Jetons, avec le scope files:write ; il commence par fd_live_. Ce n’est pas la clé d’agent (fda_live_…) qu’utilise la resource fivedock, et l’API d’upload n’accepte pas les clés d’agent.

Mettez le token dans server.cfg avec set. Jamais avec setr, qui réplique la valeur à chaque client, ni sets, qui la publie dans les infos du serveur :

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

Si un client doit uploader lui-même, voyez plus bas l’upload direct depuis la NUI.

Uploader depuis du Lua côté serveur

POST https://api.fivedock.dev/api/v3/file/base64 prend du JSON avec l’image en data URI, un nom de fichier et, en option, une clé. Le lien revient dans 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"
  }
}

Voici un script serveur complet. Il garde la forme d’une intégration Fivemanage classique et ajoute la clé :

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)

Le token va tel quel dans l’en-tête Authorization ; Bearer fd_live_… fonctionne aussi. Quand l’upload réussit, seul le lien arrive dans MySQL.

Un lien stable par personnage

C’est la key qui rend le lien stable. Sans elle, chaque upload reçoit un nouveau lien aléatoire et l’ancienne image continue d’occuper de la place. Avec mugshots/<identifier>.webp :

  • le lien est https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp et ne change jamais ;
  • refaire un mugshot l’envoie sur la même clé et remplace l’image derrière le même lien ; la précédente est conservée 7 jours, puis purgée ;
  • toute resource qui connaît l’identifiant peut construire le lien sans interroger la base.

Les liens à clé sont mis en cache cinq minutes : un nouveau mugshot atteint tous les écrans en cinq minutes environ.

Règles des clés

Une clé peut contenir des lettres, des chiffres et . _ - ~ @ + : ( ) , = !, avec / entre les dossiers. Une clé explicite qui enfreint ces règles est refusée avec un 400, c’est pourquoi le script nettoie d’abord l’identifiant. Le type de fichier est détecté à partir des octets, pas du nom, mais gardez quand même une extension honnête : si votre capture produit du JPEG, mettez .jpg dans la clé et dans le nom de fichier.

La table

ON DUPLICATE KEY UPDATE ne marche que si identifier est une clé primaire ou unique. Et comme la ligne contient maintenant un lien, un VARCHAR suffit largement :

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

Le script utilise oxmysql, la resource charge donc sa bibliothèque avant le script :

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

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

Recevoir l’image depuis un client

Les mugshots sont en général capturés côté client (souvent un headshot de ped dessiné sur un canvas dans la NUI) puis envoyés au serveur. Considérez tout ce qui arrive comme non fiable : décidez côté serveur qui peut prendre des mugshots, prenez l’identifiant du joueur côté serveur, et jetez tout ce qui n’est pas une image raisonnablement petite :

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 et getCharacterId dépendent de votre framework, c’est à vous de les écrire. Faire passer une grosse chaîne dans un net event n’est pas gratuit non plus ; si vos images dépassent quelques centaines de kilo-octets, uploadez-les depuis la NUI, comme montré plus bas.

Les screenshots fonctionnent pareil

Avec screenshot-basic, le serveur peut demander un screenshot à un client et reçoit une data URI, exactement ce que prend l’endpoint base64. Ici sous forme de photos de preuves, une clé par dossier et par horodatage :

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 vous faites tourner l’agent FiveDock, exports.fivedock:screenshot prend le screenshot et l’envoie en un seul appel, sans token dans votre script (screenshot-basic doit aussi être démarré). Voir la doc de l’agent :

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)

Quitter Fivemanage

Si votre MDT envoie déjà ses fichiers vers Fivemanage, le changement est minime :

  • remplacez https://api.fivemanage.com/api/v3/file/base64 par https://api.fivedock.dev/api/v3/file/base64,
  • remplacez la clé API Fivemanage par votre token FiveDock,
  • ajoutez key si vous voulez un lien stable par personnage plutôt qu’un nouveau fichier à chaque 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', '')

La requête et le data.url de la réponse sont les mêmes, le reste de votre code ne bouge donc pas. Pour les liens déjà présents dans votre base, le guide de migration copie vos fichiers Fivemanage vers FiveDock et vous donne une liste ancien lien → nouveau lien pour mettre les lignes à jour.

Uploader directement depuis la NUI

Parfois l’image naît dans le navigateur : une appli appareil photo dans une resource de téléphone, ou une capture de canvas. Ne donnez pas votre token à la page NUI, car chaque joueur peut lire tout ce qu’elle contient. Laissez le serveur générer un lien presigned et ne transmettez que celui-là au client. Le lien n’a pas besoin de token, peut être limité à un upload, un type de fichier et une clé, et expire tout seul :

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

Comme le serveur a choisi la clé, il connaît déjà le lien final et peut le stocker sans se fier à ce que le client lui renvoie. L’expiration, les limites de taille et les autres options sont dans la référence de l’API.

Les erreurs à gérer

  • 401 : le token est absent ou incorrect.
  • 403 : le token n’a pas le scope files:write.
  • 400 : la requête est mal formée, par exemple une clé qui enfreint les règles.
  • 413 : l’image est trop grosse. Les uploads base64 acceptent jusqu’à 16 MiB d’image.
  • 415 : type non accepté. PNG, JPEG, WebP, AVIF et GIF le sont.
  • 429 : l’organisation a dépassé sa limite de stockage. Attendez, respectez Retry-After s’il est présent, et réessayez plus tard.

Ne réessayez pas en boucle serrée après une erreur. Un mugshot raté peut attendre la prochaine fois qu’un officier en prend un ; un script qui réessaie sans fin, non.

Questions fréquentes

Faut-il stocker des images base64 dans MySQL ?

Non. Une chaîne base64 pèse environ un tiers de plus que l’image qu’elle encode. Chaque requête qui sélectionne la colonne la transporte, et le dump de la base grossit à chaque image. Envoyez l’image une fois sur un CDN et ne stockez que son lien, qui tient dans un VARCHAR(512).

Comment uploader un screenshot de screenshot-basic depuis du Lua serveur ?

Côté serveur, appelez requestClientScreenshot de screenshot-basic sans option fileName, et le callback reçoit le screenshot sous forme de data URI. Envoyez-le en JSON (base64, filename et une key optionnelle) à POST https://api.fivedock.dev/api/v3/file/base64, avec votre token API dans l’en-tête Authorization. Le lien CDN revient dans data.url, et c’est ce lien que vous stockez.

Puis-je mettre ma clé API d’upload dans un script client ou une page NUI ?

Non. Chaque joueur peut lire les scripts client et tout le contenu d’une page NUI : quiconque trouve la clé peut uploader dans votre organisation. Gardez le token dans server.cfg avec set et uploadez depuis du Lua serveur. Si l’image naît dans la NUI, faites générer par le serveur un lien presigned pour un seul upload et ne transmettez que ce lien au client.

Envoyez une key avec l’upload, par exemple mugshots/<identifier>.webp. Le fichier est alors servi sur https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp, et un nouveau mugshot envoyé sur la même clé remplace l’image derrière ce lien. Les liens à clé sont mis en cache cinq minutes, la nouvelle image apparaît donc partout en cinq minutes environ.

Un script d’upload base64 pour Fivemanage fonctionne-t-il avec FiveDock ?

Oui. FiveDock répond à la même requête POST /api/v3/file/base64 sur https://api.fivedock.dev et renvoie le lien dans le même champ data.url. Changez l’URL et remplacez la clé Fivemanage par un token API FiveDock (fd_live_…) avec le scope files:write ; le reste du script ne bouge pas.

Tous les articles