Player space
Let your players find the photos, clips and voice notes they took, by signing in with Discord, Steam or Cfx.re.
What your players get
At /player, a player signs in with Discord, Steam or their Cfx.re account,
the accounts FiveM and RedM already know, and finds every photo, clip and
voice note your server sent under their name, newest first, server by
server. They can link all three accounts to one space, and delete it
whenever they want.
A media reaches a player only when all of these hold:
- Your organisation opened the space, in Settings, Player space, and shows that kind of media. Images and clips are shown by default; voice notes are your choice, since they can carry other players' voices.
- The key that sent it stays on your game server. Tick "Feeds the player space" on that key, on the keys screen. Never tick it on a key a phone script holds: lb-phone sends every upload from the player's game, so whoever holds that key could put files under somebody else's name. Leave an anticheat or staff capture key unticked too.
- The upload carries the player's identifiers, as below.
Media uploaded before all three were true do not appear: nothing is attributed after the fact.
Send the identifiers with an upload
Add the player-metadata header. The simplest form forwards what FiveM
already gives the server:
{
"name": "Ana Kowalska",
"identifier": "char1:4b1d2a9e...",
"identifiers": [
"license:4b1d2a9e...",
"discord:<DISCORD_ID>",
"steam:1100001025e4c00"
]
}identifiers is the list GetPlayerIdentifiers(source) returns. Aperture
keeps discord, steam, fivem, license and license2, and drops the
rest, the player's address first. Named keys ("discord": "1837...") work
too and win over the list. name and identifier are what the library
shows, as before.
A server-side capture with screenshot-basic, uploaded by the server itself:
exports['screenshot-basic']:requestClientScreenshot(source, { encoding = 'webp' }, function(err, data)
if err then return end
PerformHttpRequest('https://your-instance/api/v1/uploads/base64', function(status, body)
print(status, body)
end, 'POST', json.encode({ base64 = data, filename = 'capture.webp' }), {
['Content-Type'] = 'application/json',
['Authorization'] = 'YOUR_SERVER_TOKEN',
['player-metadata'] = json.encode({
name = GetPlayerName(source),
identifiers = GetPlayerIdentifiers(source),
}),
})
end)lb-phone: upload tickets
lb-phone uploads from the player's game, so it can neither keep a key secret nor be believed about who took the photo. An upload ticket fixes both: the server, which keeps its key, asks Aperture for a single-use address with the player's identifiers on it, and the phone uploads there without any key.
lb-phone already has the hook for it: GetPresignedUrl(source, uploadType)
in server/custom/functions/functions.lua runs on the server, with the
player's source, whenever a block's url reads PRESIGNED_URL.
In shared/upload.lua, add a method beside Custom:
Aperture = {
Default = {
url = "PRESIGNED_URL",
field = "file",
error = { path = "status", value = "error" },
success = { path = "data.url" },
},
},No headers: the phone needs none. Point the three kinds at it in
config/config.lua:
Config.UploadMethod.Video = "Aperture"
Config.UploadMethod.Image = "Aperture"
Config.UploadMethod.Audio = "Aperture"Then, in GetPresignedUrl, before the warning at the end of the function:
if uploadMethod == "Aperture" then
return Citizen.Await(promise.new(function(p)
PerformHttpRequest("https://your-instance/api/v1/upload-tickets", function(status, body)
if status ~= 201 then
infoprint("error", "Aperture refused the upload ticket: " .. tostring(body))
return p:resolve()
end
p:resolve(json.decode(body)?.data?.url)
end, "POST", json.encode({
kind = string.lower(uploadType),
player = {
identifier = GetIdentifier(source),
name = GetPlayerName(source),
identifiers = GetPlayerIdentifiers(source),
},
}), {
["Content-Type"] = "application/json",
["Authorization"] = apiKey,
})
end))
endapiKey is API_KEYS[uploadType], from server/apiKeys.lua: with this
method it never leaves the server. Give each key its kind's upload scope,
tick "Feeds the player space" on all three, and give them to no other script.
This was written against lb-phone 2.9.0 and has not been run on a server yet. Send one photo, one clip and one voice note before you rely on it.
The ticket routes
POST /api/v1/upload-tickets, with your key, body
{ "kind": "image" | "video" | "audio", "player": { ... } }, the same player
object as the header. Answers 201:
{
"status": "ok",
"data": {
"url": "https://your-instance/api/v1/player-uploads/apu_...",
"field": "file",
"kind": "image",
"expires_at": "2026-10-28T20:10:00+00:00"
}
}POST the file to url, multipart, field file, without any key. The
answer is exactly what /api/v1/uploads answers. A ticket works once, for
ten minutes, for its kind only, and dies with the key that minted it.
| Code | What it means |
|---|---|
upload_ticket_invalid |
Unknown, expired, used, or its key was revoked. Ask for a new one |
unsupported_media_type |
Not the kind the ticket was minted for (details.expected_kind). The ticket stays usable |
insufficient_scope |
The key cannot upload that kind |
feature_not_enabled |
Upload tickets are not enabled for your organisation |