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
- Lua
- Uploads
- MySQL
- MDT
On this page
- Why not keep base64 in the database
- Get an API token and keep it on the server
- Upload from server-side Lua
- One stable link per character
- The table
- Receiving the image from a client
- Screenshots work the same way
- Moving from Fivemanage
- Uploading straight from NUI
- Errors worth handling
- Common questions
- Should I store base64 images in MySQL?
- How do I upload a screenshot-basic screenshot from server Lua?
- Can I put my upload API key in a client script or a NUI page?
- How do I give every character one mugshot link that never changes?
- Does a Fivemanage base64 upload script work with FiveDock?
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.
Why not keep base64 in the database
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:
# 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:
{
"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"
}
}Here is a complete server script. It keeps the shape of a typical Fivemanage integration and adds the 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)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:
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:
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:
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:
-- 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:
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)
endIf 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:
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:
-- 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:
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;
}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
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.
How do I give every character one mugshot link that never changes?
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.