diff --git a/configs/photos.lua b/configs/photos.lua index aa101bc01..b1666b001 100644 --- a/configs/photos.lua +++ b/configs/photos.lua @@ -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 ///, and the direct-upload -- claim has to know the folder or it refuses them as foreign. Blank uses the sd_phone_media_path @@ -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. -- diff --git a/configs/server/apikeys.lua b/configs/server/apikeys.lua index 25c1cf745..67f15e761 100644 --- a/configs/server/apikeys.lua +++ b/configs/server/apikeys.lua @@ -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 = '', } diff --git a/configs/vibez.lua b/configs/vibez.lua index 64c5477ec..bcd0ac6f5 100644 --- a/configs/vibez.lua +++ b/configs/vibez.lua @@ -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 diff --git a/fxmanifest.lua b/fxmanifest.lua index 5b8fb32ed..330601dfd 100644 --- a/fxmanifest.lua +++ b/fxmanifest.lua @@ -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) diff --git a/server/callrec/init.lua b/server/callrec/init.lua index c444efe88..ca8916f93 100644 --- a/server/callrec/init.lua +++ b/server/callrec/init.lua @@ -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 @@ -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 diff --git a/server/mdt/recordings.lua b/server/mdt/recordings.lua index eed0fee8e..0c8572e39 100644 --- a/server/mdt/recordings.lua +++ b/server/mdt/recordings.lua @@ -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 @@ -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) diff --git a/server/media/probe.js b/server/media/probe.js new file mode 100644 index 000000000..c753d1421 --- /dev/null +++ b/server/media/probe.js @@ -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; + }); +})(); diff --git a/server/media/probe.lua b/server/media/probe.lua new file mode 100644 index 000000000..dff918007 --- /dev/null +++ b/server/media/probe.lua @@ -0,0 +1,39 @@ +---@type table Bounded CDN media probe through the existing Node runtime. +local probe = {} +local RESOURCE = 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 diff --git a/server/messages/actions.lua b/server/messages/actions.lua index 1068b8983..872011aef 100644 --- a/server/messages/actions.lua +++ b/server/messages/actions.lua @@ -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 }) @@ -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 diff --git a/server/photos/fivemesh.lua b/server/photos/fivemesh.lua new file mode 100644 index 000000000..284472d93 --- /dev/null +++ b/server/photos/fivemesh.lua @@ -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 = 'https://api.fivemesh.io/v1/objects/base64' +local CONVAR_KEY = 'sd_fivemesh_cdn_key' +local TIMEOUT_MS = 60000 +local MAX_URL_BYTES = 512 + +-- Keep a playable extension even when a caller omits a filename or supplies the wrong one. +local EXT_BY_MIME = { + ['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 = { + 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 diff --git a/server/photos/fivemeshPresign.lua b/server/photos/fivemeshPresign.lua new file mode 100644 index 000000000..272ed57af --- /dev/null +++ b/server/photos/fivemeshPresign.lua @@ -0,0 +1,234 @@ +local config = require 'configs.config' +local provider = require 'server.photos.fivemesh' +local crypto = require 'server.crypto' +local ledger = require 'server.media.ledger' +local probe = require 'server.media.probe' +local sniff = require 'server.media.sniff' +local store = require 'server.photos.store' +local mediaLimit = require 'server.photos.mediaLimit' +local player = require 'bridge.server.player' + +---@type table FiveMesh direct uploads. Each player/app slot has its own unpredictable path. +local presign = {} +local MINT_URL = 'https://api.fivemesh.io/v1/presigned-url' +local MINT_TIMEOUT_MS = 15000 +local COOLDOWN_MS = 5000 +local MIME_BY_KIND = { + image = { ['image/jpeg'] = true, ['image/png'] = true, ['image/webp'] = true, ['image/gif'] = true }, + video = { ['video/webm'] = true, ['video/mp4'] = true, ['video/quicktime'] = true, ['video/ogg'] = true }, + audio = { ['audio/webm'] = true, ['audio/ogg'] = true, ['audio/mp4'] = true, ['audio/mpeg'] = true, ['audio/wav'] = true }, +} +local EXT_BY_MIME = { + ['image/jpeg'] = { jpg = true, jpeg = true }, ['image/png'] = { png = true }, + ['image/webp'] = { webp = true }, ['image/gif'] = { gif = true }, + ['video/webm'] = { webm = true }, ['video/mp4'] = { mp4 = true, m4v = true }, + ['video/quicktime'] = { mov = true }, ['video/ogg'] = { ogg = true }, + ['audio/webm'] = { webm = true, weba = true }, ['audio/ogg'] = { ogg = true, oga = true }, + ['audio/mp4'] = { m4a = true, mp4 = true }, ['audio/mpeg'] = { mp3 = true }, + ['audio/wav'] = { wav = true }, +} + +local slots, claims, lastMint, penalties = {}, {}, {}, {} + +local function character(src) + local cid = player.getRealIdentifier(src) + return type(cid) == 'string' and cid ~= '' and cid or nil +end + +local function strike(cid) + local now = os.time() + local penalty = penalties[cid] + if not penalty or now >= penalty.expires then penalty = { count = 0, expires = now + 3600 } end + penalty.count = penalty.count + 1 + penalties[cid] = penalty +end + +function presign.available() + return (config.Photos or {}).AllowDirectUpload == true and provider.configured() + and provider.path() ~= nil and ledger.ready() and crypto.available() and probe.available() +end + +local function escape(value) + return (value:gsub('[^%w%-_%.~]', function(c) return ('%%%02X'):format(c:byte()) end)) +end + +local function allowedMimes(kinds) + local allowed, list = {}, {} + for kind, enabled in pairs(type(kinds) == 'table' and kinds or {}) do + if enabled and MIME_BY_KIND[kind] then + for mime in pairs(MIME_BY_KIND[kind]) do allowed[mime] = true end + end + end + for mime in pairs(allowed) do list[#list + 1] = mime end + table.sort(list) + return allowed, list +end + +---A reservation is paid before the provider is asked; only a URL never handed out is refunded. +---@param src integer +---@param opts {maxBytes: integer, kinds: table, purpose: string} +---@param cb fun(url: string|nil, code: string|nil) +function presign.mint(src, opts, cb) + opts = type(opts) == 'table' and opts or {} + local maxBytes = tonumber(opts.maxBytes) + local cid = character(src) + local allowed, mimes = allowedMimes(opts.kinds) + if not presign.available() or not cid or not maxBytes or maxBytes ~= maxBytes + or maxBytes == math.huge or maxBytes < 12 or #mimes == 0 + or type(opts.purpose) ~= 'string' or opts.purpose == '' then + return cb(nil, 'unavailable') + end + maxBytes = math.floor(maxBytes) + local now = os.time() + local held = slots[src] + if claims[src] or held and (held.pending or held.exp > now) then return cb(nil, 'busy') end + if held then slots[src] = nil; strike(held.cid) end + local penalty = penalties[cid] + if penalty and now < penalty.expires and penalty.count >= 3 then return cb(nil, 'unavailable') end + local nowMs = GetGameTimer() + if lastMint[cid] and nowMs - lastMint[cid] < COOLDOWN_MS then return cb(nil, 'cooldown') end + + local nonce = crypto.randomHex(16) + if type(nonce) ~= 'string' or #nonce ~= 32 or not nonce:match('^%x+$') then return cb(nil, 'unavailable') end + local path = provider.path() + path = (path ~= '' and path .. '/' or '') .. 'direct/' .. nonce + local okBudget, _, ticket = mediaLimit.reserve(src, maxBytes) + if not okBudget then return cb(nil, 'rate-limit') end + lastMint[cid] = nowMs + + local ttl = math.max(60, math.min(600, math.ceil(maxBytes / (256 * 1024)) + 30)) + local slot = { cid = cid, purpose = opts.purpose, maxBytes = maxBytes, allowed = allowed, + ticket = ticket, pending = true, exp = now + ttl } + slots[src] = slot + local finished = false + local function refuse(code) + if finished then return end + finished = true + if slots[src] == slot then slots[src] = nil end + mediaLimit.settle(ticket, 0) + cb(nil, code) + end + slot.cancel = function() refuse('unavailable') end + SetTimeout(MINT_TIMEOUT_MS, function() refuse('provider') end) + + local query = ('?path=%s&maxFiles=1&maxFileSize=%d&expiresIn=%d&allowedMimeTypes=%s&allowCustomMetadata=false&randomizeName=true') + :format(escape(path), maxBytes, ttl, escape(table.concat(mimes, ','))) + PerformHttpRequest(MINT_URL .. query, function(status, body) + if finished then return end + if slots[src] ~= slot or character(src) ~= cid then return refuse('unavailable') end + local ok, data = pcall(json.decode, body or '') + data = ok and type(data) == 'table' and data or nil + local url = data and data.uploadUrl + local prefix = data and data.publicUrlPrefix + local cap = data and tonumber(data.maxFileSize) + local valid = (status == 200 or status == 201) and data and data.success == true + and data.method == 'POST' and data.path == path and data.maxFiles == 1 + and data.allowCustomMetadata == false and data.randomizeName == true + and cap and cap >= 12 and cap <= maxBytes and cap == math.floor(cap) + and type(url) == 'string' and #url <= 2048 + and url:match('^https://api%.fivemesh%.io/v1/presigned%-url/[%w%-_]+$') + and type(prefix) == 'string' and #prefix + #path + 50 <= 512 + and prefix:match('^https://[%w%.%-]+/[%w%-_]+/$') + and type(data.allowedMimeTypes) == 'table' and #data.allowedMimeTypes > 0 + local enforced = {} + if valid then + for _, mime in ipairs(data.allowedMimeTypes) do + if type(mime) ~= 'string' or not allowed[mime] then valid = false break end + enforced[mime] = true + end + end + if not valid then + print(('^3[sd-phone:photos]^0 [PRESIGN] FiveMesh refused a bounded slot or returned an unsupported response (HTTP %s); using the server upload route.') + :format(tostring(status))) + return refuse('provider') + end + + finished = true + slot.pending, slot.cancel = false, nil + slot.exp = os.time() + ttl + slot.maxBytes, slot.allowed, slot.prefix = cap, enforced, prefix .. path .. '/' + cb(url) + end, 'GET', '', { ['Authorization'] = 'Bearer ' .. provider.key() }) +end + +---The client reports only a URL. Its prefix, app, owner, bytes and type are established here. +---@param src integer +---@param url any +---@param opts {maxBytes: integer, kinds: table, purpose: string} +---@param cb fun(url: string|nil, code: string|nil, bytes: integer|nil) +function presign.claim(src, url, opts, cb) + opts = type(opts) == 'table' and opts or {} + local slot = slots[src] + if not slot or slot.pending then return cb(nil, 'no-slot') end + slots[src] = nil + claims[src] = slot + local function refuse(code) + if claims[src] == slot then claims[src] = nil end + strike(slot.cid) + cb(nil, code) + end + local function stillOwned() + if claims[src] ~= slot then return false end + if character(src) == slot.cid then return true end + claims[src] = nil + return false + end + if character(src) ~= slot.cid or opts.purpose ~= slot.purpose then return refuse('no-slot') end + if os.time() >= slot.exp then return refuse('expired') end + if type(url) ~= 'string' or #url > 512 or url:sub(1, #slot.prefix) ~= slot.prefix then + return refuse('foreign-url') + end + local name, ext = url:sub(#slot.prefix + 1):match('^([%w%-_]+)%.([%a%d]+)$') + if not name then return refuse('foreign-url') end + ext = ext:lower() + if ledger.has(url) or store.urlExistsAnywhere(url) then return refuse('duplicate') end + -- Both database reads may yield. A disconnect or replacement slot cannot inherit this claim. + if not stillOwned() then return cb(nil, 'no-slot') end + + probe.read(url, function(result, code) + if not stillOwned() then return cb(nil, 'no-slot') end + if not result then return refuse(code or 'probe-failed') end + local mime = result.mime + local extensions = EXT_BY_MIME[mime] + local kind = type(mime) == 'string' and mime:match('^(%a+)/') + local kinds = type(opts.kinds) == 'table' and opts.kinds or {} + if not slot.allowed[mime] or not extensions or not extensions[ext] or not kinds[kind] + or type(result.head) ~= 'string' or not sniff.matches('data:' .. mime .. ';base64,' .. result.head) then + return refuse('bad-type') + end + local bytes, maxBytes = tonumber(result.bytes), tonumber(opts.maxBytes) + if not bytes or bytes < 12 or bytes ~= math.floor(bytes) then return refuse('probe-failed') end + if not maxBytes or maxBytes ~= maxBytes or bytes > math.min(slot.maxBytes, maxBytes) then + return refuse('too-large') + end + -- The random per-slot directory excludes historical objects even on an older ledger. + ledger.record(url) + if not stillOwned() then return cb(nil, 'no-slot') end + mediaLimit.settle(slot.ticket, bytes) + claims[src] = nil + cb(url, nil, bytes) + end) +end + +function presign.forget(src) + local slot = slots[src] or claims[src] + slots[src], claims[src] = nil, nil + if slot then + if slot.cancel then slot.cancel() else strike(slot.cid) end + end +end + +CreateThread(function() + while true do + Wait(60000) + local now = os.time() + for src, slot in pairs(slots) do + if not slot.pending and now >= slot.exp then slots[src] = nil; strike(slot.cid) end + end + for cid, penalty in pairs(penalties) do + if now >= penalty.expires then penalties[cid] = nil; lastMint[cid] = nil end + end + end +end) + +return presign diff --git a/server/photos/init.lua b/server/photos/init.lua index eeb1d7344..9627db0da 100644 --- a/server/photos/init.lua +++ b/server/photos/init.lua @@ -55,11 +55,15 @@ end -- swallow that: the shutter span, the spinner ran out, no photo, no reason. Say it once at boot -- so the gap is the server owner's to fix, not something players rediscover one capture at a time. if not uploader.configured() then + local providers = { + fivemanage = { name = 'Fivemanage media', setup = 'set FivemanageMedia in configs/server/apikeys.lua (free at fivemanage.com, token type "Media").' }, + qbox = { name = 'Qbox CDN', setup = 'set QboxCdn in configs/server/apikeys.lua (token from dashboard.qbox.re -> CDN -> API).' }, + fivemesh = { name = 'FiveMesh CDN', setup = 'set FiveMeshCdn in configs/server/apikeys.lua or the sd_fivemesh_cdn_key server convar (key with cdn:write from app.fivemesh.io).' }, + } + local provider = providers[uploader.provider()] boot.warn(('^3[sd-phone]^0 no %s key set: Camera, Photos and Voice Memos will accept a capture but never save it.') - :format(uploader.provider() == 'qbox' and 'Qbox CDN' or 'Fivemanage media')) - boot.warn(uploader.provider() == 'qbox' - and '^3[sd-phone]^0 set QboxCdn in configs/server/apikeys.lua (token from dashboard.qbox.re -> CDN -> API).' - or '^3[sd-phone]^0 set FivemanageMedia in configs/server/apikeys.lua (free at fivemanage.com, token type "Media").') + :format(provider.name)) + boot.warn('^3[sd-phone]^0 ' .. provider.setup) end ---Bootstraps the schema in a thread, pcall-guarded. The URL ledger goes up with it: presign @@ -370,12 +374,17 @@ local MAX_DIRECT_BYTES = math.floor(MAX_VIDEO_BYTES * 0.75) ---React -> server: mint an upload slot. Only the URL to POST to crosses back; the bucket and the ---expiry it is later measured against stay on the server, because handing a client the thing its ---own claim is checked against would leave nothing to check. -lib.callback.register('sd-phone:server:photos:uploadSlot', function(src) +lib.callback.register('sd-phone:server:photos:uploadSlot', function(src, payload) if not presign.available() then return { success = false, code = 'unavailable' } end if uploading[src] or assembling[src] then return { success = false, code = 'busy' } end + -- FiveMesh also bounds direct stills to the photo allowance. Fivemanage keeps its clip flow. + local isPhoto = type(payload) == 'table' and payload.kind == 'photo' + if isPhoto and uploader.provider() ~= 'fivemesh' then return { success = false, code = 'unavailable' } end + local p = promise.new() - presign.mint(src, { maxBytes = MAX_DIRECT_BYTES }, + presign.mint(src, { maxBytes = isPhoto and math.floor(MAX_PHOTO_BYTES * 0.75) or MAX_DIRECT_BYTES, + kinds = isPhoto and { image = true } or { video = true }, purpose = 'photos' }, function(url, code) p:resolve({ url = url, code = code }) end) local res = Citizen.Await(p) @@ -392,7 +401,7 @@ lib.callback.register('sd-phone:server:photos:uploadDone', function(src, payload local p = promise.new() presign.claim(src, payload.url, - { maxBytes = MAX_DIRECT_BYTES, kinds = { image = true, video = true } }, + { maxBytes = MAX_DIRECT_BYTES, kinds = { image = true, video = true }, purpose = 'photos' }, function(url, code, bytes) p:resolve({ url = url, code = code, bytes = bytes }) end) @@ -408,8 +417,8 @@ lib.callback.register('sd-phone:server:photos:uploadDone', function(src, payload -- direct path exists for - that no slices crossed the wire at all - is visible rather than -- inferred from an absence. if LOG_UPLOADS then - print(('^5[sd-phone:photos]^0 [UPLOAD] src=%s clip %.2f MB direct, 0 slice(s) over the wire') - :format(tostring(src), (res.bytes or 0) / 1048576)) + print(('^5[sd-phone:photos]^0 [UPLOAD] src=%s %s %.2f MB direct, 0 slice(s) over the wire') + :format(tostring(src), store.isVideoUrl(res.url) and 'clip' or 'photo', (res.bytes or 0) / 1048576)) end if not saveHosted(src, res.url, logFailure) then return { success = false, code = 'save-failed' } end diff --git a/server/photos/presign.lua b/server/photos/presign.lua index c67096c80..8792d498f 100644 --- a/server/photos/presign.lua +++ b/server/photos/presign.lua @@ -12,6 +12,8 @@ local ledger = require 'server.media.ledger' local mediaLimit = require 'server.photos.mediaLimit' ---@type table Player bridge (bridge.server.player): the character a slot is minted for. local player = require 'bridge.server.player' +---@type table FiveMesh scoped uploads; Fivemanage's existing flow below is kept separate. +local fivemesh = require 'server.photos.fivemeshPresign' ---@type table Presign module; the table returned at end of file. Mints one-shot upload slots so a ---client can put media straight into Fivemanage over ordinary HTTPS, and decides whether the URL @@ -225,6 +227,7 @@ end ---path, which is why nothing in this change deletes it. ---@return boolean function presign.available() + if uploader.provider() == 'fivemesh' then return fivemesh.available() end -- Opt-in, under a name older configs never set. Until 2026-09-16 the switch was `DirectUpload` -- and shipped true, so honouring it would leave every existing install exposed. if PHOTOS.AllowDirectUpload ~= true then return false end @@ -246,6 +249,7 @@ end ---@param opts { maxBytes: integer } the largest object the caller's claim will accept ---@param cb fun(url: string|nil, code: 'unavailable'|'busy'|'cooldown'|'rate-limit'|'provider'|nil) function presign.mint(src, opts, cb) + if uploader.provider() == 'fivemesh' then return fivemesh.mint(src, opts, cb) end local maxBytes = math.floor(tonumber(type(opts) == 'table' and opts.maxBytes or nil) or 0) if not presign.available() or maxBytes <= 0 then cb(nil, 'unavailable') @@ -364,6 +368,7 @@ end ---into somebody's photo gallery. ---@param cb fun(url: string|nil, code: 'no-slot'|'expired'|'foreign-url'|'duplicate'|'probe-failed'|'bad-type'|'too-large'|nil, bytes: integer|nil) function presign.claim(src, url, opts, cb) + if uploader.provider() == 'fivemesh' then return fivemesh.claim(src, url, opts, cb) end opts = type(opts) == 'table' and opts or {} local maxBytes = math.floor(tonumber(opts.maxBytes) or 0) local kinds = type(opts.kinds) == 'table' and opts.kinds or {} @@ -507,6 +512,7 @@ end ---by whoever the server hands that source id to next. ---@param src number function presign.forget(src) + fivemesh.forget(src) local slot = slots[src] slots[src] = nil if slot and not slot.pending then strike(slot.cid, 'abandoned on disconnect') end diff --git a/server/photos/uploader.lua b/server/photos/uploader.lua index 772abb65f..01bfcbbad 100644 --- a/server/photos/uploader.lua +++ b/server/photos/uploader.lua @@ -4,6 +4,9 @@ local config = require 'configs.config' ---@type table Qbox CDN provider (server.photos.qbox): the multipart route through the Node helper. local qbox = require 'server.photos.qbox' +---@type table FiveMesh CDN provider (server.photos.fivemesh): the server-side base64 JSON route. +local fivemesh = require 'server.photos.fivemesh' + ---@type table Media URL ledger (server.media.ledger): records every URL this server hosts, so a ---presigned-upload claim can tell a fresh object from one that already belongs to another app. local ledger = require 'server.media.ledger' @@ -42,11 +45,13 @@ function uploader.mediaKey() return mediaKey() end ----Which CDN this server uploads to. Anything other than 'qbox' stays on Fivemanage, so a typo +---Which CDN this server uploads to. Unknown values stay on Fivemanage, so a typo ---never silently sends media somewhere the owner did not choose. ----@return 'fivemanage'|'qbox' +---@return 'fivemanage'|'qbox'|'fivemesh' function uploader.provider() - return tostring(PHOTOS.Provider or 'fivemanage'):lower() == 'qbox' and 'qbox' or 'fivemanage' + local provider = tostring(PHOTOS.Provider or 'fivemanage'):lower() + if provider == 'qbox' or provider == 'fivemesh' then return provider end + return 'fivemanage' end ---True when the active provider has a token set. Read at boot so a server missing its key is @@ -54,6 +59,7 @@ end ---@return boolean function uploader.configured() if uploader.provider() == 'qbox' then return qbox.configured() end + if uploader.provider() == 'fivemesh' then return fivemesh.configured() end return mediaKey() ~= '' end @@ -155,6 +161,10 @@ function uploader.uploadMedia(base64Image, filename, cb) qbox.uploadMedia(base64Image, filename, recorded) return end + if uploader.provider() == 'fivemesh' then + fivemesh.uploadMedia(base64Image, filename, recorded) + return + end uploadFivemanage(base64Image, filename, recorded) end diff --git a/server/voicemail/init.lua b/server/voicemail/init.lua index 9be876125..13818e97a 100644 --- a/server/voicemail/init.lua +++ b/server/voicemail/init.lua @@ -67,7 +67,7 @@ lib.callback.register('sd-phone:server:voicemail:uploadSlot', function(src) if uploading[src] then return util.fail('voicemail.uploadInProgress', 'Upload already in progress') end local p = promise.new() - presign.mint(src, { maxBytes = math.floor(MAX_AUDIO_BYTES * 0.75) }, function(url) p:resolve(url) end) + presign.mint(src, { maxBytes = math.floor(MAX_AUDIO_BYTES * 0.75), kinds = { audio = true }, purpose = 'voicemail' }, function(url) p:resolve(url) end) local url = Citizen.Await(p) if not url then return util.fail('voicemail.uploadFailed', 'Upload failed') end return util.ok({ url = url }) @@ -79,7 +79,7 @@ lib.callback.register('sd-phone:server:voicemail:uploadDone', function(src, payl local p = promise.new() presign.claim(src, payload.url, - { maxBytes = math.floor(MAX_AUDIO_BYTES * 0.75), kinds = { audio = true } }, + { maxBytes = math.floor(MAX_AUDIO_BYTES * 0.75), kinds = { audio = true }, purpose = 'voicemail' }, function(url, code, bytes) p:resolve({ url = url, code = code, bytes = bytes }) end) local res = Citizen.Await(p) if not res.url then diff --git a/server/voicememos/init.lua b/server/voicememos/init.lua index 242309bed..22cea842d 100644 --- a/server/voicememos/init.lua +++ b/server/voicememos/init.lua @@ -74,7 +74,7 @@ lib.callback.register('sd-phone:server:voice:uploadSlot', function(src, payload) 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 = 'voice-memo' }, 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 @@ -93,7 +93,7 @@ lib.callback.register('sd-phone:server:voice:uploadDone', function(src, payload) 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 = 'voice-memo' }, function(url, code, bytes) p:resolve({ url = url, code = code, bytes = bytes }) end) local res = Citizen.Await(p) if not res.url then diff --git a/web/src/apps/camera/Camera.tsx b/web/src/apps/camera/Camera.tsx index df7c69db4..00eaafa28 100644 --- a/web/src/apps/camera/Camera.tsx +++ b/web/src/apps/camera/Camera.tsx @@ -529,6 +529,14 @@ export function Camera({ onClose, onLandscapeChange, onOpenApp, photoOnly = fals if (!image) { setPending(false); return; } setUploadError(null); + try { + const blob = await (await fetch(image)).blob(); + if (await uploadDirect(blob, `sdphone-${Date.now()}.jpg`, DIRECT_UPLOAD, { kind: 'photo' })) { + setPending(false); + return; + } + } catch { /* Keep the original capture route available if local Blob conversion fails. */ } + if (!(await uploadViaServer(image, 'sd-phone:camera:httpSlot', { kind: 'photo' }))) { const res = await apiCall('sd-phone:camera:capture', { image }); if (!res.success) { setPending(false); return; } diff --git a/web/src/shared/mediaUpload.ts b/web/src/shared/mediaUpload.ts index a5e1a3035..e161ea3a9 100644 --- a/web/src/shared/mediaUpload.ts +++ b/web/src/shared/mediaUpload.ts @@ -12,25 +12,37 @@ export async function uploadDirect( actions: DirectUploadActions, slotPayload?: unknown, ): Promise { - const slot = await apiData<{ url: string }>(actions.slot, slotPayload); - if (!slot || typeof slot.url !== 'string' || slot.url === '') return null; - - let hosted: string; + let timer: ReturnType | undefined; try { + const slot = await apiData<{ url: string }>(actions.slot, slotPayload); + if (!slot || typeof slot.url !== 'string' || !slot.url.startsWith('https://')) return null; + + const controller = new AbortController(); + const timeoutMs = Math.max(60000, Math.min(600000, Math.ceil(blob.size / 262144) * 1000 + 30000)); + timer = setTimeout(() => controller.abort(), timeoutMs); const form = new FormData(); form.append('file', blob, filename); - const res = await fetch(slot.url, { method: 'POST', body: form }); + const res = await fetch(slot.url, { + method: 'POST', body: form, signal: controller.signal, + credentials: 'omit', referrerPolicy: 'no-referrer', + }); if (!res.ok) return null; - const body = await res.json() as { data?: { url?: unknown } } | null; - const url = body?.data?.url; - if (typeof url !== 'string' || url === '') return null; - hosted = url; + const body = await res.json() as { + success?: boolean; data?: { url?: unknown }; object?: { publicUrl?: unknown }; + } | null; + if (body?.success === false) return null; + // Fivemanage returns data.url; FiveMesh returns a successful object.publicUrl. + const hosted = body?.data?.url ?? (body?.success === true ? body.object?.publicUrl : null); + if (typeof hosted !== 'string' || !hosted.startsWith('https://')) return null; + clearTimeout(timer); + timer = undefined; + const done = await apiCall(actions.done, { url: hosted }); + return done.success ? hosted : null; } catch { return null; + } finally { + clearTimeout(timer); } - - const done = await apiCall(actions.done, { url: hosted }); - return done.success ? hosted : null; } const NUI_HOST_PREFIX = 'cfx-nui-';