Skip to content
Blog

FiveM mugshots and screenshots as CDN links, not base64

Upload base64 images from server-side Lua with PerformHttpRequest, store only the link in MySQL, and give every character one stable mugshot URL.

By Termi

8 min read
  • Lua
  • Uploads
  • MySQL
  • MDT
On this page

MDTs, ID cards and evidence lockers all need pictures of players. The usual first version captures the picture as a base64 data URI and writes that string straight into MySQL. It works on day one and hurts on day ninety: base64 is a third bigger than the image itself, every query that selects the column drags it along, and your database dumps grow with every arrest.

The fix is to upload the image once and store only its link. This guide does it from server-side Lua with PerformHttpRequest and the FiveDock base64 endpoint, which takes the same request as Fivemanage’s.

  • A link is well under a hundred characters, so rows stay small. The image lives on a CDN that is built to serve images.
  • The UI is faster: your MDT sends a URL to the NUI instead of a huge string, and the browser loads and caches the image itself.
  • A URL works in a Discord log, on your website and in a second resource. A base64 blob only works where you paste it.

Get an API token and keep it on the server

Uploads need an API token. Create one in the panel on the Tokens page with the files:write scope; it starts with fd_live_. It is not the agent key (fda_live_…) that the fivedock resource uses, and the upload API does not accept agent keys.

Put the token in server.cfg with set. Never use setr, which replicates the value to every client, or sets, which publishes it in your server info:

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

If a client has to upload by itself, see uploading straight from NUI below.

Upload from server-side Lua

POST https://api.fivedock.dev/api/v3/file/base64 takes JSON with the image as a data URI, a file name and, optionally, a key. The link comes back in 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"
  }
}

Here is a complete server script. It keeps the shape of a typical Fivemanage integration and adds the 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)

The token goes in the Authorization header as it is; Bearer fd_live_… works too. When the upload succeeds, only the link ends up in MySQL.

One stable link per character

The key is what keeps the link stable. Without it, every upload gets a new random link and the old picture keeps taking up space. With mugshots/<identifier>.webp:

  • the link is https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp, and it never changes;
  • re-taking a mugshot uploads to the same key and replaces the picture behind the same link; the previous one is kept for 7 days, then purged;
  • any resource that knows the identifier can build the link without asking the database.

Keyed links are cached for five minutes, so a new mugshot reaches every screen within about five minutes.

Key rules

Keys may contain letters, digits and . _ - ~ @ + : ( ) , = !, with / between folders. An explicit key that breaks the rules is refused with 400, which is why the script cleans the identifier first. File types are detected from the bytes, not the name, but keep the extension honest anyway: if your capture produces JPEG, use .jpg in the key and the file name.

The table

ON DUPLICATE KEY UPDATE only works when identifier is a primary or unique key. And since the row now holds a link, a VARCHAR is plenty:

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

The script uses oxmysql, so the resource loads its library before the script:

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

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

Receiving the image from a client

Mugshots are usually captured on the client (often a ped headshot drawn onto a canvas in NUI) and sent to the server. Treat what arrives as untrusted: decide on the server who may take mugshots, take the identifier from the server-side player, and drop anything that is not a reasonably small image:

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 and getCharacterId depend on your framework, so they are yours to write. Pushing a big string through a net event is not free either; if your images are larger than a few hundred kilobytes, upload them from NUI instead, as shown below.

Screenshots work the same way

With screenshot-basic, the server can ask a client for a screenshot and gets a data URI back, which is exactly what the base64 endpoint takes. Here it is as evidence photos, one key per case and 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

If you run the FiveDock agent, exports.fivedock:screenshot takes the screenshot and uploads it in one call, with no token in your script (it needs screenshot-basic started, too). See the agent docs:

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)

Moving from Fivemanage

If your MDT already uploads to Fivemanage, the change is small:

  • swap https://api.fivemanage.com/api/v3/file/base64 for https://api.fivedock.dev/api/v3/file/base64,
  • swap the Fivemanage API key for your FiveDock token,
  • add key if you want one stable link per character instead of a new file per 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', '')

The request and the data.url in the response are the same, so the rest of your code stays as it is. For links that are already in your database, the migration guide copies your Fivemanage files to FiveDock and hands you a list of old link → new link to update the rows with.

Uploading straight from NUI

Sometimes the image is born in the browser: a camera app in a phone resource, or a canvas capture. Do not give the NUI page your token, because every player can read everything a NUI page contains. Let the server mint a presigned link and hand only that to the client. The link needs no token, can be limited to one upload, one file type and one key, and expires on its own:

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

Because the server picked the key, it already knows the final link and can store it without trusting what the client reports back. Expiry, size limits and the other options are in the API reference.

Errors worth handling

  • 401: the token is missing or wrong.
  • 403: the token lacks the files:write scope.
  • 400: the request is malformed, for example a key that breaks the key rules.
  • 413: the image is too big. Base64 uploads take up to 16 MiB of image.
  • 415: not an accepted type. PNG, JPEG, WebP, AVIF and GIF are.
  • 429: the workspace is over its storage limit. Back off, honour Retry-After if it is there, and try again later.

Do not retry in a tight loop on errors. A failed mugshot can wait for the next time an officer takes one; a script that retries forever cannot.

Common questions

Should I store base64 images in MySQL?

No. A base64 string is about a third bigger than the image it encodes. Every query that selects the column carries it along, and the database dump grows with every picture. Upload the image to a CDN once and store only its link, which fits in a VARCHAR(512).

How do I upload a screenshot-basic screenshot from server Lua?

On the server, call screenshot-basic’s requestClientScreenshot without a fileName option, and the callback receives the screenshot as a data URI. Send it as JSON (base64, filename and an optional key) to POST https://api.fivedock.dev/api/v3/file/base64 with your API token in the Authorization header. The CDN link comes back in data.url, and that link is what you store.

Can I put my upload API key in a client script or a NUI page?

No. Every player can read client scripts and everything a NUI page contains, so anyone who finds the key can upload into your workspace. Keep the token in server.cfg with set and upload from server Lua. If the image starts in the NUI, have the server mint a presigned link for one upload and hand only that link to the client.

Send a key with the upload, such as mugshots/<identifier>.webp. The file is then served at https://cdn.fivedock.dev/o/<your-workspace>/mugshots/<identifier>.webp, and a new mugshot uploaded to the same key replaces the picture behind that link. Keyed links are cached for five minutes, so the new picture shows everywhere within about five minutes.

Does a Fivemanage base64 upload script work with FiveDock?

Yes. FiveDock answers the same POST /api/v3/file/base64 request on https://api.fivedock.dev and returns the link in the same data.url field. Swap the URL and replace the Fivemanage key with a FiveDock API token (fd_live_…) that has the files:write scope; the rest of the script stays as it is.

All articles