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.
This commit is contained in:
shuaiplus
2026-06-15 16:53:28 +08:00
parent f6169b7610
commit a8183166ac
10 changed files with 70 additions and 6 deletions
+3
View File
@@ -14,10 +14,12 @@ export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00'; export const BACKUP_DEFAULT_START_TIME = '03:00';
export type BackupDestinationType = 's3' | 'webdav'; export type BackupDestinationType = 's3' | 'webdav';
export type S3BackupAddressingStyle = 'path-style' | 'virtual-hosted-style';
export interface S3BackupDestination { export interface S3BackupDestination {
endpoint: string; endpoint: string;
bucket: string; bucket: string;
addressingStyle: S3BackupAddressingStyle;
region: string; region: string;
accessKeyId: string; accessKeyId: string;
secretAccessKey: string; secretAccessKey: string;
@@ -103,6 +105,7 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
return { return {
endpoint: '', endpoint: '',
bucket: '', bucket: '',
addressingStyle: 'path-style',
region: BACKUP_DEFAULT_S3_REGION, region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
+6
View File
@@ -16,6 +16,7 @@ import {
type BackupRuntimeState, type BackupRuntimeState,
type BackupScheduleConfig, type BackupScheduleConfig,
type BackupSettings, type BackupSettings,
type S3BackupAddressingStyle,
type S3BackupDestination, type S3BackupDestination,
type WebDavBackupDestination, type WebDavBackupDestination,
createBackupRandomId, createBackupRandomId,
@@ -35,6 +36,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings, BackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
@@ -109,6 +111,9 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
const source = isPlainObject(value) ? value : {}; const source = isPlainObject(value) ? value : {};
const endpoint = asTrimmedString(source.endpoint); const endpoint = asTrimmedString(source.endpoint);
const bucket = asTrimmedString(source.bucket); 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 accessKeyId = asTrimmedString(source.accessKeyId);
const secretAccessKey = asTrimmedString(source.secretAccessKey); const secretAccessKey = asTrimmedString(source.secretAccessKey);
const region = asTrimmedString(source.region) || 'auto'; const region = asTrimmedString(source.region) || 'auto';
@@ -131,6 +136,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
return { return {
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '', endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
bucket, bucket,
addressingStyle,
region, region,
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
+24 -5
View File
@@ -448,8 +448,27 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
return true; 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 { 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 { function normalizeS3ObjectKey(config: S3BackupDestination, relativePath: string): string {
@@ -501,7 +520,7 @@ async function putToS3(
options: RemoteBackupFilePutOptions = {} options: RemoteBackupFilePutOptions = {}
): Promise<void> { ): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath); 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); const response = await signedS3Request(config, 'PUT', url, bytes, options.contentType);
if (!response.ok) { if (!response.ok) {
@@ -594,7 +613,7 @@ async function downloadFromS3(config: S3BackupDestination, relativePath: string)
throw new Error('Please select a backup file'); throw new Error('Please select a backup file');
} }
const objectKey = normalizeS3ObjectKey(config, normalized); 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); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
throw new Error(`S3 download failed: ${response.status}`); 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<void> { async function deleteFromS3(config: S3BackupDestination, relativePath: string): Promise<void> {
const objectKey = normalizeS3ObjectKey(config, relativePath); 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); const response = await signedS3Request(config, 'DELETE', url);
if (!response.ok && response.status !== 404) { if (!response.ok && response.status !== 404) {
throw new Error(`S3 delete failed: ${response.status}`); 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<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
const objectKey = normalizeS3ObjectKey(config, relativePath); 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); const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false; if (response.status === 404) return false;
if (!response.ok) { if (!response.ok) {
@@ -2,6 +2,7 @@ import { CloudUpload, Save, Trash2 } from 'lucide-preact';
import type { import type {
BackupDestinationRecord, BackupDestinationRecord,
RemoteBackupBrowserResponse, RemoteBackupBrowserResponse,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '@/lib/api/backup'; } from '@/lib/api/backup';
@@ -401,7 +402,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
{props.selectedDestination.type === 's3' ? ( {props.selectedDestination.type === 's3' ? (
<div className="field-grid"> <div className="field-grid">
<label className="field field-span-2"> <label className="field">
<span>{t('txt_backup_s3_endpoint')}</span> <span>{t('txt_backup_s3_endpoint')}</span>
<input <input
className="input" className="input"
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
}))} }))}
/> />
</label> </label>
<label className="field">
<span>{t('txt_backup_s3_addressing_style')}</span>
<select
className="input"
value={(props.selectedDestination.destination as S3BackupDestination).addressingStyle || 'path-style'}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(event) => props.onUpdateDestination((destination) => ({
...destination,
destination: {
...(destination.destination as S3BackupDestination),
addressingStyle: (event.currentTarget as HTMLSelectElement).value as S3BackupAddressingStyle,
},
}))}
>
<option value="path-style">{t('txt_backup_s3_addressing_path_style')}</option>
<option value="virtual-hosted-style">{t('txt_backup_s3_addressing_virtual_hosted_style')}</option>
</select>
</label>
<label className="field"> <label className="field">
<span>{t('txt_backup_s3_bucket')}</span> <span>{t('txt_backup_s3_bucket')}</span>
<input <input
+2
View File
@@ -6,6 +6,7 @@ import type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings as AdminBackupSettings, BackupSettings as AdminBackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '@shared/backup-schema'; } from '@shared/backup-schema';
@@ -26,6 +27,7 @@ export type {
BackupRuntimeState, BackupRuntimeState,
BackupScheduleConfig, BackupScheduleConfig,
AdminBackupSettings, AdminBackupSettings,
S3BackupAddressingStyle,
S3BackupDestination, S3BackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
}; };
+3
View File
@@ -267,6 +267,9 @@ const en: Record<string, string> = {
"txt_backup_webdav_password": "WebDAV Password", "txt_backup_webdav_password": "WebDAV Password",
"txt_backup_webdav_path": "Remote Folder", "txt_backup_webdav_path": "Remote Folder",
"txt_backup_s3_endpoint": "S3 Endpoint", "txt_backup_s3_endpoint": "S3 Endpoint",
"txt_backup_s3_addressing_style": "S3 Addressing Style",
"txt_backup_s3_addressing_path_style": "path-style (default)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "Bucket", "txt_backup_s3_bucket": "Bucket",
"txt_backup_s3_region": "Region", "txt_backup_s3_region": "Region",
"txt_backup_s3_access_key": "Access Key", "txt_backup_s3_access_key": "Access Key",
+3
View File
@@ -267,6 +267,9 @@ const es: Record<string, string> = {
"txt_backup_webdav_password": "Contraseña WebDAV", "txt_backup_webdav_password": "Contraseña WebDAV",
"txt_backup_webdav_path": "Carpeta remota", "txt_backup_webdav_path": "Carpeta remota",
"txt_backup_s3_endpoint": "Endpoint S3", "txt_backup_s3_endpoint": "Endpoint S3",
"txt_backup_s3_addressing_style": "Estilo de direccionamiento S3",
"txt_backup_s3_addressing_path_style": "path-style (predeterminado)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "Bucket S3", "txt_backup_s3_bucket": "Bucket S3",
"txt_backup_s3_region": "Región", "txt_backup_s3_region": "Región",
"txt_backup_s3_access_key": "Clave de acceso", "txt_backup_s3_access_key": "Clave de acceso",
+3
View File
@@ -267,6 +267,9 @@ const ru: Record<string, string> = {
"txt_backup_webdav_password": "Пароль WebDAV", "txt_backup_webdav_password": "Пароль WebDAV",
"txt_backup_webdav_path": "Удаленная папка", "txt_backup_webdav_path": "Удаленная папка",
"txt_backup_s3_endpoint": "S3 endpoint", "txt_backup_s3_endpoint": "S3 endpoint",
"txt_backup_s3_addressing_style": "Стиль адресации S3",
"txt_backup_s3_addressing_path_style": "path-style (по умолчанию)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "Бакет", "txt_backup_s3_bucket": "Бакет",
"txt_backup_s3_region": "Регион", "txt_backup_s3_region": "Регион",
"txt_backup_s3_access_key": "Ключ доступа", "txt_backup_s3_access_key": "Ключ доступа",
+3
View File
@@ -267,6 +267,9 @@ const zhCN: Record<string, string> = {
"txt_backup_webdav_password": "WebDAV 密码", "txt_backup_webdav_password": "WebDAV 密码",
"txt_backup_webdav_path": "远程目录", "txt_backup_webdav_path": "远程目录",
"txt_backup_s3_endpoint": "S3 端点", "txt_backup_s3_endpoint": "S3 端点",
"txt_backup_s3_addressing_style": "S3 寻址方式",
"txt_backup_s3_addressing_path_style": "path-style(默认)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "存储桶", "txt_backup_s3_bucket": "存储桶",
"txt_backup_s3_region": "区域", "txt_backup_s3_region": "区域",
"txt_backup_s3_access_key": "访问密钥", "txt_backup_s3_access_key": "访问密钥",
+3
View File
@@ -267,6 +267,9 @@ const zhTW: Record<string, string> = {
"txt_backup_webdav_password": "WebDAV 密碼", "txt_backup_webdav_password": "WebDAV 密碼",
"txt_backup_webdav_path": "遠程目錄", "txt_backup_webdav_path": "遠程目錄",
"txt_backup_s3_endpoint": "S3 端點", "txt_backup_s3_endpoint": "S3 端點",
"txt_backup_s3_addressing_style": "S3 定址方式",
"txt_backup_s3_addressing_path_style": "path-style(預設)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "儲存桶", "txt_backup_s3_bucket": "儲存桶",
"txt_backup_s3_region": "區域", "txt_backup_s3_region": "區域",
"txt_backup_s3_access_key": "存取金鑰", "txt_backup_s3_access_key": "存取金鑰",