diff --git a/frontend/src/components/PhoneMediaCapture.vue b/frontend/src/components/PhoneMediaCapture.vue index 6f6feef..24ee5af 100644 --- a/frontend/src/components/PhoneMediaCapture.vue +++ b/frontend/src/components/PhoneMediaCapture.vue @@ -484,12 +484,7 @@ async function uploadReady(ready: UploadReady): Promise { }) const form = new FormData() - form.append('path', ready.uploadPath) 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(), @@ -511,11 +506,10 @@ async function uploadReady(ready: UploadReady): Promise { }) const text = await response.text() const body = JSON.parse(text) as { - data?: { id?: string; originalUrl?: string; url?: string } + data?: { id?: string; url?: string } error?: string id?: string message?: string - originalUrl?: string url?: string } const uploaded = body.data ?? body @@ -529,7 +523,6 @@ async function uploadReady(ready: UploadReady): Promise { debugStage = 'completion_callback' const completion = await nuiCall('media:completeUpload', { correlationId: ready.correlationId, - 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 index 6457255..91fc3f0 100644 --- a/frontend/src/mediaConfig.server.contract.test.ts +++ b/frontend/src/mediaConfig.server.contract.test.ts @@ -28,6 +28,10 @@ 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', @@ -58,15 +62,25 @@ describe('FiveManage server configuration contract', () => { ) }) - it('resolves uploads through their server-generated FiveManage path', () => { + it('uses the direct FiveManage upload response flow for Camera media', () => { expect(mediaConfig).not.toContain('VerificationRetryDelaysMs') - expect(mediaServer).toContain('upload_path = "sky_phone-" .. capture_token') + expect(mediaCapture).toContain("form.append('file', blob, fileName)") + expect(mediaCapture).not.toContain("form.append('path'") + expect(mediaCapture).not.toContain("form.append(\n 'metadata'") + expect(mediaServer).toContain('if state.media_type ~= "audio" then') + expect(mediaServer).toContain( + 'Accepting the direct FiveManage camera upload response', + ) + expect(mediaServer).toContain('remote_id = remote_id') + expect(mediaServer).toContain('url = uploaded_url') + }) + + it('keeps server-generated upload path verification for voice memos', () => { + expect(memoServer).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', @@ -76,7 +90,7 @@ describe('FiveManage server configuration contract', () => { 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(memoServer).toContain('provider_base_url = provider_base_url') expect(mediaServer).toContain('state.provider_base_url') }) diff --git a/frontend/src/types/media.ts b/frontend/src/types/media.ts index 59c8ab0..bca7d7e 100644 --- a/frontend/src/types/media.ts +++ b/frontend/src/types/media.ts @@ -70,7 +70,6 @@ export type MediaImportResult = { } export type UploadReady = { - captureToken: string correlationId: string mediaType: MediaType photo?: { @@ -79,7 +78,6 @@ export type UploadReady = { } presignedUrl: string requestId: string - uploadPath: string uploadTimeoutMs?: number video?: { BitrateKbps?: number diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index 464eeda..72d1611 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -500,6 +500,27 @@ local function verify_remote_upload(state, remote_id, uploaded_url, original_url ) return nil, "invalid_upload" end + + if state.media_type ~= "audio" 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 camera upload returned an unexpected media host." + ) + return nil, "invalid_upload" + end + media_debug( + "Accepting the direct FiveManage camera upload response (type=%s).", + tostring(state.media_type) + ) + return { + mime_type = state.mime_type, + remote_id = remote_id, + url = uploaded_url, + }, nil, true + end + 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, uploaded_url) if not remote then @@ -909,7 +930,7 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data) diagnostic_text(correlation_id, 80), owner.account_id and "account" or "device" ) - local presigned_url, presigned_error, provider_base_url = request_presigned_url() + local presigned_url, presigned_error = request_presigned_url() if not presigned_url then Bridge.Debug( "error", @@ -921,33 +942,28 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data) 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 upload session identifiers (source=%s, correlation=%s, rows=%s, request-id=%s, capture-token=%s).", + "[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), - type(capture_token) + 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" 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) @@ -959,13 +975,11 @@ RegisterNetEvent("sky_phone:media:request-upload", function(data) tostring(media_type) ) TriggerClientEvent("sky_phone:media:upload-ready", src, { - captureToken = capture_token, correlationId = correlation_id, mediaType = media_type, 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, })