From fe38dc6efaaaa77fafcf7a1cba883a7aa1ea2b07 Mon Sep 17 00:00:00 2001 From: "Leon.Schmidt" Date: Fri, 21 Aug 2026 00:57:57 +0200 Subject: [PATCH] FIX - match FiveManage memo upload flow --- frontend/src/components/PhoneMemoRecorder.vue | 14 +- .../src/mediaConfig.server.contract.test.ts | 54 ++-- frontend/src/types/memos.ts | 2 - sky_phone/source/server/media.lua | 273 +----------------- sky_phone/source/server/memos.lua | 26 +- 5 files changed, 48 insertions(+), 321 deletions(-) diff --git a/frontend/src/components/PhoneMemoRecorder.vue b/frontend/src/components/PhoneMemoRecorder.vue index 4972797..7c3e6b4 100644 --- a/frontend/src/components/PhoneMemoRecorder.vue +++ b/frontend/src/components/PhoneMemoRecorder.vue @@ -365,6 +365,7 @@ async function stopRecording(data: Record): Promise { mimeType, note: finalMetadata.note, pinned: finalMetadata.pinned, + sizeBytes: blob.size, title: finalMetadata.title, waveform, } @@ -457,16 +458,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', - JSON.stringify({ - captureToken: ready.captureToken, - purpose: 'memo', - source: 'sky_phone', - }), - ) const controller = new AbortController() pending.abortController = controller const timeout = window.setTimeout( @@ -480,9 +472,8 @@ async function uploadReady(ready: MemoUploadReady): Promise { signal: controller.signal, }) const body = (await response.json()) as { - data?: { id?: string; originalUrl?: string; url?: string } + data?: { id?: string; url?: string } id?: string - originalUrl?: string url?: string } const uploaded = body.data ?? body @@ -490,7 +481,6 @@ 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 index 91fc3f0..3e09ed2 100644 --- a/frontend/src/mediaConfig.server.contract.test.ts +++ b/frontend/src/mediaConfig.server.contract.test.ts @@ -62,58 +62,42 @@ describe('FiveManage server configuration contract', () => { ) }) - it('uses the direct FiveManage upload response flow for Camera media', () => { + 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(mediaServer).toContain('if state.media_type ~= "audio" then') + 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 camera upload response', + '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('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(memoRecorder).toContain("form.append('path', ready.uploadPath)") - 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', () => { + it('allowlists the FiveManage API and media hosts', () => { expect(mediaServer).toContain('["api.fivemanage.com"] = true') expect(mediaServer).toContain('["fmapi.net"] = true') - expect(memoServer).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.', + 'uploaded_host:lower() ~= "r2.fivemanage.com"', ) }) - it('binds an unindexed upload to the server path before probing R2', () => { - expect(mediaServer).toContain('host:lower() ~= "r2.fivemanage.com"') - expect(mediaServer).toContain( - 'path:find("/" .. state.upload_path .. "/", 1, true)', + 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(mediaServer).toContain('local storage_key = path:sub(2)') - expect(mediaServer).toContain('}, nil, storage_key') - expect(mediaServer).toContain('"HEAD"') - expect(mediaServer).toContain( - 'SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type")', - ) - expect(mediaServer).toContain( - 'SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length")', + 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') }) }) diff --git a/frontend/src/types/memos.ts b/frontend/src/types/memos.ts index 4f99272..6b1894d 100644 --- a/frontend/src/types/memos.ts +++ b/frontend/src/types/memos.ts @@ -39,9 +39,7 @@ export type MemoRecordingMetadata = { export type MemoUploadReady = { requestId: string correlationId: string - captureToken: string presignedUrl: string - uploadPath: string uploadTimeoutMs?: number } diff --git a/sky_phone/source/server/media.lua b/sky_phone/source/server/media.lua index 72d1611..004dfa2 100644 --- a/sky_phone/source/server/media.lua +++ b/sky_phone/source/server/media.lua @@ -4,21 +4,6 @@ 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, @@ -180,7 +165,7 @@ local function request_presigned_url() 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 + return presigned_url end local function encode_remote_path(value) @@ -191,130 +176,6 @@ local function encode_remote_path(value) return table.concat(segments, "/") end -local function probe_uploaded_object(state, remote_id, uploaded_url) - if type(uploaded_url) ~= "string" or #uploaded_url > Config.Media.UrlMaxLength then - return nil, "invalid_upload" - end - local host, path = uploaded_url:match("^https://([^/%?#]+)(/[^?#]+)$") - if not host or host:lower() ~= "r2.fivemanage.com" or path:find("%", 1, true) then - return nil, "invalid_upload" - end - if not path:find("/" .. state.upload_path .. "/", 1, true) then - Bridge.Debug("error", "[sky_phone][media-debug] FiveManage upload URL is not bound to the server upload path.") - return nil, "invalid_upload_token" - end - local storage_key = path:sub(2) - if #storage_key > 128 or storage_key:find("//", 1, true) - or not storage_key:match("^[%w%._%-%/]+$") - then - Bridge.Debug("error", "[sky_phone][media-debug] FiveManage upload URL contains an invalid storage key.") - return nil, "invalid_upload" - end - - local response = SkyPhoneMediaImport.HttpRequest( - uploaded_url, - {}, - tonumber(Config.Media.FiveManage.RequestTimeoutMs) or 10000, - "HEAD" - ) - media_debug("FiveManage bound object HEAD probe returned HTTP %s.", tostring(response.status)) - if response.status == 0 then - return nil, "request_timeout" - end - if response.status < 200 or response.status >= 300 then - return nil, ("request_failed_%s"):format(response.status) - end - - local mime_type = SkyPhoneMediaImport.ResponseHeader(response.headers, "content-type") - mime_type = type(mime_type) == "string" and mime_type:lower():match("^%s*([^;%s]+)") or nil - local allowed_mimes = allowed_remote_mimes[state.media_type] - if not mime_type or not allowed_mimes or not allowed_mimes[mime_type] then - return nil, "invalid_media_type" - end - local size = tonumber(SkyPhoneMediaImport.ResponseHeader(response.headers, "content-length")) - local maximum_size = state.media_type == "photo" and tonumber(Config.Media.Import.MaxPhotoBytes) - or state.media_type == "video" and tonumber(Config.Media.Import.MaxVideoBytes) - or state.media_type == "audio" and tonumber(Config.Memos.MaximumBytes) - if not size or size ~= math.floor(size) or size < 1 or not maximum_size or size > maximum_size then - return nil, "invalid_upload" - end - - media_debug( - "FiveManage capability-bound object verification succeeded (type=%s, mime=%s, size=%s).", - tostring(state.media_type), - mime_type, - tostring(size) - ) - return { - id = remote_id, - metadata = { - captureToken = state.capture_token, - purpose = state.purpose, - source = "sky_phone", - }, - mimeType = mime_type, - size = size, - type = mime_type, - url = uploaded_url, - }, nil, storage_key -end - -local function get_remote_file(state, remote_id, uploaded_url) - 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 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" - local authenticated_index_checked = false - 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 - authenticated_index_checked = true - 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 - if not authenticated_index_checked then - return nil, last_error - end - media_debug("FiveManage authenticated index omitted the uploaded file; verifying the bound storage object.") - return probe_uploaded_object(state, remote_id, uploaded_url) -end - local function delete_remote_file(remote_id) local api_key = SkyPhoneMediaProviderConfig.FiveManageApiKey() if api_key == "" then @@ -468,29 +329,14 @@ 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, original_url) - if not valid_remote_id(remote_id) or type(uploaded_url) ~= "string" or #uploaded_url > 2048 +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 > Config.Media.UrlMaxLength 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", @@ -501,109 +347,24 @@ 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 + 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 metadata verification failed: %s.", - diagnostic_text(remote_error, 120) + "[sky_phone][media-debug] FiveManage upload returned an unexpected media host." ) - 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 - 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 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 - 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 - 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).", + "Accepting the direct FiveManage upload response (type=%s, size=%s).", tostring(state.media_type), - diagnostic_text(remote_mime, 80), - tostring(remote.size) + tostring(state.size_bytes) ) return { - mime_type = allowed_mimes[remote_mime] and remote_mime or state.mime_type, - remote_id = remote_path, - size = tonumber(remote.size), - url = verified_url, + mime_type = state.mime_type, + remote_id = remote_id, + size = state.size_bytes, + url = uploaded_url, }, nil, true end @@ -1003,12 +764,11 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data) return end media_debug( - "Server received upload completion (source=%s, correlation=%s, remote-id=%s, url=%s, original-url=%s).", + "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", - type(data.originalUrl) == "string" and "present" or "missing" + type(data.url) == "string" and "present" or "missing" ) state.completing = true local owner, error_response = session_owner(src) @@ -1029,8 +789,7 @@ RegisterNetEvent("sky_phone:media:complete-upload", function(data) local verified, verify_error, trusted_remote = verify_remote_upload( state, data.remoteId, - data.url, - data.originalUrl + data.url ) if not verified then pending_uploads[request_id] = nil diff --git a/sky_phone/source/server/memos.lua b/sky_phone/source/server/memos.lua index 45668a2..ca14dcf 100644 --- a/sky_phone/source/server/memos.lua +++ b/sky_phone/source/server/memos.lua @@ -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, } @@ -361,7 +365,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, provider_base_url = SkyPhoneMedia.RequestPresignedUrl() + local presigned_url, presigned_error = SkyPhoneMedia.RequestPresignedUrl() if not presigned_url then Bridge.Debug( "error", @@ -373,23 +377,19 @@ 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, - provider_base_url = provider_base_url, - purpose = "memo", + size_bytes = memo.size_bytes, source = src, - upload_path = "sky_phone-" .. capture_token, } SetTimeout(Config.Memos.UploadSessionTimeoutMs, function() expire_upload(request_id) @@ -397,9 +397,7 @@ 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, - uploadPath = pending_uploads[request_id].upload_path, uploadTimeoutMs = Config.Media.FiveManage.UploadTimeoutMs, }) end) @@ -418,8 +416,7 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data) local rejected, _, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload( state, data.remoteId, - data.url, - data.originalUrl + data.url ) if rejected or trusted_remote then discard_verified_upload(data.remoteId) @@ -430,8 +427,7 @@ RegisterNetEvent("sky_phone:memos:complete-upload", function(data) local verified, verify_error, trusted_remote = SkyPhoneMedia.VerifyRemoteUpload( state, data.remoteId, - data.url, - data.originalUrl + data.url ) if not verified then pending_uploads[request_id] = nil