fix: harden desktop release OSS uploads

This commit is contained in:
2026-06-22 23:49:24 +08:00
parent dcbab28e69
commit f0de364063
7 changed files with 218 additions and 63 deletions
+66 -31
View File
@@ -1,7 +1,7 @@
import axios from 'axios' 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 DesktopReleasePlatform = 'darwin' | 'win32' | 'linux'
export type DesktopReleaseArch = 'universal' | 'x64' | 'arm64' | 'ia32' export type DesktopReleaseArch = 'universal' | 'x64' | 'arm64' | 'ia32'
@@ -126,7 +126,6 @@ interface DesktopClientReleaseDirectUploadSession {
interface DesktopClientReleaseFileHashes { interface DesktopClientReleaseFileHashes {
sha256: string sha256: string
contentMD5: string
} }
export const platformOptions = [ export const platformOptions = [
@@ -175,7 +174,20 @@ export const desktopClientReleaseApi = {
file: File, file: File,
target: DesktopClientReleaseUploadTarget, 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( async uploadOSSLegacy(
file: File, file: File,
@@ -246,7 +258,6 @@ async function uploadOSSDirect(
file_name: file.name, file_name: file.name,
file_size_bytes: file.size, file_size_bytes: file.size,
sha256: hashes.sha256, sha256: hashes.sha256,
content_md5: hashes.contentMD5,
}, },
) )
@@ -254,24 +265,42 @@ async function uploadOSSDirect(
if (!session.upload_url) { if (!session.upload_url) {
throw new Error('missing_direct_upload_url') throw new Error('missing_direct_upload_url')
} }
await axios.put(session.upload_url, file, { try {
headers: session.headers, await axios.put(session.upload_url, file, {
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS, headers: directUploadHeaders(session.headers),
onUploadProgress(event) { timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
const total = event.total ?? file.size onUploadProgress(event) {
target.onProgress?.({ const total = event.total ?? file.size
loaded: event.loaded, target.onProgress?.({
total, loaded: event.loaded,
percent: total ? Math.min(99, Math.round((event.loaded / total) * 100)) : undefined, total,
stage: 'uploading', 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?.({ target.onProgress?.({
loaded: file.size, loaded: session.file_size_bytes,
total: file.size, total: session.file_size_bytes,
percent: 100, percent: 100,
stage: 'verifying', stage: 'verifying',
}) })
@@ -298,34 +327,40 @@ async function uploadOSSDirect(
return result 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( async function hashFile(
file: File, file: File,
onProgress?: (loaded: number) => void, onProgress?: (loaded: number) => void,
): Promise<DesktopClientReleaseFileHashes> { ): Promise<DesktopClientReleaseFileHashes> {
const [sha256Hasher, md5Hasher] = await Promise.all([createSHA256(), createMD5()]) const sha256Hasher = await createSHA256()
let loaded = 0 let loaded = 0
while (loaded < file.size) { while (loaded < file.size) {
const end = Math.min(loaded + DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES, 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()) const chunk = new Uint8Array(await file.slice(loaded, end).arrayBuffer())
sha256Hasher.update(chunk) sha256Hasher.update(chunk)
md5Hasher.update(chunk)
loaded = end loaded = end
onProgress?.(loaded) onProgress?.(loaded)
} }
return { return {
sha256: sha256Hasher.digest('hex') as string, 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( async function uploadOSSInChunks(
file: File, file: File,
target: DesktopClientReleaseUploadTarget, target: DesktopClientReleaseUploadTarget,
@@ -602,8 +602,7 @@ func (s *DesktopClientReleaseService) InitiateDirectUpload(
return nil, err return nil, err
} }
} }
contentMD5, err := normalizeDesktopReleaseContentMD5(in.ContentMD5) if _, err := normalizeDesktopReleaseContentMD5(in.ContentMD5); err != nil {
if err != nil {
return nil, err return nil, err
} }
@@ -635,7 +634,7 @@ func (s *DesktopClientReleaseService) InitiateDirectUpload(
ctx, ctx,
objectKey, objectKey,
detectDesktopReleaseContentType(fileName, nil), detectDesktopReleaseContentType(fileName, nil),
contentMD5, "",
desktopClientReleaseDirectUploadTTL, desktopClientReleaseDirectUploadTTL,
) )
if err != nil { if err != nil {
@@ -724,6 +723,21 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload(
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成") return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
} }
} }
actualSHA, actualSize, err := hashDesktopClientReleaseStorageObject(ctx, s.storage, objectKey)
if err != nil {
if errors.Is(err, objectstorage.ErrObjectNotFound) {
return nil, response.ErrBadRequest(40078, "direct_upload_not_found", "OSS 直传文件尚未完成")
}
appErr := response.ErrInternal(50092, "desktop_client_release_upload_failed", err.Error())
appErr.Cause = err
return nil, appErr
}
if actualSize != in.FileSizeBytes {
return nil, response.ErrBadRequest(40079, "direct_upload_size_mismatch", "OSS 直传文件大小不匹配,请重新上传")
}
if actualSHA != sha256Hex {
return nil, response.ErrBadRequest(40070, "invalid_sha256", "OSS 直传文件 SHA256 不匹配,请重新上传")
}
return &DesktopClientReleaseUploadResult{ return &DesktopClientReleaseUploadResult{
OSSObjectKey: objectKey, OSSObjectKey: objectKey,
@@ -733,6 +747,34 @@ func (s *DesktopClientReleaseService) CompleteDirectUpload(
}, nil }, nil
} }
func hashDesktopClientReleaseStorageObject(
ctx context.Context,
storage objectstorage.Client,
objectKey string,
) (string, int64, error) {
if readerClient, ok := storage.(objectstorage.ObjectReaderClient); ok {
reader, err := readerClient.GetReader(ctx, objectKey)
if err != nil {
return "", 0, err
}
defer reader.Close()
hasher := sha256.New()
size, err := io.Copy(hasher, reader)
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), size, nil
}
content, err := storage.GetBytes(ctx, objectKey)
if err != nil {
return "", 0, err
}
actualSHA := sha256.Sum256(content)
return hex.EncodeToString(actualSHA[:]), int64(len(content)), nil
}
func MaxDesktopClientReleaseMultipartBytes() int64 { func MaxDesktopClientReleaseMultipartBytes() int64 {
return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes return MaxDesktopClientReleaseUploadBytes + desktopClientReleaseMultipartOverheadBytes
} }
@@ -6,6 +6,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
@@ -343,12 +344,15 @@ func TestDesktopClientReleaseDirectUploadUsesPresignedPutURL(t *testing.T) {
require.Equal(t, "desktop-client/releases/blobs/"+wantSHA+".dmg", initResult.OSSObjectKey) require.Equal(t, "desktop-client/releases/blobs/"+wantSHA+".dmg", initResult.OSSObjectKey)
require.Equal(t, "https://oss.example.test/upload/"+initResult.OSSObjectKey, initResult.UploadURL) require.Equal(t, "https://oss.example.test/upload/"+initResult.OSSObjectKey, initResult.UploadURL)
require.Equal(t, "application/x-apple-diskimage", initResult.Headers["Content-Type"]) require.Equal(t, "application/x-apple-diskimage", initResult.Headers["Content-Type"])
require.Equal(t, "pt74NOtqjIwBJtPUYvJ8AA==", initResult.Headers["Content-Md5"]) require.NotContains(t, initResult.Headers, "Content-Md5")
require.NotContains(t, initResult.Headers, "Content-MD5")
require.Len(t, storage.presignedPutCalls, 1) require.Len(t, storage.presignedPutCalls, 1)
require.Equal(t, initResult.OSSObjectKey, storage.presignedPutCalls[0]) require.Equal(t, initResult.OSSObjectKey, storage.presignedPutCalls[0])
require.Equal(t, []string{""}, storage.presignedPutMD5s)
storage.existsByKey[initResult.OSSObjectKey] = true storage.existsByKey[initResult.OSSObjectKey] = true
storage.objectSizes[initResult.OSSObjectKey] = int64(len(content)) storage.objectSizes[initResult.OSSObjectKey] = int64(len(content))
storage.objects[initResult.OSSObjectKey] = content
completeResult, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{ completeResult, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{
Platform: "darwin", Platform: "darwin",
Arch: "arm64", Arch: "arm64",
@@ -392,6 +396,34 @@ func TestDesktopClientReleaseDirectUploadRejectsSizeMismatch(t *testing.T) {
require.Contains(t, err.Error(), "direct_upload_size_mismatch") require.Contains(t, err.Error(), "direct_upload_size_mismatch")
} }
func TestDesktopClientReleaseDirectUploadRejectsSHA256Mismatch(t *testing.T) {
t.Parallel()
sum := sha256.Sum256([]byte("release-package"))
sha256Hex := hex.EncodeToString(sum[:])
objectKey := buildDesktopClientReleaseObjectKey(sha256Hex, "client.zip")
storage := &desktopReleaseDownloadURLStorage{
existsByKey: map[string]bool{objectKey: true},
objects: map[string][]byte{objectKey: []byte("different-package")},
objectSizes: map[string]int64{objectKey: int64(len("different-package"))},
}
svc := NewDesktopClientReleaseService(nil, storage, nil)
_, err := svc.CompleteDirectUpload(context.Background(), DesktopClientReleaseDirectUploadCompleteInput{
Platform: "darwin",
Arch: "arm64",
Channel: "stable",
Version: "1.2.3",
Kind: DesktopClientAssetKindUpdater,
OSSObjectKey: objectKey,
FileName: "client.zip",
FileSizeBytes: int64(len("different-package")),
SHA256: sha256Hex,
})
require.Error(t, err)
require.Contains(t, err.Error(), "invalid_sha256")
}
type desktopReleaseDownloadURLStorage struct { type desktopReleaseDownloadURLStorage struct {
publicURLCalls []string publicURLCalls []string
downloadURLCalls []string downloadURLCalls []string
@@ -399,6 +431,7 @@ type desktopReleaseDownloadURLStorage struct {
objects map[string][]byte objects map[string][]byte
objectSizes map[string]int64 objectSizes map[string]int64
presignedPutCalls []string presignedPutCalls []string
presignedPutMD5s []string
} }
func (s *desktopReleaseDownloadURLStorage) Validate() error { func (s *desktopReleaseDownloadURLStorage) Validate() error {
@@ -429,8 +462,23 @@ func (s *desktopReleaseDownloadURLStorage) PutReader(_ context.Context, objectKe
return nil return nil
} }
func (s *desktopReleaseDownloadURLStorage) GetBytes(context.Context, string) ([]byte, error) { func (s *desktopReleaseDownloadURLStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
return nil, errors.New("not implemented") if s.objects == nil {
return nil, fmt.Errorf("%w: %s", objectstorage.ErrObjectNotFound, objectKey)
}
content, ok := s.objects[objectKey]
if !ok {
return nil, fmt.Errorf("%w: %s", objectstorage.ErrObjectNotFound, objectKey)
}
return append([]byte(nil), content...), nil
}
func (s *desktopReleaseDownloadURLStorage) GetReader(_ context.Context, objectKey string) (io.ReadCloser, error) {
content, err := s.GetBytes(context.Background(), objectKey)
if err != nil {
return nil, err
}
return io.NopCloser(bytes.NewReader(content)), nil
} }
func (s *desktopReleaseDownloadURLStorage) Exists(_ context.Context, objectKey string) (bool, error) { func (s *desktopReleaseDownloadURLStorage) Exists(_ context.Context, objectKey string) (bool, error) {
@@ -448,6 +496,7 @@ func (s *desktopReleaseDownloadURLStorage) PresignedPutURL(
ttl time.Duration, ttl time.Duration,
) (*objectstorage.PresignedPutResult, error) { ) (*objectstorage.PresignedPutResult, error) {
s.presignedPutCalls = append(s.presignedPutCalls, objectKey) s.presignedPutCalls = append(s.presignedPutCalls, objectKey)
s.presignedPutMD5s = append(s.presignedPutMD5s, contentMD5Base64)
headers := http.Header{} headers := http.Header{}
headers.Set("Content-Type", contentType) headers.Set("Content-Type", contentType)
if contentMD5Base64 != "" { if contentMD5Base64 != "" {
+21 -13
View File
@@ -217,6 +217,26 @@ func aliyunStatusCode(err error) int {
} }
func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) { func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) {
reader, err := c.GetReader(ctx, objectKey)
if err != nil {
return nil, err
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
c.logError(ctx, "aliyun read object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return nil, fmt.Errorf("read aliyun object: %w", err)
}
return data, nil
}
func (c *aliyunClient) GetReader(ctx context.Context, objectKey string) (io.ReadCloser, error) {
if err := c.Validate(); err != nil { if err := c.Validate(); err != nil {
return nil, err return nil, err
} }
@@ -243,19 +263,7 @@ func (c *aliyunClient) GetBytes(ctx context.Context, objectKey string) ([]byte,
} }
return nil, fmt.Errorf("get aliyun object: %w", err) return nil, fmt.Errorf("get aliyun object: %w", err)
} }
defer reader.Close() return reader, nil
data, err := io.ReadAll(reader)
if err != nil {
c.logError(ctx, "aliyun read object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
return nil, fmt.Errorf("read aliyun object: %w", err)
}
return data, nil
} }
func isAliyunObjectNotFound(err error) bool { func isAliyunObjectNotFound(err error) bool {
@@ -33,6 +33,10 @@ type ReaderClient interface {
PutReader(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error PutReader(ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string) error
} }
type ObjectReaderClient interface {
GetReader(ctx context.Context, objectKey string) (io.ReadCloser, error)
}
type PresignedPutResult struct { type PresignedPutResult struct {
URL string URL string
Headers http.Header Headers http.Header
+21 -13
View File
@@ -161,20 +161,9 @@ func (c *minioClient) PutReader(
} }
func (c *minioClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) { func (c *minioClient) GetBytes(ctx context.Context, objectKey string) ([]byte, error) {
if err := c.Validate(); err != nil { reader, err := c.GetReader(ctx, objectKey)
return nil, err
}
reader, err := c.client.GetObject(ctx, c.bucket, objectKey, minio.GetObjectOptions{})
if err != nil { if err != nil {
c.logError(ctx, "minio get object failed", return nil, err
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
if isMinIOObjectNotFound(err) {
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
}
return nil, fmt.Errorf("get minio object: %w", err)
} }
defer reader.Close() defer reader.Close()
@@ -193,6 +182,25 @@ func (c *minioClient) GetBytes(ctx context.Context, objectKey string) ([]byte, e
return data, nil return data, nil
} }
func (c *minioClient) GetReader(ctx context.Context, objectKey string) (io.ReadCloser, error) {
if err := c.Validate(); err != nil {
return nil, err
}
reader, err := c.client.GetObject(ctx, c.bucket, objectKey, minio.GetObjectOptions{})
if err != nil {
c.logError(ctx, "minio get object failed",
zap.String("bucket", c.bucket),
zap.String("object_key", objectKey),
zap.Error(err),
)
if isMinIOObjectNotFound(err) {
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
}
return nil, fmt.Errorf("get minio object: %w", err)
}
return reader, nil
}
func isMinIOObjectNotFound(err error) bool { func isMinIOObjectNotFound(err error) bool {
responseErr := minio.ToErrorResponse(err) responseErr := minio.ToErrorResponse(err)
return responseErr.StatusCode == 404 || return responseErr.StatusCode == 404 ||
@@ -49,6 +49,15 @@ func (c *ReloadableClient) GetBytes(ctx context.Context, objectKey string) ([]by
return c.snapshot().GetBytes(ctx, objectKey) return c.snapshot().GetBytes(ctx, objectKey)
} }
func (c *ReloadableClient) GetReader(ctx context.Context, objectKey string) (io.ReadCloser, error) {
client := c.snapshot()
readerClient, ok := client.(ObjectReaderClient)
if !ok {
return nil, ErrNotConfigured
}
return readerClient.GetReader(ctx, objectKey)
}
func (c *ReloadableClient) Exists(ctx context.Context, objectKey string) (bool, error) { func (c *ReloadableClient) Exists(ctx context.Context, objectKey string) (bool, error) {
return c.snapshot().Exists(ctx, objectKey) return c.snapshot().Exists(ctx, objectKey)
} }