fix: harden desktop release OSS uploads
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import { createMD5, createSHA256 } from 'hash-wasm'
|
||||
import { createSHA256 } from 'hash-wasm'
|
||||
|
||||
import { http } from '@/lib/http'
|
||||
import { OpsApiError, http } from '@/lib/http'
|
||||
|
||||
export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux'
|
||||
export type DesktopReleaseArch = 'universal' | 'x64' | 'arm64' | 'ia32'
|
||||
@@ -126,7 +126,6 @@ interface DesktopClientReleaseDirectUploadSession {
|
||||
|
||||
interface DesktopClientReleaseFileHashes {
|
||||
sha256: string
|
||||
contentMD5: string
|
||||
}
|
||||
|
||||
export const platformOptions = [
|
||||
@@ -175,7 +174,20 @@ export const desktopClientReleaseApi = {
|
||||
file: File,
|
||||
target: DesktopClientReleaseUploadTarget,
|
||||
) {
|
||||
return uploadOSSDirect(file, target)
|
||||
try {
|
||||
return await uploadOSSDirect(file, target)
|
||||
} catch (error) {
|
||||
if (!shouldFallbackToChunkedUpload(error)) {
|
||||
throw error
|
||||
}
|
||||
target.onProgress?.({
|
||||
loaded: 0,
|
||||
total: file.size,
|
||||
percent: 0,
|
||||
stage: 'uploading',
|
||||
})
|
||||
return uploadOSSInChunks(file, target)
|
||||
}
|
||||
},
|
||||
async uploadOSSLegacy(
|
||||
file: File,
|
||||
@@ -246,7 +258,6 @@ async function uploadOSSDirect(
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.size,
|
||||
sha256: hashes.sha256,
|
||||
content_md5: hashes.contentMD5,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -254,24 +265,42 @@ async function uploadOSSDirect(
|
||||
if (!session.upload_url) {
|
||||
throw new Error('missing_direct_upload_url')
|
||||
}
|
||||
await axios.put(session.upload_url, file, {
|
||||
headers: session.headers,
|
||||
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
|
||||
onUploadProgress(event) {
|
||||
const total = event.total ?? file.size
|
||||
target.onProgress?.({
|
||||
loaded: event.loaded,
|
||||
total,
|
||||
percent: total ? Math.min(99, Math.round((event.loaded / total) * 100)) : undefined,
|
||||
stage: 'uploading',
|
||||
})
|
||||
},
|
||||
})
|
||||
try {
|
||||
await axios.put(session.upload_url, file, {
|
||||
headers: directUploadHeaders(session.headers),
|
||||
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
|
||||
onUploadProgress(event) {
|
||||
const total = event.total ?? file.size
|
||||
target.onProgress?.({
|
||||
loaded: event.loaded,
|
||||
total,
|
||||
percent: total ? Math.min(99, Math.round((event.loaded / total) * 100)) : undefined,
|
||||
stage: 'uploading',
|
||||
})
|
||||
},
|
||||
})
|
||||
} catch (uploadError) {
|
||||
try {
|
||||
return await completeDirectUpload(session, target)
|
||||
} catch (completeError) {
|
||||
if (isDirectUploadNotFoundError(completeError)) {
|
||||
throw uploadError
|
||||
}
|
||||
throw completeError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completeDirectUpload(session, target)
|
||||
}
|
||||
|
||||
async function completeDirectUpload(
|
||||
session: DesktopClientReleaseDirectUploadSession,
|
||||
target: DesktopClientReleaseUploadTarget,
|
||||
): Promise<DesktopClientReleaseUploadResult> {
|
||||
target.onProgress?.({
|
||||
loaded: file.size,
|
||||
total: file.size,
|
||||
loaded: session.file_size_bytes,
|
||||
total: session.file_size_bytes,
|
||||
percent: 100,
|
||||
stage: 'verifying',
|
||||
})
|
||||
@@ -298,34 +327,40 @@ async function uploadOSSDirect(
|
||||
return result
|
||||
}
|
||||
|
||||
function isDirectUploadNotFoundError(error: unknown): boolean {
|
||||
return error instanceof OpsApiError && error.code === 40078
|
||||
}
|
||||
|
||||
function directUploadHeaders(headers: Record<string, string> | undefined): Record<string, string> {
|
||||
if (!headers) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'content-md5'),
|
||||
)
|
||||
}
|
||||
|
||||
function shouldFallbackToChunkedUpload(error: unknown): boolean {
|
||||
if (axios.isAxiosError(error)) return true
|
||||
return error instanceof Error && error.message === 'missing_direct_upload_url'
|
||||
}
|
||||
|
||||
async function hashFile(
|
||||
file: File,
|
||||
onProgress?: (loaded: number) => void,
|
||||
): Promise<DesktopClientReleaseFileHashes> {
|
||||
const [sha256Hasher, md5Hasher] = await Promise.all([createSHA256(), createMD5()])
|
||||
const sha256Hasher = await createSHA256()
|
||||
let loaded = 0
|
||||
while (loaded < file.size) {
|
||||
const end = Math.min(loaded + DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES, file.size)
|
||||
const chunk = new Uint8Array(await file.slice(loaded, end).arrayBuffer())
|
||||
sha256Hasher.update(chunk)
|
||||
md5Hasher.update(chunk)
|
||||
loaded = end
|
||||
onProgress?.(loaded)
|
||||
}
|
||||
return {
|
||||
sha256: sha256Hasher.digest('hex') as string,
|
||||
contentMD5: uint8ArrayToBase64(md5Hasher.digest('binary') as Uint8Array),
|
||||
}
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
binary += String.fromCharCode(value[index])
|
||||
}
|
||||
return window.btoa(binary)
|
||||
}
|
||||
|
||||
async function uploadOSSInChunks(
|
||||
file: File,
|
||||
target: DesktopClientReleaseUploadTarget,
|
||||
|
||||
Reference in New Issue
Block a user