FIX - restore FiveManage camera and memo uploads (#19)

* TRY - isolate FiveManage media upload verification

* TRY - verify unindexed FiveManage storage objects

* FIX - bind FiveManage uploads by storage key

* FIX - match FiveManage camera upload flow

* FIX - match FiveManage memo upload flow
This commit is contained in:
Leon.Schmidt
2026-08-21 01:29:56 +02:00
committed by GitHub
parent 7bbafd0026
commit 7b4e445394
18 changed files with 556 additions and 154 deletions
+89 -14
View File
@@ -14,6 +14,12 @@ import { isTrustedRootMessageSource } from '@/utils/windowMessages'
type RecordingChunk = { blob: Blob; durationMs: number }
type PendingVideo = { blob: Blob; fileName: string }
type UploadFailureDebug = {
correlationId: string
message: string
stage: string
status?: number
}
const canvasRef = ref<HTMLCanvasElement | null>(null)
const pendingVideos = new Map<string, PendingVideo>()
@@ -130,7 +136,10 @@ function cleanupRecording(): void {
try {
activeRecorder.stop()
} catch (error) {
console.error('[Camera] Could not stop the failed media recorder.', error)
console.error(
'[Camera] Could not stop the failed media recorder.',
error,
)
}
}
}
@@ -245,8 +254,7 @@ async function startRecording(data: Record<string, unknown>): Promise<void> {
const activeRecorder = recorder
removeRecorderErrorListener = bindMediaRecorderError(
activeRecorder,
() =>
generation === recordingGeneration && recorder === activeRecorder,
() => generation === recordingGeneration && recorder === activeRecorder,
(event) => {
console.error('[Camera] Media recorder failed while recording.', event)
cleanupRecording()
@@ -417,8 +425,32 @@ async function capturePhotoBlob(ready: UploadReady): Promise<Blob> {
}
}
async function failUpload(requestId: string, error: string): Promise<void> {
await nuiCall('media:failUpload', { error, requestId })
async function failUpload(
requestId: string,
error: string,
debug: UploadFailureDebug,
): Promise<void> {
console.error('[Sky Phone Media] Upload failed.', {
correlationId: debug.correlationId,
detail: debug.message,
error,
stage: debug.stage,
status: debug.status,
})
const response = await nuiCall('media:failUpload', {
correlationId: debug.correlationId,
debugMessage: debug.message,
debugStage: debug.stage,
debugStatus: debug.status,
error,
requestId,
})
if (!response.success) {
console.error('[Sky Phone Media] Could not forward upload diagnostics.', {
correlationId: debug.correlationId,
error: response.error,
})
}
}
async function uploadReady(ready: UploadReady): Promise<void> {
@@ -435,49 +467,87 @@ async function uploadReady(ready: UploadReady): Promise<void> {
blob = await capturePhotoBlob(ready)
fileName = `camera-${ready.correlationId}.${ready.photo?.Encoding ?? 'jpg'}`
}
} catch {
await failUpload(ready.requestId, 'capture_failed')
} catch (error) {
await failUpload(ready.requestId, 'capture_failed', {
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: 'capture',
})
return
}
console.info('[Sky Phone Media] Capture prepared for upload.', {
bytes: blob.size,
correlationId: ready.correlationId,
mimeType: blob.type,
type: ready.mediaType,
})
const form = new FormData()
form.append('file', blob, fileName)
form.append(
'metadata',
JSON.stringify({ captureToken: ready.captureToken, source: 'sky_phone' }),
)
const controller = new AbortController()
const timeout = window.setTimeout(
() => controller.abort(),
ready.uploadTimeoutMs ?? 25000,
)
let debugStage = 'provider_request'
let debugStatus: number | undefined
try {
const response = await fetch(ready.presignedUrl, {
body: form,
method: 'POST',
signal: controller.signal,
})
debugStatus = response.status
debugStage = 'provider_response'
console.info('[Sky Phone Media] FiveManage upload responded.', {
correlationId: ready.correlationId,
status: response.status,
})
const text = await response.text()
const body = JSON.parse(text) as {
data?: { id?: string; url?: string }
error?: string
id?: string
message?: string
url?: string
}
const uploaded = body.data ?? body
if (!response.ok || !uploaded.id || !uploaded.url) {
throw new Error('upload_failed')
throw new Error(
(typeof body.error === 'string' && body.error) ||
(typeof body.message === 'string' && body.message) ||
'upload_failed',
)
}
await nuiCall('media:completeUpload', {
debugStage = 'completion_callback'
const completion = await nuiCall('media:completeUpload', {
correlationId: ready.correlationId,
remoteId: uploaded.id,
requestId: ready.requestId,
url: uploaded.url,
})
if (!completion.success) {
throw new Error(completion.error ?? 'completion_callback_failed')
}
console.info(
'[Sky Phone Media] Upload completion forwarded to the server.',
{
correlationId: ready.correlationId,
},
)
} catch (error) {
await failUpload(
ready.requestId,
error instanceof DOMException && error.name === 'AbortError'
? 'upload_timeout'
: 'upload_failed',
{
correlationId: ready.correlationId,
message: error instanceof Error ? error.message : String(error),
stage: debugStage,
status: debugStatus,
},
)
} finally {
window.clearTimeout(timeout)
@@ -523,7 +593,12 @@ function onMessage(event: MessageEvent): void {
)
}
} else if (message.type === 'media:uploadReady') {
void uploadReady(message.data as UploadReady)
const ready = message.data as UploadReady
console.info('[Sky Phone Media] Upload-ready received.', {
correlationId: ready.correlationId,
type: ready.mediaType,
})
void uploadReady(ready)
} else if (message.type === 'media:uploadResult') {
const correlationId = String(message.data?.correlationId ?? '')
if (correlationId) pendingVideos.delete(correlationId)
@@ -365,6 +365,7 @@ async function stopRecording(data: Record<string, unknown>): Promise<void> {
mimeType,
note: finalMetadata.note,
pinned: finalMetadata.pinned,
sizeBytes: blob.size,
title: finalMetadata.title,
waveform,
}
@@ -458,14 +459,6 @@ async function uploadReady(ready: MemoUploadReady): Promise<void> {
pending.requestId = ready.requestId
const form = new FormData()
form.append('file', pending.blob, pending.fileName)
form.append(
'metadata',
JSON.stringify({
captureToken: ready.captureToken,
purpose: 'memo',
source: 'sky_phone',
}),
)
const controller = new AbortController()
pending.abortController = controller
const timeout = window.setTimeout(
@@ -0,0 +1,103 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaConfig = readFileSync(
new URL('../../sky_phone/config/media.lua', import.meta.url),
'utf8',
)
const manifest = readFileSync(
new URL('../../sky_phone/fxmanifest.lua', import.meta.url),
'utf8',
)
const mediaProviderConfig = readFileSync(
new URL(
'../../sky_phone/source/server/media_provider_config.lua',
import.meta.url,
),
'utf8',
)
const mediaImportAdapter = readFileSync(
new URL(
'../../sky_phone/source/server/media_import/fivemanage.lua',
import.meta.url,
),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
const memoServer = readFileSync(
new URL('../../sky_phone/source/server/memos.lua', import.meta.url),
'utf8',
)
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const memoRecorder = readFileSync(
new URL('./components/PhoneMemoRecorder.vue', import.meta.url),
'utf8',
)
describe('FiveManage server configuration contract', () => {
it('keeps the provider token in the server-only media config', () => {
expect(mediaConfig).toMatch(/FiveManage\s*=\s*{\s*ApiKey\s*=/)
expect(mediaProviderConfig).toContain(
'return trim_key(Config.Media.FiveManage.ApiKey)',
)
expect(mediaProviderConfig).not.toContain('GetConvar')
})
it('uses one resolver for Camera uploads and FiveManage imports', () => {
expect(
manifest.indexOf("'source/server/media_provider_config.lua'"),
).toBeLessThan(manifest.indexOf("'source/server/media_import.lua'"))
expect(mediaServer).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey()',
)
expect(mediaImportAdapter).toContain(
'SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)',
)
})
it('uses the direct FiveManage upload response flow for Camera and voice memos', () => {
expect(mediaConfig).not.toContain('VerificationRetryDelaysMs')
expect(mediaCapture).toContain("form.append('file', blob, fileName)")
expect(mediaCapture).not.toContain("form.append('path'")
expect(mediaCapture).not.toContain("form.append(\n 'metadata'")
expect(memoRecorder).toContain(
"form.append('file', pending.blob, pending.fileName)",
)
expect(memoRecorder).not.toContain("form.append('path'")
expect(memoRecorder).not.toContain("form.append(\n 'metadata'")
expect(mediaServer).toContain(
'Accepting the direct FiveManage upload response',
)
expect(mediaServer).toContain('remote_id = remote_id')
expect(mediaServer).toContain('url = uploaded_url')
expect(mediaServer).not.toContain('"HEAD"')
expect(mediaServer).not.toContain('authenticated upload-path lookup')
})
it('allowlists the FiveManage API and media hosts', () => {
expect(mediaServer).toContain('["api.fivemanage.com"] = true')
expect(mediaServer).toContain('["fmapi.net"] = true')
expect(mediaServer).toContain(
'uploaded_host:lower() ~= "r2.fivemanage.com"',
)
})
it('validates and preserves the recorded memo size before upload', () => {
expect(memoRecorder).toContain('sizeBytes: blob.size')
expect(memoServer).toContain(
'local size_bytes = tonumber(data.sizeBytes)',
)
expect(memoServer).toContain(
'size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes',
)
expect(memoServer).toContain('size_bytes = memo.size_bytes')
expect(mediaServer).toContain('size = state.size_bytes')
})
})
@@ -0,0 +1,21 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const mediaCapture = readFileSync(
new URL('./components/PhoneMediaCapture.vue', import.meta.url),
'utf8',
)
const mediaServer = readFileSync(
new URL('../../sky_phone/source/server/media.lua', import.meta.url),
'utf8',
)
describe('media upload diagnostics contracts', () => {
it('forwards browser upload failure context to the server log', () => {
expect(mediaCapture).toContain("let debugStage = 'provider_request'")
expect(mediaCapture).toContain('debugStatus: debug.status')
expect(mediaServer).toContain('Client-reported upload failure')
expect(mediaServer).toContain('diagnostic_text(data.debugMessage, 240)')
})
})
+5
View File
@@ -4012,6 +4012,11 @@ const defaultLocales: LocaleTree = {
invalid_media_type: 'The uploaded media type is invalid.',
invalid_upload: 'The upload could not be verified.',
invalid_upload_token: 'The upload session is no longer valid.',
media_provider_failed: 'The camera upload service is unavailable.',
media_provider_rate_limited:
'The camera upload service is busy. Try again shortly.',
media_provider_unauthorized:
'The configured FiveManage API key was rejected.',
missing_config: 'Camera uploads are not configured.',
microphone_unavailable:
'Allow microphone access or mute the microphone before recording.',
-1
View File
@@ -70,7 +70,6 @@ export type MediaImportResult = {
}
export type UploadReady = {
captureToken: string
correlationId: string
mediaType: MediaType
photo?: {
-1
View File
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
export type MemoUploadReady = {
requestId: string
correlationId: string
captureToken: string
presignedUrl: string
uploadTimeoutMs?: number
}
+7
View File
@@ -158,6 +158,13 @@ describe('media utilities', () => {
expect(mediaErrorKey('profile_photo_required')).toBe(
'profile_photo_required',
)
expect(mediaErrorKey('media_provider_failed')).toBe('media_provider_failed')
expect(mediaErrorKey('media_provider_rate_limited')).toBe(
'media_provider_rate_limited',
)
expect(mediaErrorKey('media_provider_unauthorized')).toBe(
'media_provider_unauthorized',
)
expect(mediaErrorKey('private_provider_error')).toBe('request_failed')
})
})
+3
View File
@@ -95,6 +95,9 @@ export function mediaErrorKey(error?: string): string {
'invalid_import_url',
'invalid_upload',
'invalid_upload_token',
'media_provider_failed',
'media_provider_rate_limited',
'media_provider_unauthorized',
'missing_config',
'import_media_not_allowed',
'import_media_too_large',
+26 -1
View File
@@ -134,10 +134,30 @@ async function requestPhoto(): Promise<void> {
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
return
}
await nuiCall('media:requestUpload', {
console.info('[Sky Phone Media] Camera requested a photo upload.', {
correlationId: id,
})
const response = await nuiCall('media:requestUpload', {
correlationId: id,
mediaType: 'photo',
})
if (!response.success) {
console.error('[Sky Phone Media] Photo upload request was rejected.', {
correlationId: id,
error: response.error,
})
window.postMessage(
{
data: {
correlationId: id,
error: response.error ?? 'request_failed',
success: false,
},
type: 'media:uploadResult',
},
'*',
)
}
}
function startRecording(): void {
@@ -374,6 +394,11 @@ function onMessage(event: MessageEvent): void {
} else if (message.type === 'media:uploadResult') {
const result = message.data as UploadResult
if (!result?.correlationId) return
console.info('[Sky Phone Media] Camera received an upload result.', {
correlationId: result.correlationId,
error: result.error,
success: result.success,
})
savingVideo.value = false
if (result.success && result.media) {
latestMedia.value = result.media
+3 -1
View File
@@ -1426,7 +1426,9 @@ Locales["de"] = {
errors = {
cancelled = "Die Aufnahme ist abgesagt.", capture_failed = "Die Spielansicht kann nicht erfasst werden.",
invalid_media_type = "Der hochgeladene Medientyp ist ungültig.", invalid_upload = "Der Upload konnte nicht überprüft werden.",
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", missing_config = "Kamera-Uploads sind nicht konfiguriert.",
invalid_upload_token = "Die Upload-Sitzung ist nicht mehr gültig.", media_provider_failed = "Der Kamera-Upload-Dienst ist nicht verfügbar.",
media_provider_rate_limited = "Der Kamera-Upload-Dienst ist ausgelastet. Versuch es gleich erneut.", media_provider_unauthorized = "Der konfigurierte FiveManage API-Schlüssel wurde abgelehnt.",
missing_config = "Kamera-Uploads sind nicht konfiguriert.",
microphone_unavailable = "Erlaube den Mikrofonzugriff oder schalte das Mikrofon vor der Aufnahme stumm.",
not_found = "Das Medienobjekt existiert nicht mehr.", owner_changed = "Das aktive Telefonkonto hat sich beim Upload geändert.",
operation_in_progress = "Eine weitere Medienoperation ist bereits im Gange.",
+3 -1
View File
@@ -1426,7 +1426,9 @@ Locales["en"] = {
errors = {
cancelled = "Capture cancelled.", capture_failed = "Unable to capture the game view.",
invalid_media_type = "The uploaded media type is invalid.", invalid_upload = "The upload could not be verified.",
invalid_upload_token = "The upload session is no longer valid.", missing_config = "Camera uploads are not configured.",
invalid_upload_token = "The upload session is no longer valid.", media_provider_failed = "The camera upload service is unavailable.",
media_provider_rate_limited = "The camera upload service is busy. Try again shortly.", media_provider_unauthorized = "The configured FiveManage API key was rejected.",
missing_config = "Camera uploads are not configured.",
microphone_unavailable = "Allow microphone access or mute the microphone before recording.",
not_found = "The media item no longer exists.", owner_changed = "The active phone account changed during upload.",
operation_in_progress = "Another media operation is already in progress.",
+1
View File
@@ -106,6 +106,7 @@ server_scripts {
'source/server/notifications.lua',
'source/shared/public_api.lua',
'source/server/public_api.lua',
'source/server/media_provider_config.lua',
'source/server/media_import.lua',
'source/server/media_import/fivemanage.lua',
'source/server/media_import/manifest.lua',
+42
View File
@@ -448,6 +448,13 @@ RegisterNUICallback("media:requestUpload", function(data, cb)
cb({ success = false, error = "invalid_request" })
return
end
Bridge.Debug(
"debug",
"[sky_phone][media-debug] NUI requested an upload (correlation=%s, type=%s).",
tostring(data.correlationId),
tostring(data.mediaType),
{ notice = true }
)
TriggerServerEvent("sky_phone:media:request-upload", data)
cb({ success = true })
end)
@@ -457,6 +464,15 @@ RegisterNUICallback("media:completeUpload", function(data, cb)
cb({ success = false, error = "invalid_request" })
return
end
Bridge.Debug(
"debug",
"[sky_phone][media-debug] NUI completed the provider upload (correlation=%s, remote-id=%s, url=%s, original-url=%s).",
tostring(data.correlationId),
type(data.remoteId) == "string" and "present" or "missing",
type(data.url) == "string" and "present" or "missing",
type(data.originalUrl) == "string" and "present" or "missing",
{ notice = true }
)
TriggerServerEvent("sky_phone:media:complete-upload", data)
cb({ success = true })
end)
@@ -475,6 +491,16 @@ RegisterNUICallback("media:failUpload", function(data, cb)
cb({ success = false, error = "invalid_request" })
return
end
local debug_message = tostring(data.debugMessage or "unknown"):gsub("[\r\n]", " "):sub(1, 240)
Bridge.Debug(
"error",
"[sky_phone][media-debug] NUI reported an upload failure (correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
tostring(data.correlationId),
tostring(data.error),
tostring(data.debugStage),
tostring(data.debugStatus),
debug_message
)
TriggerServerEvent("sky_phone:media:fail-upload", data)
cb({ success = true })
end)
@@ -534,10 +560,26 @@ RegisterNUICallback("memos:failUpload", function(data, cb)
end)
RegisterNetEvent("sky_phone:media:upload-ready", function(data)
Bridge.Debug(
"debug",
"[sky_phone][media-debug] Client received upload-ready (correlation=%s, type=%s, presigned-url=%s).",
tostring(type(data) == "table" and data.correlationId),
tostring(type(data) == "table" and data.mediaType),
type(data) == "table" and type(data.presignedUrl) == "string" and "present" or "missing",
{ notice = true }
)
SendNUIMessage({ type = "media:uploadReady", data = data })
end)
RegisterNetEvent("sky_phone:media:upload-result", function(data)
Bridge.Debug(
"debug",
"[sky_phone][media-debug] Client received upload-result (correlation=%s, success=%s, error=%s).",
tostring(type(data) == "table" and data.correlationId),
tostring(type(data) == "table" and data.success),
tostring(type(data) == "table" and data.error),
{ notice = true }
)
SendNUIMessage({ type = "media:uploadResult", data = data })
end)
+216 -113
View File
@@ -4,36 +4,19 @@ SkyPhoneMediaImport.Initialize()
local pending_uploads = {}
local pending_deletes = {}
local allowed_remote_mimes = {
audio = {
["audio/ogg"] = true,
["audio/webm"] = true,
},
photo = {
["image/jpeg"] = true,
["image/png"] = true,
["image/webp"] = true,
},
video = {
["video/mp4"] = true,
["video/webm"] = true,
},
local allowed_fivemanage_hosts = {
["api.fivemanage.com"] = true,
["fmapi.net"] = true,
}
local function media_config()
return Config.Media.FiveManage
local function diagnostic_text(value, maximum_length)
return tostring(value or "unknown"):gsub("[\r\n]", " "):sub(1, maximum_length)
end
local function media_api_key()
local api_key = media_config().ApiKey
if type(api_key) ~= "string" then
return ""
end
return api_key:match("^%s*(.-)%s*$")
end
local function api_configured()
return media_api_key() ~= ""
local function media_debug(message, ...)
local arguments = { ... }
arguments[#arguments + 1] = { notice = true }
Bridge.Debug("debug", "[sky_phone][media-debug] " .. message, table.unpack(arguments))
end
local function http_request(url, method, body, headers, timeout_ms)
@@ -65,6 +48,9 @@ local function response_error_message(response)
if type(response) ~= "table" then
return "invalid response"
end
if type(response.status) == "number" and response.status >= 200 and response.status < 300 then
return "none"
end
if type(response.error) == "string" and response.error ~= "" then
return response.error:sub(1, 240)
end
@@ -75,6 +61,10 @@ local function response_error_message(response)
return message:sub(1, 240)
end
end
local response_body = tostring(response.body or ""):gsub("[\r\n]", " ")
if response_body ~= "" then
return response_body:sub(1, 240)
end
return "no provider error message"
end
@@ -95,8 +85,22 @@ local function decode_response(response)
return decoded.data or decoded
end
local function fivemanage_file_base(value)
if type(value) ~= "string" then
return nil
end
local host = value:match("^https://([^/%?#]+)")
host = host and host:lower() or nil
if not host or not allowed_fivemanage_hosts[host] then
return nil
end
return ("https://%s/api/v3/file"):format(host), host
end
local function request_presigned_url()
if not api_configured() then
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
media_debug("Starting FiveManage presigned upload request (api-key=%s).", api_key ~= "" and "present" or "missing")
if api_key == "" then
Bridge.Debug(
"error",
"[sky_phone] FiveManage presigned upload request failed: Config.Media.FiveManage.ApiKey is empty or invalid.",
@@ -109,9 +113,10 @@ local function request_presigned_url()
tostring(config.BaseUrl):gsub("/+$", "") .. "/presigned-url",
"GET",
"",
{ ["Authorization"] = media_api_key() },
{ ["Authorization"] = api_key },
tonumber(config.RequestTimeoutMs) or 10000
)
media_debug("FiveManage presigned upload request returned HTTP %s.", tostring(response.status))
if response.status == 401 or response.status == 403 then
Bridge.Debug(
"error",
@@ -150,40 +155,41 @@ local function request_presigned_url()
)
return nil, "media_provider_failed"
end
local provider_base_url, provider_host = fivemanage_file_base(presigned_url)
if not provider_base_url then
Bridge.Debug(
"error",
"[sky_phone] FiveManage returned a presigned URL on an unexpected host.",
{ always = true }
)
return nil, "media_provider_failed"
end
media_debug("FiveManage returned a valid presigned upload URL (host=%s).", provider_host)
return presigned_url
end
local function get_remote_file(remote_id)
if not api_configured() then
return nil, "missing_config"
local function encode_remote_path(value)
local segments = {}
for segment in tostring(value):gmatch("[^/]+") do
segments[#segments + 1] = SkyPhoneMediaImport.UrlEncode(segment)
end
local config = Config.Media.FiveManage
local response = http_request(
("%s/%s"):format(
tostring(config.BaseUrl):gsub("/+$", ""),
SkyPhoneMediaImport.UrlEncode(remote_id)
),
"GET",
"",
{ ["Authorization"] = media_api_key() },
tonumber(config.RequestTimeoutMs) or 10000
)
return decode_response(response)
return table.concat(segments, "/")
end
local function delete_remote_file(remote_id)
if not api_configured() then
local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey()
if api_key == "" then
return false, "missing_config"
end
local config = Config.Media.FiveManage
local response = http_request(
("%s/%s"):format(
tostring(config.BaseUrl):gsub("/+$", ""),
SkyPhoneMediaImport.UrlEncode(remote_id)
encode_remote_path(remote_id)
),
"DELETE",
"",
{ ["Authorization"] = media_api_key() },
{ ["Authorization"] = api_key },
tonumber(config.RequestTimeoutMs) or 10000
)
if response.status < 200 or response.status >= 300 then
@@ -289,6 +295,14 @@ function SkyPhoneMedia.ResolveOwnedMedia(source, media_id, media_type)
end
local function upload_result(source, correlation_id, success, error_code, media)
media_debug(
"Sending upload result (source=%s, correlation=%s, success=%s, error=%s, media=%s).",
tostring(source),
diagnostic_text(correlation_id, 80),
tostring(success),
diagnostic_text(error_code, 80),
type(media) == "table" and "present" or "missing"
)
TriggerClientEvent("sky_phone:media:upload-result", source, {
correlationId = correlation_id,
success = success,
@@ -315,79 +329,42 @@ local function delete_many_result(source, correlation_id, success, error_code, d
})
end
local function parse_metadata(value)
if type(value) == "table" then
return value
end
if type(value) ~= "string" then
return nil
end
local success, decoded = pcall(json.decode, value)
return success and type(decoded) == "table" and decoded or nil
end
local function valid_remote_id(value)
return type(value) == "string" and #value >= 4 and #value <= 128 and value:match("^[%w_%-]+$") ~= nil
end
local function verify_remote_upload(state, remote_id, uploaded_url)
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048
if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string"
or #uploaded_url > Config.Media.UrlMaxLength
or not uploaded_url:match("^https://")
then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload completion payload is invalid (remote-id=%s, url=%s).",
valid_remote_id(remote_id) and "valid" or "invalid",
type(uploaded_url) == "string" and uploaded_url:match("^https://") and "https" or "invalid"
)
return nil, "invalid_upload"
end
local remote, remote_error = get_remote_file(remote_id)
if not remote then
return nil, remote_error
end
if remote.id ~= remote_id then
local uploaded_host = uploaded_url:match("^https://([^/%?#]+)")
if not uploaded_host or uploaded_host:lower() ~= "r2.fivemanage.com" then
Bridge.Debug(
"error",
"[sky_phone][media-debug] FiveManage upload returned an unexpected media host."
)
return nil, "invalid_upload"
end
if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then
return nil, "invalid_upload"
end
local verified_url = remote.url or uploaded_url
if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength
or not verified_url:match("^https://")
then
return nil, "invalid_upload"
end
local metadata = parse_metadata(remote.metadata)
if not metadata or metadata.captureToken ~= state.capture_token or metadata.source ~= "sky_phone" then
return nil, "invalid_upload_token"
end
if state.purpose and metadata.purpose ~= state.purpose then
return nil, "invalid_upload", true
end
local allowed_mimes = allowed_remote_mimes[state.media_type]
if not allowed_mimes then
return nil, "invalid_media_type", true
end
local remote_mime = tostring(remote.mimeType or ""):lower():match("^%s*([^;%s]+)") or ""
local remote_type = tostring(remote.type or ""):lower():match("^%s*([^;%s]+)") or ""
if remote_mime == "" and allowed_mimes[remote_type] then
remote_mime = remote_type
end
if remote_type == "" then
remote_type = remote_mime
end
if state.media_type == "photo" and remote_type ~= "" and not remote_type:find("image", 1, true) then
return nil, "invalid_media_type", true
end
if state.media_type == "video" and remote_type ~= "" and not remote_type:find("video", 1, true) then
return nil, "invalid_media_type", true
end
if state.media_type == "audio" and remote_type ~= "" and not remote_type:find("audio", 1, true) then
return nil, "invalid_media_type", true
end
if remote_mime ~= "" and not allowed_mimes[remote_mime] then
return nil, "invalid_media_type", true
end
media_debug(
"Accepting the direct FiveManage upload response (type=%s, size=%s).",
tostring(state.media_type),
tostring(state.size_bytes)
)
return {
mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type,
mime_type = state.mime_type,
remote_id = remote_id,
size = tonumber(remote.size),
url = verified_url,
size = state.size_bytes,
url = uploaded_url,
}, nil, true
end
@@ -401,6 +378,12 @@ local function expire_upload(request_id)
return
end
pending_uploads[request_id] = nil
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload session expired before completion (source=%s, correlation=%s).",
tostring(state.source),
diagnostic_text(state.correlation_id, 80)
)
upload_result(state.source, state.correlation_id, false, "upload_timeout")
end
@@ -660,35 +643,81 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
data = type(data) == "table" and data or {}
local correlation_id = data.correlationId
local media_type = data.mediaType
media_debug(
"Server received upload request (source=%s, correlation=%s, type=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
diagnostic_text(media_type, 20)
)
if type(correlation_id) ~= "string" or #correlation_id > 80
or (media_type ~= "photo" and media_type ~= "video")
then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload request validation failed (source=%s, correlation-type=%s, correlation-length=%s, media-type=%s).",
tostring(src),
type(correlation_id),
type(correlation_id) == "string" and tostring(#correlation_id) or "invalid",
diagnostic_text(media_type, 20)
)
upload_result(src, correlation_id, false, "invalid_request")
return
end
if not SkyPhone.AllowOperation(src, "media_write", 20, 60) then
Bridge.Debug(
"warn",
"[sky_phone][media-debug] Upload request was rate limited (source=%s, correlation=%s).",
tostring(src),
diagnostic_text(correlation_id, 80)
)
upload_result(src, correlation_id, false, "rate_limited")
return
end
local owner, error_response = session_owner(src)
if not owner then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload request has no valid phone session (source=%s, correlation=%s, error=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
diagnostic_text(error_response and error_response.error, 80)
)
upload_result(src, correlation_id, false, error_response.error)
return
end
media_debug(
"Upload request session resolved (source=%s, correlation=%s, owner=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
owner.account_id and "account" or "device"
)
local presigned_url, presigned_error = request_presigned_url()
if not presigned_url then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Presigned upload request failed (source=%s, correlation=%s, error=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
diagnostic_text(presigned_error, 80)
)
upload_result(src, correlation_id, false, presigned_error)
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
local request_id = ids[1] and ids[1].request_id
local capture_token = ids[1] and ids[1].capture_token
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
if type(request_id) ~= "string" then
Bridge.Debug(
"error",
"[sky_phone][media-debug] Database did not generate an upload request ID (source=%s, correlation=%s, rows=%s, request-id=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
tostring(type(ids) == "table" and #ids or 0),
type(request_id)
)
upload_result(src, correlation_id, false, "request_failed")
return
end
pending_uploads[request_id] = {
capture_token = capture_token,
correlation_id = correlation_id,
media_type = media_type,
mime_type = media_type == "video" and "video/webm"
@@ -700,8 +729,13 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data)
SetTimeout(tonumber(Config.Media.UploadSessionTimeoutMs) or 60000, function()
expire_upload(request_id)
end)
media_debug(
"Sending upload-ready to client (source=%s, correlation=%s, type=%s).",
tostring(src),
diagnostic_text(correlation_id, 80),
tostring(media_type)
)
TriggerClientEvent("sky_phone:media:upload-ready", src, {
captureToken = capture_token,
correlationId = correlation_id,
mediaType = media_type,
photo = Config.Media.Photo,
@@ -718,18 +752,55 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local request_id = data.requestId
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
Bridge.Debug(
"warn",
"[sky_phone][media-debug] Rejected upload completion (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
tostring(src),
type(request_id) == "string" and "present" or "invalid",
state and "present" or "missing",
tostring(state and state.source == src),
tostring(state and state.completing)
)
return
end
media_debug(
"Server received upload completion (source=%s, correlation=%s, remote-id=%s, url=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
type(data.remoteId) == "string" and "present" or "missing",
type(data.url) == "string" and "present" or "missing"
)
state.completing = true
local owner, error_response = session_owner(src)
if not owner or not owners_match(owner, state.owner) then
pending_uploads[request_id] = nil
Bridge.Debug(
"error",
"[sky_phone][media-debug] Upload owner changed before completion (source=%s, correlation=%s, session=%s, owner-match=%s, error=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
owner and "present" or "missing",
tostring(owner and owners_match(owner, state.owner)),
diagnostic_text(error_response and error_response.error, 80)
)
upload_result(src, state.correlation_id, false, error_response and error_response.error or "owner_changed")
return
end
local verified, verify_error, trusted_remote = verify_remote_upload(state, data.remoteId, data.url)
local verified, verify_error, trusted_remote = verify_remote_upload(
state,
data.remoteId,
data.url
)
if not verified then
pending_uploads[request_id] = nil
Bridge.Debug(
"error",
"[sky_phone][media-debug] Uploaded file verification failed (source=%s, correlation=%s, error=%s, trusted-remote=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
diagnostic_text(verify_error, 80),
tostring(trusted_remote)
)
if trusted_remote then
local deleted, delete_error = delete_remote_file(data.remoteId)
if not deleted then
@@ -760,9 +831,22 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data)
local media_id = type(result) == "number" and result or (type(result) == "table" and tonumber(result.insertId))
if not media_id then
delete_remote_file(verified.remote_id)
Bridge.Debug(
"error",
"[sky_phone][media-debug] Database insert did not return a media ID (source=%s, correlation=%s, result-type=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
type(result)
)
upload_result(src, state.correlation_id, false, "request_failed")
return
end
media_debug(
"Media upload completed successfully (source=%s, correlation=%s, media-id=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
tostring(media_id)
)
upload_result(src, state.correlation_id, true, nil, {
id = media_id,
url = verified.url,
@@ -787,6 +871,15 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
local request_id = type(data) == "table" and data.requestId or nil
local state = type(request_id) == "string" and pending_uploads[request_id] or nil
if not state or state.source ~= src or state.completing then
Bridge.Debug(
"warn",
"[sky_phone][media-debug] Rejected client upload failure report (source=%s, request-id=%s, session=%s, owner-match=%s, completing=%s).",
tostring(src),
type(request_id) == "string" and "present" or "invalid",
state and "present" or "missing",
tostring(state and state.source == src),
tostring(state and state.completing)
)
return
end
local allowed_errors = {
@@ -797,6 +890,16 @@ RegisterNetEvent("sky_phone:media:fail-upload", function(data)
}
pending_uploads[request_id] = nil
local error_code = allowed_errors[data.error] and data.error or "upload_failed"
Bridge.Debug(
"error",
"[sky_phone][media-debug] Client-reported upload failure (source=%s, correlation=%s, error=%s, stage=%s, status=%s, detail=%s).",
tostring(src),
diagnostic_text(state.correlation_id, 80),
diagnostic_text(error_code, 80),
diagnostic_text(data.debugStage, 40),
diagnostic_text(data.debugStatus, 20),
diagnostic_text(data.debugMessage, 240)
)
upload_result(src, state.correlation_id, false, error_code)
end)
@@ -981,7 +1084,7 @@ AddEventHandler("playerDropped", function()
end
end)
if not api_configured() then
if SkyPhoneMediaProviderConfig.FiveManageApiKey() == "" then
Bridge.Debug(
"warn",
"[sky_phone] FiveManage media integration is disabled because Config.Media.FiveManage.ApiKey is empty in config/media.lua. Camera photo and video uploads, Voice Memo uploads, remote Gallery deletion, and FiveManage imports are unavailable. Add a FiveManage V3 token with Media access and restart sky_phone.",
@@ -1,9 +1,5 @@
local function api_key(website)
local configured_key = website.ApiKey or Config.Media.FiveManage.ApiKey
if type(configured_key) ~= "string" then
return ""
end
return configured_key:match("^%s*(.-)%s*$")
return SkyPhoneMediaProviderConfig.FiveManageApiKey(website.ApiKey)
end
local function provider_error(response, not_found_error)
@@ -0,0 +1,17 @@
SkyPhoneMediaProviderConfig = {}
local function trim_key(value)
if type(value) ~= "string" then
return ""
end
return value:match("^%s*(.-)%s*$")
end
function SkyPhoneMediaProviderConfig.FiveManageApiKey(override_key)
local api_key = trim_key(override_key)
if api_key ~= "" then
return api_key
end
return trim_key(Config.Media.FiveManage.ApiKey)
end
+18 -9
View File
@@ -170,6 +170,7 @@ local function validate_upload(data)
local title_length = title and utf8.len(title) or nil
local note_length = note and utf8.len(note) or nil
local duration_ms = tonumber(data.durationMs)
local size_bytes = tonumber(data.sizeBytes)
local normalized_mime = allowed_audio_mimes[data.mimeType]
local waveform = normalize_waveform(data.waveform)
if not title_length or title_length < 1 or title_length > Config.Memos.TitleMaxLength
@@ -177,6 +178,8 @@ local function validate_upload(data)
or not duration_ms or duration_ms ~= duration_ms
or duration_ms == math.huge or duration_ms == -math.huge
or duration_ms < 300 or duration_ms > Config.Memos.MaximumDurationMs
or not size_bytes or size_bytes ~= math.floor(size_bytes)
or size_bytes < 1 or size_bytes > Config.Memos.MaximumBytes
or not normalized_mime or not waveform or type(data.pinned) ~= "boolean"
then
return nil
@@ -188,6 +191,7 @@ local function validate_upload(data)
mime_type = normalized_mime,
note = note,
pinned = data.pinned,
size_bytes = size_bytes,
title = title,
waveform = waveform,
}
@@ -373,20 +377,18 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
upload_result(src, memo.correlation_id, false, presigned_error)
return
end
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`, UUID() AS `capture_token`", {})
local ids = Bridge.Database.Query("SELECT UUID() AS `request_id`", {})
local request_id = ids[1] and ids[1].request_id
local capture_token = ids[1] and ids[1].capture_token
if type(request_id) ~= "string" or type(capture_token) ~= "string" then
error("[sky_phone] Database did not generate a voice memo upload token.")
if type(request_id) ~= "string" then
error("[sky_phone] Database did not generate a voice memo upload request ID.")
end
pending_uploads[request_id] = {
capture_token = capture_token,
media_type = "audio",
mime_type = memo.mime_type,
memo = memo,
owner = owner,
owner_key = pending_owner_key,
purpose = "memo",
size_bytes = memo.size_bytes,
source = src,
}
SetTimeout(Config.Memos.UploadSessionTimeoutMs, function()
@@ -395,7 +397,6 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data)
TriggerClientEvent("sky_phone:memos:upload-ready", src, {
requestId = request_id,
correlationId = memo.correlation_id,
captureToken = capture_token,
presignedUrl = presigned_url,
uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs,
})
@@ -412,14 +413,22 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data)
local owner, error_response = device_owner(src, state.owner.imei)
if not owner or owner.imei ~= state.owner.imei or owner.account_id ~= state.owner.account_id then
pending_uploads[request_id] = nil
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
state,
data.remoteId,
data.url
)
if rejected or trusted_remote then
discard_verified_upload(data.remoteId)
end
upload_result(src, state.memo.correlation_id, false, error_response and error_response.error or "owner_changed")
return
end
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(state, data.remoteId, data.url)
local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload(
state,
data.remoteId,
data.url
)
if not verified then
pending_uploads[request_id] = nil
if trusted_remote then