@@ -18,21 +18,28 @@ async function generateSignature(
1818 method : string ,
1919 url : string ,
2020 headers : Record < string , string > ,
21- payload : ArrayBuffer ,
21+ payload : BufferSource ,
2222 config : S3Config
2323) {
2424 const algorithm = 'AWS4-HMAC-SHA256' ;
2525 const date = new Date ( ) ;
2626 const dateStamp = date . toISOString ( ) . slice ( 0 , 10 ) . replace ( / - / g, '' ) ;
2727 const amzDate = date . toISOString ( ) . replace ( / [: \- ] | \. \d { 3 } / g, '' ) ;
2828
29+ // 必须将 x-amz-date 加入 headers 参与签名
30+ headers [ 'x-amz-date' ] = amzDate ;
31+
2932 // 创建规范请求
30- const canonicalUri = new URL ( url ) . pathname ;
33+ // 必须对路径进行 URI 编码,但要保留斜杠
34+ const canonicalUri = new URL ( url ) . pathname . split ( '/' ) . map ( encodeURIComponent ) . join ( '/' ) ;
3135 const canonicalQuerystring = '' ;
36+
37+ // AWS V4 签名要求 Headers 的 Key 必须全部转为小写
3238 const canonicalHeaders = Object . keys ( headers )
3339 . sort ( )
34- . map ( key => `${ key . toLowerCase ( ) } :${ headers [ key ] } \n` )
40+ . map ( key => `${ key . toLowerCase ( ) } :${ headers [ key ] . trim ( ) } \n` )
3541 . join ( '' ) ;
42+
3643 const signedHeaders = Object . keys ( headers )
3744 . sort ( )
3845 . map ( key => key . toLowerCase ( ) )
@@ -154,41 +161,121 @@ export async function testS3Connection(config: S3Config): Promise<boolean> {
154161 const proxyUrl = await store . get < string > ( 'proxy' )
155162 const proxy : Proxy | undefined = proxyUrl ? { all : proxyUrl } : undefined
156163
157- const endpoint = config . endpoint || `https://s3.${ config . region } .amazonaws.com` ;
158- const url = ` ${ endpoint } / ${ config . bucket } ` ;
164+ const endpoint = ( config . endpoint || `https://s3.${ config . region } .amazonaws.com` ) . trim ( ) ;
165+ const bucket = config . bucket . trim ( ) ;
159166
167+ // 智能判断 URL 风格
168+ let url = `${ endpoint } /${ bucket } ` ;
169+
170+ // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务进行优化
171+ // 将 https://oss-cn-beijing.aliyuncs.com/bucket 改为 https://bucket.oss-cn-beijing.aliyuncs.com
172+ const isAliyun = endpoint . includes ( 'aliyuncs.com' ) ;
173+ const isAWS = endpoint . includes ( 'amazonaws.com' ) ;
174+
175+ if ( isAliyun || isAWS ) {
176+ try {
177+ const urlObj = new URL ( endpoint ) ;
178+ urlObj . hostname = `${ bucket } .${ urlObj . hostname } ` ;
179+ url = urlObj . toString ( ) ;
180+ // 移除末尾斜杠
181+ if ( url . endsWith ( '/' ) ) url = url . slice ( 0 , - 1 ) ;
182+ console . log ( '[S3] Switched to Virtual Hosted Style for optimization:' , url ) ;
183+ } catch {
184+ console . warn ( '[S3] Failed to construct Virtual Hosted URL, falling back to Path Style' ) ;
185+ }
186+ }
187+
188+ console . log ( '[S3 Test] Testing connection to:' , url ) ;
189+
160190 const emptyPayload = new ArrayBuffer ( 0 ) ;
161191 const payloadHash = await crypto . subtle . digest ( 'SHA-256' , emptyPayload ) ;
162192 const payloadHashHex = Array . from ( new Uint8Array ( payloadHash ) )
163193 . map ( b => b . toString ( 16 ) . padStart ( 2 , '0' ) )
164194 . join ( '' ) ;
165195
166- const headers = {
196+ const headers : Record < string , string > = {
167197 'Host' : new URL ( url ) . host ,
168198 'X-Amz-Content-Sha256' : payloadHashHex
169199 } ;
170200
171- const { authorization, amzDate } = await generateSignature ( 'HEAD' , url , headers , emptyPayload , config ) ;
201+ // 使用 GET 请求代替 HEAD,以便在出错时能获取具体的 XML 错误信息
202+ const method = 'GET' ;
203+ const { authorization, amzDate } = await generateSignature ( method , url , headers , emptyPayload , config ) ;
172204
173205 const requestHeaders = new Headers ( ) ;
174206 requestHeaders . append ( 'Authorization' , authorization ) ;
207+ // 注意:fetch 请求头的键不区分大小写,但为了与签名完全一致,建议保持一致
175208 requestHeaders . append ( 'X-Amz-Date' , amzDate ) ;
176209 requestHeaders . append ( 'X-Amz-Content-Sha256' , payloadHashHex ) ;
177210
178211 const response = await fetch ( url , {
179- method : 'HEAD' ,
212+ method : method ,
180213 headers : requestHeaders ,
181214 proxy
182215 } ) ;
183216
184- if ( response . status !== 200 ) {
185- const errorText = await response . text ( ) ;
186- console . error ( 'S3 Error Response:' , errorText ) ;
217+ if ( response . status === 200 ) {
218+ return true ;
219+ }
220+
221+ // 如果 GET (ListObjects) 失败(可能是只有写权限),尝试 PUT 一个测试文件
222+ if ( response . status === 403 ) {
223+ console . warn ( 'ListObjects (GET) failed with 403, trying PutObject to verify write permission...' ) ;
224+
225+ const testKey = '.connection-test' ;
226+ const testUrl = `${ url } /${ testKey } ` . replace ( / ( [ ^ : ] \/ ) \/ + / g, "$1" ) ;
227+ const testContent = new TextEncoder ( ) . encode ( 'test' ) ;
228+
229+ const putHeaders = {
230+ 'Host' : new URL ( testUrl ) . host ,
231+ 'Content-Type' : 'text/plain' ,
232+ 'Content-Length' : testContent . byteLength . toString ( )
233+ } ;
234+
235+ const { authorization : authPut , amzDate : datePut , payloadHashHex : hashPut } =
236+ await generateSignature ( 'PUT' , testUrl , putHeaders , testContent , config ) ;
237+
238+ const requestPutHeaders = new Headers ( ) ;
239+ requestPutHeaders . append ( 'Authorization' , authPut ) ;
240+ requestPutHeaders . append ( 'X-Amz-Date' , datePut ) ;
241+ requestPutHeaders . append ( 'Content-Type' , 'text/plain' ) ;
242+ requestPutHeaders . append ( 'X-Amz-Content-Sha256' , hashPut ) ;
243+
244+ const putResponse = await fetch ( testUrl , {
245+ method : 'PUT' ,
246+ headers : requestPutHeaders ,
247+ body : testContent ,
248+ proxy
249+ } ) ;
250+
251+ if ( putResponse . status === 200 || putResponse . status === 204 ) {
252+ console . log ( 'PutObject verification successful!' ) ;
253+ return true ;
254+ } else {
255+ const putErrorText = await putResponse . text ( ) ;
256+ console . error ( 'PutObject also failed:' , putResponse . status , putErrorText ) ;
257+ }
187258 }
259+
260+ const errorText = await response . text ( ) ;
261+ console . warn ( 'S3 Check Failed:' , {
262+ status : response . status ,
263+ statusText : response . statusText ,
264+ url : url ,
265+ headers : Object . fromEntries ( response . headers . entries ( ) ) ,
266+ errorBody : errorText || '(empty body)'
267+ } ) ;
188268
189- return response . status === 200 ;
269+ return false ;
190270 } catch ( error ) {
191271 console . error ( 'S3 connection test failed:' , error ) ;
272+
273+ // 尝试提取更有用的错误信息
274+ const errorMessage = ( error as Error ) . message || String ( error ) ;
275+ if ( errorMessage . includes ( 'error sending request' ) ) {
276+ console . warn ( 'Network Error Details: Please check your Endpoint, Region, and Proxy settings. URL might be malformed.' ) ;
277+ }
278+
192279 return false ;
193280 }
194281}
@@ -215,12 +302,35 @@ export async function uploadImageByS3(file: File): Promise<string | undefined> {
215302 const id = uuid ( ) ;
216303 const ext = file . name . split ( '.' ) . pop ( ) || 'jpg' ;
217304 const filename = `${ id } .${ ext } ` . replace ( / \s / g, '_' ) ;
218- const key = config . pathPrefix ? `${ config . pathPrefix } /${ filename } ` : filename ;
305+
306+ // 处理 pathPrefix,移除末尾的斜杠以防止双斜杠问题
307+ const prefix = config . pathPrefix ? config . pathPrefix . trim ( ) . replace ( / \/ + $ / , '' ) : '' ;
308+ const key = prefix ? `${ prefix } /${ filename } ` : filename ;
219309
220310 // 准备上传
221- const endpoint = config . endpoint || `https://s3.${ config . region } .amazonaws.com` ;
222- const url = `${ endpoint } /${ config . bucket } /${ key } ` ;
311+ let endpoint = ( config . endpoint || `https://s3.${ config . region } .amazonaws.com` ) . trim ( ) ;
312+ // 移除 endpoint 末尾的斜杠
313+ if ( endpoint . endsWith ( '/' ) ) endpoint = endpoint . slice ( 0 , - 1 ) ;
314+
315+ const bucket = config . bucket . trim ( ) ;
316+ let url = `${ endpoint } /${ bucket } /${ key } ` ;
317+
318+ // 针对阿里云 OSS、AWS S3 等支持 Virtual Hosted Style 的服务进行优化
319+ const isAliyun = endpoint . includes ( 'aliyuncs.com' ) ;
320+ const isAWS = endpoint . includes ( 'amazonaws.com' ) ;
223321
322+ if ( isAliyun || isAWS ) {
323+ try {
324+ const urlObj = new URL ( endpoint ) ;
325+ urlObj . hostname = `${ bucket } .${ urlObj . hostname } ` ;
326+ // 重新构建 URL,包含 key
327+ url = `${ urlObj . toString ( ) } /${ key } ` ;
328+ // 处理可能的双斜杠
329+ url = url . replace ( / ( [ ^ : ] \/ ) \/ + / g, "$1" ) ;
330+ } catch {
331+ console . warn ( '[S3 Upload] Failed to switch to Virtual Hosted Style' ) ;
332+ }
333+ }
224334 // 读取文件内容
225335 const arrayBuffer = await file . arrayBuffer ( ) ;
226336 const uint8Array = new Uint8Array ( arrayBuffer ) ;
@@ -249,9 +359,21 @@ export async function uploadImageByS3(file: File): Promise<string | undefined> {
249359 if ( response . status === 200 || response . status === 204 ) {
250360 // 返回访问 URL
251361 if ( config . customDomain ) {
252- return `${ config . customDomain } /${ key } ` ;
362+ const domain = config . customDomain . trim ( ) . replace ( / \/ + $ / , '' ) ;
363+ return `${ domain } /${ key } ` ;
253364 } else {
254- return `${ endpoint } /${ config . bucket } /${ key } ` ;
365+ // 如果使用了 Virtual Hosted Style,返回优化后的 URL
366+ if ( isAliyun || isAWS ) {
367+ try {
368+ const urlObj = new URL ( endpoint ) ;
369+ urlObj . hostname = `${ bucket } .${ urlObj . hostname } ` ;
370+ const baseUrl = urlObj . toString ( ) . replace ( / \/ + $ / , '' ) ;
371+ return `${ baseUrl } /${ key } ` ;
372+ } catch {
373+ return `${ endpoint } /${ bucket } /${ key } ` ;
374+ }
375+ }
376+ return `${ endpoint } /${ bucket } /${ key } ` ;
255377 }
256378 } else {
257379 const errorText = await response . text ( ) ;
0 commit comments