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 {