From a8183166ac69c4c704c40572cd6162eb7ed7a739 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 15 Jun 2026 16:53:28 +0800 Subject: [PATCH] fix: add S3 addressing style option Add a configurable S3 addressing style for remote backups while keeping path-style as the default for existing configurations. Use virtual-hosted-style to support providers such as Tencent COS buckets that reject path-style requests. --- shared/backup-schema.ts | 3 ++ src/services/backup-config.ts | 6 ++++ src/services/backup-uploader.ts | 29 +++++++++++++++---- .../backup-center/BackupDestinationDetail.tsx | 21 +++++++++++++- webapp/src/lib/api/backup.ts | 2 ++ webapp/src/lib/i18n/locales/en.ts | 3 ++ webapp/src/lib/i18n/locales/es.ts | 3 ++ webapp/src/lib/i18n/locales/ru.ts | 3 ++ webapp/src/lib/i18n/locales/zh-CN.ts | 3 ++ webapp/src/lib/i18n/locales/zh-TW.ts | 3 ++ 10 files changed, 70 insertions(+), 6 deletions(-) diff --git a/shared/backup-schema.ts b/shared/backup-schema.ts index 3ccb519..2478a6f 100644 --- a/shared/backup-schema.ts +++ b/shared/backup-schema.ts @@ -14,10 +14,12 @@ export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; export const BACKUP_DEFAULT_START_TIME = '03:00'; export type BackupDestinationType = 's3' | 'webdav'; +export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style'; export interface S3BackupDestination { endpoint: string; bucket: string; + addressingStyle: S3BackupAddressingStyle; region: string; accessKeyId: string; secretAccessKey: string; @@ -103,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType return { endpoint: '', bucket: '', + addressingStyle: 'path-style', region: BACKUP_DEFAULT_S3_REGION, accessKeyId: '', secretAccessKey: '', diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index e614a8e..9ecfc66 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -16,6 +16,7 @@ import { type BackupRuntimeState, type BackupScheduleConfig, type BackupSettings, + type S3BackupAddressingStyle, type S3BackupDestination, type WebDavBackupDestination, createBackupRandomId, @@ -35,6 +36,7 @@ export type { BackupRuntimeState, BackupScheduleConfig, BackupSettings, + S3BackupAddressingStyle, S3BackupDestination, WebDavBackupDestination, } from '../../shared/backup-schema'; @@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back const source = isPlainObject(value) ? value : {}; const endpoint = asTrimmedString(source.endpoint); const bucket = asTrimmedString(source.bucket); + const addressingStyleRaw = asTrimmedString(source.addressingStyle); + const addressingStyle: S3BackupAddressingStyle = + addressingStyleRaw === 'virtual-hosted-style' ? 'virtual-hosted-style' : 'path-style'; const accessKeyId = asTrimmedString(source.accessKeyId); const secretAccessKey = asTrimmedString(source.secretAccessKey); const region = asTrimmedString(source.region) || 'auto'; @@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back return { endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '', bucket, + addressingStyle, region, accessKeyId, secretAccessKey, diff --git a/src/services/backup-uploader.ts b/src/services/backup-uploader.ts index aa8603b..53b2520 100644 --- a/src/services/backup-uploader.ts +++ b/src/services/backup-uploader.ts @@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str return true; } +function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean { + const hostname = endpoint.hostname.toLowerCase(); + const bucketName = bucket.trim().toLowerCase(); + return !!bucketName && (hostname === bucketName || hostname.startsWith(`${bucketName}.`)); +} + function s3BucketBaseUrl(config: S3BackupDestination): URL { - return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`); + const endpoint = new URL(config.endpoint.replace(/\/+$/, '')); + const bucket = config.bucket.trim(); + + if (config.addressingStyle === 'virtual-hosted-style') { + if (isBucketHostedS3Endpoint(endpoint, bucket)) return endpoint; + endpoint.hostname = `${bucket}.${endpoint.hostname}`; + return endpoint; + } + + return new URL(`${endpoint.toString().replace(/\/+$/, '')}/${encodeURIComponent(bucket)}`); +} + +function s3ObjectUrl(config: S3BackupDestination, objectKey: string): URL { + return new URL(`${s3BucketBaseUrl(config).toString().replace(/\/+$/, '')}/${encodePathSegments(objectKey)}`); } function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string { @@ -501,7 +520,7 @@ async function putToS3( options: RemoteBackupFilePutOptions = {} ): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); - const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); + const url = s3ObjectUrl(config, objectKey); const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType); if (!response.ok) { @@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string) throw new Error('Please select a backup file'); } const objectKey = normalizeS3ObjectKey(config, normalized); - const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); + const url = s3ObjectUrl(config, objectKey); const response = await signedS3Request(config, 'GET', url); if (!response.ok) { throw new Error(`S3 download failed: ${response.status}`); @@ -610,7 +629,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string) async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); - const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); + const url = s3ObjectUrl(config, objectKey); const response = await signedS3Request(config, 'DELETE', url); if (!response.ok && response.status !== 404) { throw new Error(`S3 delete failed: ${response.status}`); @@ -619,7 +638,7 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string): async function existsInS3(config: S3BackupDestination, relativePath: string): Promise { const objectKey = normalizeS3ObjectKey(config, relativePath); - const url = new URL(`${s3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); + const url = s3ObjectUrl(config, objectKey); const response = await signedS3Request(config, 'HEAD', url); if (response.status === 404) return false; if (!response.ok) { diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx index 8d94115..147e390 100644 --- a/webapp/src/components/backup-center/BackupDestinationDetail.tsx +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact'; import type { BackupDestinationRecord, RemoteBackupBrowserResponse, + S3BackupAddressingStyle, S3BackupDestination, WebDavBackupDestination, } from '@/lib/api/backup'; @@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { {props.selectedDestination.type === 's3' ? (
-