Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions configs/photos.lua
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,25 @@
-- value still falls back to the legacy `sd_fivemanage_key` server convar. Create a "Media" token
-- at https://app.fivemanage.com.
return {
-- Which CDN uploads go to: 'fivemanage' or 'qbox'.
-- Which CDN uploads go to: 'fivemanage', 'qbox' or 'fivemesh'.
--
-- 'fivemanage' uses the Fivemanage Media token (FivemanageMedia in configs/server/apikeys.lua,
-- or the legacy sd_fivemanage_key convar).
--
-- 'qbox' uses the Qbox Dashboard CDN (QboxCdn in configs/server/apikeys.lua, or the
-- sd_qbox_cdn_key convar). Generate the token at https://dashboard.qbox.re -> CDN -> API.
-- Anything other than 'qbox' stays on Fivemanage, so a typo never quietly moves your media.
--
-- 'fivemesh' uses FiveMesh CDN (FiveMeshCdn in configs/server/apikeys.lua, or the
-- sd_fivemesh_cdn_key server convar). Use a Server API Key or a Developer API Key with
-- cdn:write at https://app.fivemesh.io. Restricted paths must include FiveMeshPath below.
-- Unknown values stay on Fivemanage, so a typo never quietly moves your media.
Provider = 'fivemanage',

-- FiveMesh destination folder, independent of Fivemanage's MediaPath. Nested folders are
-- supported (e.g. 'phone/production'); use letters, numbers, hyphens and underscores in each
-- segment. An empty string uploads to the CDN root. Filenames receive a unique suffix.
FiveMeshPath = 'phone',

-- Folder your Fivemanage Media token is scoped to, if any (e.g. 'myserver/prod'). Uploads made
-- with a folder-scoped token come back as <CDN>/<team>/<folder>/<name>, and the direct-upload
-- claim has to know the folder or it refuses them as foreign. Blank uses the sd_phone_media_path
Expand Down Expand Up @@ -81,10 +90,13 @@ return {
-- With this off, every upload goes through your server first, where UploadLimits applies to
-- every byte before anything reaches Fivemanage. Nothing else changes.
--
-- This replaces the old DirectUpload setting, which is no longer read.
-- FiveMesh also supports this opt-in: one file per link, bounded size and MIME types, a
-- short expiry and a unique upload folder. The server verifies ownership and media bytes
-- before saving. The warning above describes Fivemanage's less restrictive links.
-- Qbox keeps the server upload routes. This replaces the old DirectUpload setting.
AllowDirectUpload = false,

-- Ceilings on how much players can upload to your media provider (Fivemanage or Qbox). Every
-- Ceilings on how much players can upload to your media provider (Fivemanage, Qbox, FiveMesh). Every
-- camera photo and clip, voice memo, voicemail, call recording, voice message, bodycam
-- recording and Clout voiceover counts. The defaults are far above what a normal player uses.
--
Expand Down
7 changes: 7 additions & 0 deletions configs/server/apikeys.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,15 @@
-- 2. Open CDN -> API -> generate an API token
-- 3. Paste it below (it looks like qbox_live_...)
-- Left blank here, the uploader falls back to the `sd_qbox_cdn_key` server convar.
--
-- FiveMesh CDN. Only read when Provider = 'fivemesh' in configs/photos.lua.
-- At https://app.fivemesh.io, enable CDN and generate a Server API Key (fm_server_...) or a
-- Developer API Key (fm_live_...) with cdn:write. If restricting CDN paths, allow the folder in
-- Photos.FiveMeshPath (default: phone). Left blank, falls back to sd_fivemesh_cdn_key in server.cfg.
-- Use `set` for that convar, never `setr` or `sets`: the key must stay server-side.
return {
Giphy = '',
FivemanageMedia = '',
QboxCdn = '',
FiveMeshCdn = '',
}
4 changes: 2 additions & 2 deletions configs/vibez.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ return {

-- Text to speech on a Clout upload. The composer offers a text box and a voice; the
-- server turns them into an audio clip and plays it over the video, the way TikTok does.
-- Needs the same media upload key the app already uses to store videos (Fivemanage or
-- Qbox). The endpoint is a free public relay, so it can rate limit or go down; a failure
-- Needs the same media upload key the app already uses to store videos (Fivemanage,
-- Qbox or FiveMesh). The endpoint is a free public relay, so it can rate limit or go down; a failure
-- is soft, the post still uploads, just without the voiceover.
TTS = {
-- Whether the composer offers text to speech at all. With this off the field is hidden
Expand Down
1 change: 1 addition & 0 deletions fxmanifest.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ server_scripts {
'server/crypto.js',
'server/relay.js',
'server/upload.js',
'server/media/probe.js',
'bridge/server/init.lua',
'server/main.lua',
'server/compat/computers.lua', -- sd-computers link (optional; inert unless sd-computers calls it)
Expand Down
4 changes: 2 additions & 2 deletions server/callrec/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ lib.callback.register('sd-phone:server:callrec:uploadSlot', function(src, payloa
payload = type(payload) == 'table' and payload or {}

local p = promise.new()
presign.mint(src, { maxBytes = MAX_DIRECT_BYTES },
presign.mint(src, { maxBytes = MAX_DIRECT_BYTES, kinds = { audio = true }, purpose = 'call-recording' },
function(url, code) p:resolve({ url = url, code = code }) end)
local res = Citizen.Await(p)
if not res.url then return { success = false, code = res.code or 'provider' } end
Expand All @@ -101,7 +101,7 @@ lib.callback.register('sd-phone:server:callrec:uploadDone', function(src, payloa
payload = type(payload) == 'table' and payload or {}

local p = promise.new()
presign.claim(src, payload.url, { maxBytes = MAX_DIRECT_BYTES, kinds = { audio = true } },
presign.claim(src, payload.url, { maxBytes = MAX_DIRECT_BYTES, kinds = { audio = true }, purpose = 'call-recording' },
function(url, code, bytes) p:resolve({ url = url, code = code, bytes = bytes }) end)
local res = Citizen.Await(p)
if not res.url then
Expand Down
4 changes: 2 additions & 2 deletions server/mdt/recordings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,7 @@ if ENABLED then
if not meta then return { success = false, code = 'refused', message = reason } end

local p = promise.new()
presign.mint(src, { maxBytes = MAX_DIRECT_BYTES },
presign.mint(src, { maxBytes = MAX_DIRECT_BYTES, kinds = { video = true }, purpose = 'bodycam' },
function(url, code) p:resolve({ url = url, code = code }) end)
local res = Citizen.Await(p)
if not res.url then return { success = false, code = res.code or 'provider' } end
Expand All @@ -520,7 +520,7 @@ if ENABLED then

local p = promise.new()
presign.claim(src, payload.url,
{ maxBytes = MAX_DIRECT_BYTES, kinds = { video = true } },
{ maxBytes = MAX_DIRECT_BYTES, kinds = { video = true }, purpose = 'bodycam' },
function(url, code, bytes)
p:resolve({ url = url, code = code, bytes = bytes })
end)
Expand Down
69 changes: 69 additions & 0 deletions server/media/probe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// A bounded range read: verifying a direct upload must never download a whole recording.
(() => {
const https = require('node:https');
const registerExport = typeof exports === 'function' ? exports : global.exports;
const HEAD_BYTES = 12;

registerExport('sdMediaProbeReady', () => true);
registerExport('sdMediaProbe', (ticket, url) => {
let settled = false;
const finish = (result, code) => {
if (settled) return;
settled = true;
const send = typeof emit === 'function' ? emit : TriggerEvent;
send('sd-phone:server:media:probed', String(ticket), result, code);
};
let parsed;
try { parsed = new URL(url); } catch { finish(null, 'probe-failed'); return true; }
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port || parsed.hash) {
finish(null, 'probe-failed');
return true;
}

const req = https.request(parsed, {
method: 'GET',
headers: { Range: `bytes=0-${HEAD_BYTES - 1}`, 'Accept-Encoding': 'identity' },
}, (res) => {
const range = /^bytes 0-(\d+)\/(\d+)$/.exec(res.headers['content-range'] || '');
const total = range ? Number(range[2]) : 0;
const end = range ? Number(range[1]) : -1;
const mime = String(res.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase();
if (res.statusCode !== 206 || !Number.isSafeInteger(total) || total < HEAD_BYTES
|| end !== HEAD_BYTES - 1 || res.headers['content-encoding'] && res.headers['content-encoding'] !== 'identity') {
finish(null, 'probe-failed');
res.destroy();
return;
}

const chunks = [];
let received = 0;
res.on('data', (chunk) => {
received += chunk.length;
if (received > HEAD_BYTES) {
finish(null, 'probe-failed');
res.destroy();
return;
}
chunks.push(chunk);
});
res.on('end', () => {
if (received !== HEAD_BYTES) return finish(null, 'probe-failed');
finish({ bytes: total, mime, head: Buffer.concat(chunks).toString('base64') }, null);
});
res.on('error', () => finish(null, 'probe-failed'));
res.on('aborted', () => finish(null, 'probe-failed'));
});
// A wall-clock deadline also covers a peer that trickles bytes to avoid an idle timeout.
const timer = setTimeout(() => {
finish(null, 'probe-failed');
req.destroy();
}, 10000);
req.on('close', () => {
clearTimeout(timer);
finish(null, 'probe-failed');
});
req.on('error', () => finish(null, 'probe-failed'));
req.end();
return true;
});
})();
39 changes: 39 additions & 0 deletions server/media/probe.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---@type table Bounded CDN media probe through the existing Node runtime.
local probe = {}
local RESOURCE <const> = GetCurrentResourceName()
local waiting = {}
local sequence = 0

local function call(name, ...)
return pcall(function(...) return exports[RESOURCE][name](exports[RESOURCE], ...) end, ...)
end

function probe.available()
local ok, ready = call('sdMediaProbeReady')
return ok and ready == true
end

local function settle(ticket, result, code)
local cb = waiting[ticket]
if not cb then return end
waiting[ticket] = nil
cb(result, code)
end

AddEventHandler('sd-phone:server:media:probed', function(ticket, result, code)
settle(tostring(ticket), result, code)
end)

---Reads twelve bytes and trusted response headers. Redirects and ignored ranges are refused.
---@param url string
---@param cb fun(result: {bytes: integer, mime: string, head: string}|nil, code: string|nil)
function probe.read(url, cb)
sequence = sequence + 1
local ticket = tostring(sequence)
waiting[ticket] = cb
local ok, accepted = call('sdMediaProbe', ticket, url)
if not ok or accepted ~= true then settle(ticket, nil, 'probe-failed') end
SetTimeout(12000, function() settle(ticket, nil, 'probe-failed') end)
end

return probe
4 changes: 2 additions & 2 deletions server/messages/actions.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1328,7 +1328,7 @@ function actions.voiceSlot(source)
if not presign.available() then return fail('messages.uploadFailed', 'Upload failed') end
local maxBytes = (config.VoiceMemos and config.VoiceMemos.MaxAudioBytes) or (8 * 1024 * 1024)
local p = promise.new()
presign.mint(source, { maxBytes = math.floor(maxBytes * 0.75) }, function(url) p:resolve(url) end)
presign.mint(source, { maxBytes = math.floor(maxBytes * 0.75), kinds = { audio = true }, purpose = 'voice-message' }, function(url) p:resolve(url) end)
local url = Citizen.Await(p)
if not url then return fail('messages.uploadFailed', 'Upload failed') end
return ok({ url = url })
Expand All @@ -1345,7 +1345,7 @@ function actions.voiceDone(source, payload)
local maxBytes = (config.VoiceMemos and config.VoiceMemos.MaxAudioBytes) or (8 * 1024 * 1024)
local p = promise.new()
presign.claim(source, payload.url,
{ maxBytes = math.floor(maxBytes * 0.75), kinds = { audio = true } },
{ maxBytes = math.floor(maxBytes * 0.75), kinds = { audio = true }, purpose = 'voice-message' },
function(url, code, bytes) p:resolve({ url = url, code = code, bytes = bytes }) end)
local res = Citizen.Await(p)
if not res.url then
Expand Down
149 changes: 149 additions & 0 deletions server/photos/fivemesh.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
---@type table sd-phone config root: FiveMesh credentials stay in config.ApiKeys on the server.
local config = require 'configs.config'

---@type table FiveMesh CDN provider; shares the uploader callback contract with the other CDNs.
local fivemesh = {}

local UPLOAD_URL <const> = 'https://api.fivemesh.io/v1/objects/base64'
local CONVAR_KEY <const> = 'sd_fivemesh_cdn_key'
local TIMEOUT_MS <const> = 60000
local MAX_URL_BYTES <const> = 512

-- Keep a playable extension even when a caller omits a filename or supplies the wrong one.
local EXT_BY_MIME <const> = {
['image/jpeg'] = 'jpg', ['image/jpg'] = 'jpg', ['image/png'] = 'png',
['image/gif'] = 'gif', ['image/webp'] = 'webp',
['video/webm'] = 'webm', ['video/mp4'] = 'mp4', ['video/quicktime'] = 'mov',
['video/ogg'] = 'ogg', ['video/x-matroska'] = 'webm',
['audio/webm'] = 'webm', ['audio/ogg'] = 'ogg', ['audio/mp4'] = 'm4a',
['audio/x-m4a'] = 'm4a', ['audio/mpeg'] = 'mp3', ['audio/mp3'] = 'mp3',
['audio/aac'] = 'aac', ['audio/wav'] = 'wav', ['audio/wave'] = 'wav',
['audio/x-wav'] = 'wav', ['audio/flac'] = 'flac', ['audio/x-flac'] = 'flac',
}

-- The player-facing codes are already translated by the phone. Provider details stay in the log.
local CODE_BY_ERROR <const> = {
UNAUTHORIZED = 'no-key',
FORBIDDEN = 'no-key',
INVALID_PATH = 'bad-data',
FILE_TOO_LARGE = 'bad-data',
CONTENT_TYPE_NOT_ALLOWED = 'bad-data',
}

---Config key first, then the server convar; read fresh for each upload.
---@return string
function fivemesh.key()
local key = (config.ApiKeys or {}).FiveMeshCdn
if type(key) == 'string' and key ~= '' then return key end
return GetConvar(CONVAR_KEY, '')
end

---@return boolean
function fivemesh.configured()
return fivemesh.key() ~= ''
end

---A configured folder is kept separate from Fivemanage's MediaPath. Fail on an invalid folder
---rather than silently storing a file outside the operator's intended prefix.
---@return string|nil
function fivemesh.path()
local path = (config.Photos or {}).FiveMeshPath
if path == nil then path = 'phone' end
if type(path) ~= 'string' then return nil end
path = path:gsub('^/+', ''):gsub('/+$', '')
if path ~= '' and (('/' .. path):gsub('/[%w%-_]+', '')) ~= '' then return nil end
return path
end

---FiveMesh stores the filename we send. Add a random suffix so simultaneous captures, exports
---and different servers sharing a folder never intentionally reuse the same object name.
---@param filename string|nil
---@param ext string
---@return string
local function uniqueFilename(filename, ext)
local stem = type(filename) == 'string' and filename:match('([^/\\]+)$') or 'sdphone'
stem = (stem or 'sdphone'):gsub('%.[^%.]*$', ''):gsub('[^%w%-_]', '-'):sub(1, 64)
if stem == '' then stem = 'sdphone' end
local suffix = ('%08x%08x%08x%08x'):format(
math.random(0, 0xFFFFFFFF), math.random(0, 0xFFFFFFFF),
math.random(0, 0xFFFFFFFF), math.random(0, 0xFFFFFFFF))
return ('%s-%s.%s'):format(stem, suffix, ext)
end

---Uploads a data URL using FiveMesh's server-side JSON endpoint. Media sniffing, per-player
---budgets and URL ledger recording belong to the shared uploader and its callers.
---@param dataUrl string
---@param filename string|nil
---@param cb fun(url: string|nil, err: string|nil, code: 'no-key'|'bad-data'|'provider'|nil)
function fivemesh.uploadMedia(dataUrl, filename, cb)
local key = fivemesh.key()
if key == '' then
cb(nil, 'No FiveMesh CDN key configured on this server', 'no-key')
return
end

local mime = type(dataUrl) == 'string' and dataUrl:match('^data:([^;,]+)') or nil
mime = mime and mime:lower() or nil
local ext = mime and EXT_BY_MIME[mime] or nil
if not ext or not dataUrl:find(';base64,', 1, true) then
cb(nil, 'Empty or unsupported media payload', 'bad-data')
return
end

local path = fivemesh.path()
if not path then
print('^1[sd-phone:photos]^0 [UPLOAD] invalid FiveMeshPath: use folder segments containing only letters, numbers, hyphens or underscores.')
cb(nil, 'The FiveMesh CDN upload folder is invalid', 'provider')
return
end

local body = json.encode({
data = dataUrl,
filename = uniqueFilename(filename, ext),
contentType = mime,
path = path,
})

local settled = false
local function settle(url, err, code)
if settled then return end
settled = true
cb(url, err, code)
end

SetTimeout(TIMEOUT_MS, function()
if settled then return end
print('^1[sd-phone:photos]^0 [UPLOAD] FiveMesh CDN did not answer in time.')
settle(nil, 'The FiveMesh CDN did not answer in time', 'provider')
end)

PerformHttpRequest(UPLOAD_URL, function(status, responseBody)
if settled then return end
local okJson, decoded = pcall(json.decode, responseBody or '')
decoded = okJson and type(decoded) == 'table' and decoded or nil
if status < 200 or status >= 300 or not decoded or decoded.success ~= true then
local errorCode = decoded and type(decoded.error) == 'table' and decoded.error.code or nil
local code = CODE_BY_ERROR[errorCode] or (status == 401 or status == 403) and 'no-key'
or status == 413 and 'bad-data' or 'provider'
-- Avoid logging the full response: it may contain credentials or echoed media data.
print(('^1[sd-phone:photos]^0 [UPLOAD] FiveMesh CDN rejected the upload: HTTP %s (%s)')
:format(tostring(status), tostring(errorCode or 'invalid response')))
settle(nil, ('FiveMesh CDN upload failed: HTTP %s'):format(tostring(status)), code)
return
end

local url = type(decoded.object) == 'table' and decoded.object.publicUrl or nil
if type(url) ~= 'string' or #url > MAX_URL_BYTES
or not url:match('^https://[%w%.%-]+/.+') or url:find('[%s%c]') then
print('^1[sd-phone:photos]^0 [UPLOAD] FiveMesh CDN returned no usable public URL.')
settle(nil, 'FiveMesh CDN returned no usable public URL', 'provider')
return
end
settle(url, nil)
end, 'POST', body, {
['Content-Type'] = 'application/json',
['Authorization'] = 'Bearer ' .. key,
})
end

return fivemesh
Loading