Skip to content

Commit 66a228f

Browse files
xianggaucnbcodexu
authored
fix(#796): 改进了 NoteGen 的 S3 图床功能兼容性支持阿里云oss连接 (#798)
* feat(image-hosting): 添加S3存储图片上传和连接测试功能 实现通过AWS S3签名V4协议上传图片和测试连接的功能,支持阿里云OSS和AWS S3的虚拟托管风格优化,包含错误处理和代理配置 * fix(s3): fix unused variable lint errors * refactor(imageHosting): 将payload类型从ArrayBuffer改为BufferSource fix: 删除重复的s3 copy.ts文件并清理ja.json中的无用翻译 --------- Co-authored-by: cnb <your-email@example.com> Co-authored-by: codexu <461229187@qq.com>
1 parent 0e56653 commit 66a228f

2 files changed

Lines changed: 140 additions & 18 deletions

File tree

messages/ja.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1738,4 +1738,4 @@
17381738
"quickRecord": {
17391739
"description": "記録ツールを選択して、素早く記録を作成"
17401740
}
1741-
}
1741+
}

src/lib/imageHosting/s3.ts

Lines changed: 139 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)