mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
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:
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
|
||||
@@ -70,7 +70,6 @@ export type MediaImportResult = {
|
||||
}
|
||||
|
||||
export type UploadReady = {
|
||||
captureToken: string
|
||||
correlationId: string
|
||||
mediaType: MediaType
|
||||
photo?: {
|
||||
|
||||
@@ -39,7 +39,6 @@ export type MemoRecordingMetadata = {
|
||||
export type MemoUploadReady = {
|
||||
requestId: string
|
||||
correlationId: string
|
||||
captureToken: string
|
||||
presignedUrl: string
|
||||
uploadTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user