mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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:
@@ -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: '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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) {
|
||||
|
||||
@@ -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' ? (
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_s3_endpoint')}</span>
|
||||
<input
|
||||
className="input"
|
||||
@@ -417,6 +418,24 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||
}))}
|
||||
/>
|
||||
</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">
|
||||
<span>{t('txt_backup_s3_bucket')}</span>
|
||||
<input
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings as AdminBackupSettings,
|
||||
S3BackupAddressingStyle,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '@shared/backup-schema';
|
||||
@@ -26,6 +27,7 @@ export type {
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
AdminBackupSettings,
|
||||
S3BackupAddressingStyle,
|
||||
S3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
};
|
||||
|
||||
@@ -267,6 +267,9 @@ const en: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "WebDAV Password",
|
||||
"txt_backup_webdav_path": "Remote Folder",
|
||||
"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_region": "Region",
|
||||
"txt_backup_s3_access_key": "Access Key",
|
||||
|
||||
@@ -267,6 +267,9 @@ const es: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "Contraseña WebDAV",
|
||||
"txt_backup_webdav_path": "Carpeta remota",
|
||||
"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_region": "Región",
|
||||
"txt_backup_s3_access_key": "Clave de acceso",
|
||||
|
||||
@@ -267,6 +267,9 @@ const ru: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "Пароль WebDAV",
|
||||
"txt_backup_webdav_path": "Удаленная папка",
|
||||
"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_region": "Регион",
|
||||
"txt_backup_s3_access_key": "Ключ доступа",
|
||||
|
||||
@@ -267,6 +267,9 @@ const zhCN: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "WebDAV 密码",
|
||||
"txt_backup_webdav_path": "远程目录",
|
||||
"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_region": "区域",
|
||||
"txt_backup_s3_access_key": "访问密钥",
|
||||
|
||||
@@ -267,6 +267,9 @@ const zhTW: Record<string, string> = {
|
||||
"txt_backup_webdav_password": "WebDAV 密碼",
|
||||
"txt_backup_webdav_path": "遠程目錄",
|
||||
"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_region": "區域",
|
||||
"txt_backup_s3_access_key": "存取金鑰",
|
||||
|
||||
Reference in New Issue
Block a user