Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,8 @@
"advancedSettings": "詳細設定",
"endpoint": "カスタムエンドポイント",
"endpointDesc": "AWS S3 の場合は空白、S3 互換サービスのエンドポイントを入力",
"forcePathStyle": "パススタイルを強制",
"forcePathStyleDesc": "MinIO、Cloudflare R2 など、パススタイル URL が必要なサービスで有効にしてください",
"customDomain": "カスタムドメイン",
"customDomainDesc": "オプション、画像アクセス用のカスタムドメイン",
"pathPrefix": "パスプレフィックス",
Expand Down
2 changes: 2 additions & 0 deletions messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions messages/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,8 @@
"advancedSettings": "進階設定",
"endpoint": "自訂端點",
"endpointDesc": "留空使用 AWS S3,或輸入相容 S3 的服務端點",
"forcePathStyle": "強制 Path Style",
"forcePathStyleDesc": "為 MinIO、Cloudflare R2 或其他需要 Path Style URL 的服務啟用",
"customDomain": "自訂域名",
"customDomainDesc": "可選,用於訪問圖片的自訂域名",
"pathPrefix": "路徑前綴",
Expand Down
2 changes: 2 additions & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,8 @@
"advancedSettings": "高级设置",
"endpoint": "自定义端点",
"endpointDesc": "留空使用 AWS S3,或输入兼容 S3 的服务端点",
"forcePathStyle": "强制 Path Style",
"forcePathStyleDesc": "为 MinIO、Cloudflare R2 或其他需要 Path Style URL 的服务启用",
"customDomain": "自定义域名",
"customDomainDesc": "可选,用于访问图片的自定义域名",
"pathPrefix": "路径前缀",
Expand Down
18 changes: 17 additions & 1 deletion src/app/core/setting/imageHosting/s3.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -21,6 +22,7 @@ interface S3Config {
endpoint?: string
customDomain?: string
pathPrefix?: string
forcePathStyle?: boolean
}

export function S3ImageHosting() {
Expand All @@ -34,7 +36,8 @@ export function S3ImageHosting() {
bucket: '',
endpoint: '',
customDomain: '',
pathPrefix: ''
pathPrefix: '',
forcePathStyle: false
});

const [showSecretKey, setShowSecretKey] = useState(false);
Expand Down Expand Up @@ -239,6 +242,19 @@ export function S3ImageHosting() {
placeholder="https://s3.amazonaws.com"
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="forcePathStyle">{t('settings.imageHosting.s3.forcePathStyle')}</Label>
<p className="text-xs text-muted-foreground">
{t('settings.imageHosting.s3.forcePathStyleDesc')}
</p>
</div>
<Switch
id="forcePathStyle"
checked={config.forcePathStyle || false}
onCheckedChange={(checked) => handleConfigChange({ ...config, forcePathStyle: checked })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="customDomain">{t('settings.imageHosting.s3.customDomain')}</Label>
<Input
Expand Down
103 changes: 51 additions & 52 deletions src/lib/imageHosting/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface S3Config {
endpoint?: string
customDomain?: string
pathPrefix?: string
forcePathStyle?: boolean
}

// 生成 AWS 签名 V4 (使用 Web Crypto API)
Expand Down Expand Up @@ -154,36 +155,60 @@ async function getSignatureKey(key: string, dateStamp: string, regionName: strin
);
}

// 已知支持 Virtual Hosted Style 的 S3 兼容服务
const VIRTUAL_HOSTED_SERVICES = [
'amazonaws.com', // AWS S3
'aliyuncs.com', // Alibaba Cloud OSS
'myqcloud.com', // Tencent Cloud COS
'digitaloceanspaces.com', // DigitalOcean Spaces
'wasabisys.com', // Wasabi
];

function isVirtualHostedService(endpoint: string): boolean {
return VIRTUAL_HOSTED_SERVICES.some(domain => 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<boolean> {
try {
const store = await Store.load('store.json');
const proxyUrl = await store.get<string>('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);
Expand Down Expand Up @@ -310,24 +335,8 @@ export async function uploadImageByS3(file: File): Promise<string | undefined> {
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);
Expand Down Expand Up @@ -359,18 +368,8 @@ export async function uploadImageByS3(file: File): Promise<string | undefined> {
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();
Expand Down
1 change: 1 addition & 0 deletions src/stores/imageHosting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface S3Config {
endpoint?: string
customDomain?: string
pathPrefix?: string
forcePathStyle?: boolean
}

interface MarkState {
Expand Down