From d32e6b5584e9db6ce6444d9c85412ef8d3747e4c Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Fri, 21 Aug 2026 00:23:28 +0200 Subject: [PATCH] TRY - isolate FiveManage media upload verification --- frontend/src/components/PhoneMediaCapture.vue | 104 +++++- frontend/src/components/PhoneMemoRecorder.vue | 5 +- .../src/mediaConfig.server.contract.test.ts | 89 +++++ .../src/mediaUploadDebug.contract.test.ts | 21 ++ frontend/src/stores/phone.ts | 5 + frontend/src/types/media.ts | 1 + frontend/src/types/memos.ts | 1 + frontend/src/utils/media.test.ts | 7 + frontend/src/utils/media.ts | 3 + frontend/src/views/apps/CameraApp.vue | 27 +- sky_phone/config/locales/de.lua | 4 +- sky_phone/config/locales/en.lua | 4 +- sky_phone/fxmanifest.lua | 1 + sky_phone/source/client/camera.lua | 42 +++ sky_phone/source/server/media.lua | 334 ++++++++++++++++-- .../source/server/media_import/fivemanage.lua | 6 +- .../source/server/media_provider_config.lua | 17 + sky_phone/source/server/memos.lua | 19 +- 18 files changed, 628 insertions(+), 62 deletions(-) create mode 100644 frontend/src/mediaConfig.server.contract.test.ts create mode 100644 frontend/src/mediaUploadDebug.contract.test.ts create mode 100644 sky_phone/source/server/media_provider_config.lua diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index 6b63411..6f6feef 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -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(null) const pendingVideos = new Map() @@ -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): Promise { 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 { } } -async function failUpload(requestId: string, error: string): Promise { - await nuiCall('media:failUpload', { error, requestId }) +async function failUpload( + requestId: string, + error: string, + debug: UploadFailureDebug, +): Promise { + 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 { @@ -435,12 +467,24 @@ async function uploadReady(ready: UploadReady): Promise { 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('path', ready.uploadPath) form.append('file', blob, fileName) form.append( 'metadata', @@ -451,33 +495,66 @@ async function uploadReady(ready: UploadReady): Promise { () => 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 } + data?: { id?: string; originalUrl?: string; url?: string } + error?: string id?: string + message?: string + originalUrl?: 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, + originalUrl: uploaded.originalUrl, 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 +600,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) diff --git a/frontend/src/components/PhoneMemoRecorder.vue b/frontend/src/components/PhoneMemoRecorder.vue index fd50ad2..4972797 100644 --- a/frontend/src/components/PhoneMemoRecorder.vue +++ b/frontend/src/components/PhoneMemoRecorder.vue @@ -457,6 +457,7 @@ async function uploadReady(ready: MemoUploadReady): Promise { } pending.requestId = ready.requestId const form = new FormData() + form.append('path', ready.uploadPath) form.append('file', pending.blob, pending.fileName) form.append( 'metadata', @@ -479,8 +480,9 @@ async function uploadReady(ready: MemoUploadReady): Promise { signal: controller.signal, }) const body = (await response.json()) as { - data?: { id?: string; url?: string } + data?: { id?: string; originalUrl?: string; url?: string } id?: string + originalUrl?: string url?: string } const uploaded = body.data ?? body @@ -488,6 +490,7 @@ async function uploadReady(ready: MemoUploadReady): Promise { throw new Error('upload_failed') } const complete = await nuiCall('memos:completeUpload', { + originalUrl: uploaded.originalUrl, remoteId: uploaded.id, requestId: ready.requestId, url: uploaded.url, diff --git a/frontend/src/mediaConfig.server.contract.test.ts b/frontend/src/mediaConfig.server.contract.test.ts new file mode 100644 index 0000000..78c4a5c --- /dev/null +++ b/frontend/src/mediaConfig.server.contract.test.ts @@ -0,0 +1,89 @@ +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 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('resolves uploads through their server-generated FiveManage path', () => { + expect(mediaConfig).not.toContain('VerificationRetryDelaysMs') + expect(mediaServer).toContain('upload_path = "sky_phone-" .. capture_token') + expect(mediaServer).toContain( + '"?limit=100&page=1&path=" .. SkyPhoneMediaImport.UrlEncode(state.upload_path)', + ) + expect(mediaCapture).toContain("form.append('path', ready.uploadPath)") + expect(memoRecorder).toContain("form.append('path', ready.uploadPath)") + expect(mediaCapture).toContain('originalUrl: uploaded.originalUrl') + expect(memoRecorder).toContain('originalUrl: uploaded.originalUrl') + expect(mediaServer).toContain( + 'local verified_url = remote.url or remote.originalUrl', + ) + }) + + it('binds metadata verification to the FiveManage host that issued the upload URL', () => { + expect(mediaServer).toContain('["api.fivemanage.com"] = true') + expect(mediaServer).toContain('["fmapi.net"] = true') + expect(mediaServer).toContain('provider_base_url = provider_base_url') + expect(mediaServer).toContain('state.provider_base_url') + }) + + it('authenticates the exact returned ID from the filtered file list', () => { + expect(mediaServer).toContain('remote.id == remote_id') + expect(mediaServer).toContain( + 'FiveManage upload-path lookup found the exact uploaded file ID.', + ) + }) +}) diff --git a/frontend/src/mediaUploadDebug.contract.test.ts b/frontend/src/mediaUploadDebug.contract.test.ts new file mode 100644 index 0000000..3ed689d --- /dev/null +++ b/frontend/src/mediaUploadDebug.contract.test.ts @@ -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)') + }) +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0d3ce1b..a2ee63a 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -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.', diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts index 499ea73..59c8ab0 100644 --- a/frontend/src/types/media.ts +++ b/frontend/src/types/media.ts @@ -79,6 +79,7 @@ export type UploadReady = { } presignedUrl: string requestId: string + uploadPath: string uploadTimeoutMs?: number video?: { BitrateKbps?: number diff --git a/frontend/src/types/memos.ts b/frontend/src/types/memos.ts index 30dccab..4f99272 100644 --- a/frontend/src/types/memos.ts +++ b/frontend/src/types/memos.ts @@ -41,6 +41,7 @@ export type MemoUploadReady = { correlationId: string captureToken: string presignedUrl: string + uploadPath: string uploadTimeoutMs?: number } diff --git a/frontend/src/utils/media.test.ts b/frontend/src/utils/media.test.ts index 9dfd136..a0cc77c 100644 --- a/frontend/src/utils/media.test.ts +++ b/frontend/src/utils/media.test.ts @@ -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') }) }) diff --git a/frontend/src/utils/media.ts b/frontend/src/utils/media.ts index 2ec67c6..d5dd01a 100644 --- a/frontend/src/utils/media.ts +++ b/frontend/src/utils/media.ts @@ -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', diff --git a/frontend/src/views/apps/CameraApp.vue b/frontend/src/views/apps/CameraApp.vue index 1b10071..d260274 100644 --- a/frontend/src/views/apps/CameraApp.vue +++ b/frontend/src/views/apps/CameraApp.vue @@ -134,10 +134,30 @@ async function requestPhoto(): Promise { 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 diff --git a/sky_phone/config/locales/de.lua b/sky_phone/config/locales/de.lua index 2b035d8..d2bde7f 100644 --- a/sky_phone/config/locales/de.lua +++ b/sky_phone/config/locales/de.lua @@ -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.", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index d5b8f70..6a83388 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -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.", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index cf67680..5148eb6 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -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', diff --git a/sky_phone/source/client/camera.lua b/sky_phone/source/client/camera.lua index c2899c7..afb372e 100644 --- a/sky_phone/source/client/camera.lua +++ b/sky_phone/source/client/camera.lua @@ -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) diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index e2b54ba..e96e1e1 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -19,21 +19,19 @@ local allowed_remote_mimes = { ["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 +63,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 +76,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 +100,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 +128,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 +170,91 @@ local function request_presigned_url() ) return nil, "media_provider_failed" end - return presigned_url + 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, nil, provider_base_url end -local function get_remote_file(remote_id) - if not api_configured() then +local function encode_remote_path(value) + local segments = {} + for segment in tostring(value):gmatch("[^/]+") do + segments[#segments + 1] = SkyPhoneMediaImport.UrlEncode(segment) + end + return table.concat(segments, "/") +end + +local function get_remote_file(state, remote_id) + local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey() + if api_key == "" then return nil, "missing_config" end + if type(state.upload_path) ~= "string" or not state.upload_path:match("^sky_phone%-%x[%x%-]+$") then + return nil, "invalid_upload" + 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) + local configured_base_url = tostring(config.BaseUrl):gsub("/+$", "") + local base_urls = {} + if state.provider_base_url then + base_urls[#base_urls + 1] = state.provider_base_url + end + if configured_base_url ~= state.provider_base_url then + base_urls[#base_urls + 1] = configured_base_url + end + local last_error = "request_failed_404" + for _, base_url in ipairs(base_urls) do + local provider_host = base_url:match("^https://([^/]+)") or "invalid" + local response = http_request( + base_url .. "?limit=100&page=1&path=" .. SkyPhoneMediaImport.UrlEncode(state.upload_path), + "GET", + "", + { ["Authorization"] = api_key }, + tonumber(config.RequestTimeoutMs) or 10000 + ) + local files, response_error = decode_response(response) + media_debug( + "FiveManage authenticated upload-path lookup via %s returned HTTP %s (records=%s, error=%s).", + provider_host, + tostring(response.status), + type(files) == "table" and tostring(#files) or "invalid", + diagnostic_text(response_error_message(response), 160) + ) + if files then + for _, remote in ipairs(files) do + if type(remote) == "table" and remote.id == remote_id then + media_debug("FiveManage upload-path lookup found the exact uploaded file ID.") + return remote, nil, remote_id + end + end + last_error = "request_failed_404" + else + last_error = response_error + end + end + return nil, last_error 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 +360,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, @@ -330,30 +409,59 @@ 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) +local function verify_remote_upload(state, remote_id, uploaded_url, original_url) if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048 or not uploaded_url:match("^https://") + or (original_url ~= nil and ( + type(original_url) ~= "string" + or #original_url > Config.Media.UrlMaxLength + or not original_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) + media_debug("Verifying uploaded file with FiveManage (type=%s).", tostring(state.media_type)) + local remote, remote_error, remote_path = get_remote_file(state, remote_id) if not remote then + Bridge.Debug( + "error", + "[sky_phone][media-debug] FiveManage metadata verification failed: %s.", + diagnostic_text(remote_error, 120) + ) return nil, remote_error end if remote.id ~= remote_id then + Bridge.Debug("error", "[sky_phone][media-debug] FiveManage returned a different remote file ID.") return nil, "invalid_upload" end - if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url then + if remote.url ~= uploaded_url and remote.originalUrl ~= uploaded_url + and remote.url ~= original_url and remote.originalUrl ~= original_url + then + Bridge.Debug("error", "[sky_phone][media-debug] FiveManage returned a different remote file URL.") return nil, "invalid_upload" end - local verified_url = remote.url or uploaded_url + local verified_url = remote.url or remote.originalUrl if type(verified_url) ~= "string" or #verified_url > Config.Media.UrlMaxLength or not verified_url:match("^https://") then + Bridge.Debug("error", "[sky_phone][media-debug] FiveManage returned an invalid verified media URL.") 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 + Bridge.Debug( + "error", + "[sky_phone][media-debug] FiveManage metadata did not preserve the capture token (metadata=%s, token-match=%s, source-match=%s).", + metadata and "present" or "missing", + tostring(metadata and metadata.captureToken == state.capture_token), + tostring(metadata and metadata.source == "sky_phone") + ) return nil, "invalid_upload_token" end if state.purpose and metadata.purpose ~= state.purpose then @@ -381,11 +489,24 @@ local function verify_remote_upload(state, remote_id, uploaded_url) return nil, "invalid_media_type", true end if remote_mime ~= "" and not allowed_mimes[remote_mime] then + Bridge.Debug( + "error", + "[sky_phone][media-debug] FiveManage returned unsupported media metadata (type=%s, mime=%s, expected=%s).", + diagnostic_text(remote_type, 80), + diagnostic_text(remote_mime, 80), + tostring(state.media_type) + ) return nil, "invalid_media_type", true end + media_debug( + "FiveManage upload verification succeeded (type=%s, mime=%s, size=%s).", + tostring(state.media_type), + diagnostic_text(remote_mime, 80), + tostring(remote.size) + ) return { mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type, - remote_id = remote_id, + remote_id = remote_path, size = tonumber(remote.size), url = verified_url, }, nil, true @@ -401,6 +522,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,23 +787,63 @@ 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 - local presigned_url, presigned_error = request_presigned_url() + 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, provider_base_url = 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 @@ -684,6 +851,15 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data) 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 + Bridge.Debug( + "error", + "[sky_phone][media-debug] Database did not generate upload session identifiers (source=%s, correlation=%s, rows=%s, request-id=%s, capture-token=%s).", + tostring(src), + diagnostic_text(correlation_id, 80), + tostring(type(ids) == "table" and #ids or 0), + type(request_id), + type(capture_token) + ) upload_result(src, correlation_id, false, "request_failed") return end @@ -695,11 +871,19 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data) or ({ png = "image/png", webp = "image/webp" })[tostring(Config.Media.Photo.Encoding):lower()] or "image/jpeg", owner = owner, + provider_base_url = provider_base_url, source = src, + upload_path = "sky_phone-" .. capture_token, } 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, @@ -707,6 +891,7 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data) photo = Config.Media.Photo, presignedUrl = presigned_url, requestId = request_id, + uploadPath = pending_uploads[request_id].upload_path, uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs, video = Config.Media.Video, }) @@ -718,18 +903,57 @@ 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, original-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", + type(data.originalUrl) == "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, + data.originalUrl + ) 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 +984,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 +1024,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 +1043,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 +1237,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.", diff --git a/sky_phone/source/server/media_import/fivemanage.lua b/sky_phone/source/server/media_import/fivemanage.lua index 216c9cc..8beef72 100644 --- a/sky_phone/source/server/media_import/fivemanage.lua +++ b/sky_phone/source/server/media_import/fivemanage.lua @@ -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) diff --git a/sky_phone/source/server/media_provider_config.lua b/sky_phone/source/server/media_provider_config.lua new file mode 100644 index 0000000..cfeec89 --- /dev/null +++ b/sky_phone/source/server/media_provider_config.lua @@ -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 diff --git a/sky_phone/source/server/memos.lua b/sky_phone/source/server/memos.lua index c31084b..45668a2 100644 --- a/sky_phone/source/server/memos.lua +++ b/sky_phone/source/server/memos.lua @@ -361,7 +361,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data) upload_result(src, memo.correlation_id, false, "operation_in_progress") return end - local presigned_url, presigned_error = SkyPhoneMedia.RequestPresignedUrl() + local presigned_url, presigned_error, provider_base_url = SkyPhoneMedia.RequestPresignedUrl() if not presigned_url then Bridge.Debug( "error", @@ -386,8 +386,10 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data) memo = memo, owner = owner, owner_key = pending_owner_key, + provider_base_url = provider_base_url, purpose = "memo", source = src, + upload_path = "sky_phone-" .. capture_token, } SetTimeout(Config.Memos.UploadSessionTimeoutMs, function() expire_upload(request_id) @@ -397,6 +399,7 @@ RegisterNetEvent("sky_phone:memos:request-upload", function(data) correlationId = memo.correlation_id, captureToken = capture_token, presignedUrl = presigned_url, + uploadPath = pending_uploads[request_id].upload_path, uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs, }) end) @@ -412,14 +415,24 @@ 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, + data.originalUrl + ) 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, + data.originalUrl + ) if not verified then pending_uploads[request_id] = nil if trusted_remote then