diff --git a/messages/en.json b/messages/en.json index bde2074b3..48f8746d7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -774,6 +774,8 @@ "advancedSettings": "Advanced Settings", "endpoint": "Custom Endpoint", "endpointDesc": "Leave empty for AWS S3, or enter S3-compatible service endpoint", + "forcePathStyle": "Force Path Style", + "forcePathStyleDesc": "Enable for MinIO, Cloudflare R2, or other services that require path-style URLs", "customDomain": "Custom Domain", "customDomainDesc": "Optional, custom domain for accessing images", "pathPrefix": "Path Prefix", diff --git a/messages/ja.json b/messages/ja.json index 9c24428ff..f5428bb3b 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -707,6 +707,8 @@ "advancedSettings": "詳細設定", "endpoint": "カスタムエンドポイント", "endpointDesc": "AWS S3 の場合は空白、S3 互換サービスのエンドポイントを入力", + "forcePathStyle": "パススタイルを強制", + "forcePathStyleDesc": "MinIO、Cloudflare R2 など、パススタイル URL が必要なサービスで有効にしてください", "customDomain": "カスタムドメイン", "customDomainDesc": "オプション、画像アクセス用のカスタムドメイン", "pathPrefix": "パスプレフィックス", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 5219bfdd1..8465376ef 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -732,6 +732,8 @@ "advancedSettings": "Configurações Avançadas", "endpoint": "Endpoint Personalizado", "endpointDesc": "Deixe em branco para AWS S3, ou insira o endpoint do serviço compatível com S3", + "forcePathStyle": "Forçar Path Style", + "forcePathStyleDesc": "Ative para MinIO, Cloudflare R2 ou outros serviços que exigem URLs no estilo de caminho", "customDomain": "Domínio Personalizado", "customDomainDesc": "Opcional, domínio personalizado para acessar imagens", "pathPrefix": "Prefixo do Caminho", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 0752d8269..1b94eeb47 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -708,6 +708,8 @@ "advancedSettings": "進階設定", "endpoint": "自訂端點", "endpointDesc": "留空使用 AWS S3,或輸入相容 S3 的服務端點", + "forcePathStyle": "強制 Path Style", + "forcePathStyleDesc": "為 MinIO、Cloudflare R2 或其他需要 Path Style URL 的服務啟用", "customDomain": "自訂域名", "customDomainDesc": "可選,用於訪問圖片的自訂域名", "pathPrefix": "路徑前綴", diff --git a/messages/zh.json b/messages/zh.json index daff06b9a..a592ed302 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -769,6 +769,8 @@ "advancedSettings": "高级设置", "endpoint": "自定义端点", "endpointDesc": "留空使用 AWS S3,或输入兼容 S3 的服务端点", + "forcePathStyle": "强制 Path Style", + "forcePathStyleDesc": "为 MinIO、Cloudflare R2 或其他需要 Path Style URL 的服务启用", "customDomain": "自定义域名", "customDomainDesc": "可选,用于访问图片的自定义域名", "pathPrefix": "路径前缀", diff --git a/src/app/core/setting/imageHosting/s3.tsx b/src/app/core/setting/imageHosting/s3.tsx index fd37aae18..085470eae 100644 --- a/src/app/core/setting/imageHosting/s3.tsx +++ b/src/app/core/setting/imageHosting/s3.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Eye, EyeOff, CheckCircle, XCircle, Loader2 } from 'lucide-react'; +import { Switch } from '@/components/ui/switch'; import { toast } from '@/hooks/use-toast'; import useImageStore from '@/stores/imageHosting'; import { SyncStateEnum } from '@/lib/sync/github.types'; @@ -21,6 +22,7 @@ interface S3Config { endpoint?: string customDomain?: string pathPrefix?: string + forcePathStyle?: boolean } export function S3ImageHosting() { @@ -34,7 +36,8 @@ export function S3ImageHosting() { bucket: '', endpoint: '', customDomain: '', - pathPrefix: '' + pathPrefix: '', + forcePathStyle: false }); const [showSecretKey, setShowSecretKey] = useState(false); @@ -239,6 +242,19 @@ export function S3ImageHosting() { placeholder="https://s3.amazonaws.com" /> +
+
+ +

+ {t('settings.imageHosting.s3.forcePathStyleDesc')} +

+
+ handleConfigChange({ ...config, forcePathStyle: checked })} + /> +
endpoint.includes(domain)); +} + +// 构建 S3 URL,支持 Path Style 和 Virtual Hosted Style +function buildS3Url(endpoint: string, bucket: string, key?: string, forcePathStyle?: boolean): string { + // Path Style: endpoint/bucket[/key] + if (forcePathStyle) { + const parts = [endpoint, bucket]; + if (key) parts.push(key); + return parts.join('/').replace(/([^:]\/)\/+/g, "$1"); + } + + // Virtual Hosted Style for known services: bucket.hostname[/key] + if (isVirtualHostedService(endpoint)) { + try { + const urlObj = new URL(endpoint); + urlObj.hostname = `${bucket}.${urlObj.hostname}`; + let url = urlObj.toString().replace(/\/+$/, ''); + if (key) url = `${url}/${key}`; + return url.replace(/([^:]\/)\/+/g, "$1"); + } catch { + console.warn('[S3] Failed to construct Virtual Hosted URL, falling back to Path Style'); + } + } + + // Default: Path Style + const parts = [endpoint, bucket]; + if (key) parts.push(key); + return parts.join('/').replace(/([^:]\/)\/+/g, "$1"); +} + // 测试 S3 连接 export async function testS3Connection(config: S3Config): Promise { try { @@ -161,29 +203,12 @@ export async function testS3Connection(config: S3Config): Promise { const proxyUrl = await store.get('proxy') const proxy: Proxy | undefined = proxyUrl ? { all: proxyUrl } : undefined - const endpoint = (config.endpoint || `https://s3.${config.region}.amazonaws.com`).trim(); + let endpoint = (config.endpoint || `https://s3.${config.region}.amazonaws.com`).trim(); + if (endpoint.endsWith('/')) endpoint = endpoint.slice(0, -1); const bucket = config.bucket.trim(); - - // 智能判断 URL 风格 - let url = `${endpoint}/${bucket}`; - - // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务进行优化 - // 将 https://oss-cn-beijing.aliyuncs.com/bucket 改为 https://bucket.oss-cn-beijing.aliyuncs.com - const isAliyun = endpoint.includes('aliyuncs.com'); - const isAWS = endpoint.includes('amazonaws.com'); - - if (isAliyun || isAWS) { - try { - const urlObj = new URL(endpoint); - urlObj.hostname = `${bucket}.${urlObj.hostname}`; - url = urlObj.toString(); - // 移除末尾斜杠 - if (url.endsWith('/')) url = url.slice(0, -1); - } catch { - console.warn('[S3] Failed to construct Virtual Hosted URL, falling back to Path Style'); - } - } - + + // 使用统一的 URL 构建函数 + const url = buildS3Url(endpoint, bucket, undefined, config.forcePathStyle); const emptyPayload = new ArrayBuffer(0); const payloadHash = await crypto.subtle.digest('SHA-256', emptyPayload); @@ -310,24 +335,8 @@ export async function uploadImageByS3(file: File): Promise { if (endpoint.endsWith('/')) endpoint = endpoint.slice(0, -1); const bucket = config.bucket.trim(); - let url = `${endpoint}/${bucket}/${key}`; - - // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务进行优化 - const isAliyun = endpoint.includes('aliyuncs.com'); - const isAWS = endpoint.includes('amazonaws.com'); - - if (isAliyun || isAWS) { - try { - const urlObj = new URL(endpoint); - urlObj.hostname = `${bucket}.${urlObj.hostname}`; - // 重新构建 URL,包含 key - url = `${urlObj.toString()}/${key}`; - // 处理可能的双斜杠 - url = url.replace(/([^:]\/)\/+/g, "$1"); - } catch { - console.warn('[S3 Upload] Failed to switch to Virtual Hosted Style'); - } - } + // 使用统一的 URL 构建函数 + const url = buildS3Url(endpoint, bucket, key, config.forcePathStyle); // 读取文件内容 const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); @@ -359,18 +368,8 @@ export async function uploadImageByS3(file: File): Promise { const domain = config.customDomain.trim().replace(/\/+$/, ''); return `${domain}/${key}`; } else { - // 如果使用了 Virtual Hosted Style,返回优化后的 URL - if (isAliyun || isAWS) { - try { - const urlObj = new URL(endpoint); - urlObj.hostname = `${bucket}.${urlObj.hostname}`; - const baseUrl = urlObj.toString().replace(/\/+$/, ''); - return `${baseUrl}/${key}`; - } catch { - return `${endpoint}/${bucket}/${key}`; - } - } - return `${endpoint}/${bucket}/${key}`; + // 使用统一的 URL 构建函数生成访问 URL + return buildS3Url(endpoint, bucket, key, config.forcePathStyle); } } else { const errorText = await response.text(); diff --git a/src/stores/imageHosting.ts b/src/stores/imageHosting.ts index 62835d485..6bda94fce 100644 --- a/src/stores/imageHosting.ts +++ b/src/stores/imageHosting.ts @@ -12,6 +12,7 @@ interface S3Config { endpoint?: string customDomain?: string pathPrefix?: string + forcePathStyle?: boolean } interface MarkState {