Skip to content

Commit 44095fe

Browse files
authored
🐛 修复带路径前缀的 S3 端点(如 Supabase)同步失败 (#1729)
S3Client 只取 endpoint 的协议/主机/端口,丢掉了 endpoint 自带的路径前缀, 请求与签名的 canonical URI 都落在服务根路径上,Supabase 的 https://<ref>.storage.supabase.co/storage/v1/s3 因此在验证阶段返回 404 NotFound。 统一由 getResourcePath 生成请求路径与 canonical URI,保留 endpoint 路径前缀。 close #1723
1 parent fe4f26b commit 44095fe

2 files changed

Lines changed: 91 additions & 20 deletions

File tree

packages/filesystem/s3/client.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ describe("S3Client", () => {
6262
expect(client.getEndpointUrl()).toBe("https://minio.example.com");
6363
});
6464

65+
it("应当保留 endpoint 中的路径前缀", () => {
66+
const client = new S3Client({
67+
...defaultConfig,
68+
endpoint: "https://abcdefg.supabase.co/storage/v1/s3",
69+
});
70+
71+
expect(client.getEndpointUrl()).toBe("https://abcdefg.supabase.co/storage/v1/s3");
72+
});
73+
6574
it("应当支持 http:// 协议的 endpoint", () => {
6675
const client = new S3Client({
6776
...defaultConfig,
@@ -280,6 +289,71 @@ describe("S3Client", () => {
280289
expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket");
281290
});
282291

292+
it("应当在 endpoint 带路径前缀时把前缀带进 path-style 请求 URL", async () => {
293+
const prefixClient = new S3Client({
294+
...defaultConfig,
295+
endpoint: "https://abcdefg.supabase.co/storage/v1/s3",
296+
});
297+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
298+
299+
await prefixClient.request("GET", "my-bucket", "folder/file.txt");
300+
301+
const [url] = fetchSpy.mock.calls[0];
302+
expect(url).toBe("https://abcdefg.supabase.co/storage/v1/s3/my-bucket/folder/file.txt");
303+
});
304+
305+
it("应当在 endpoint 带路径前缀时把前缀带进 virtual-hosted 请求 URL", async () => {
306+
const prefixClient = new S3Client({
307+
...defaultConfig,
308+
endpoint: "https://s3.example.com/gateway",
309+
forcePathStyle: false,
310+
});
311+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
312+
313+
await prefixClient.request("GET", "my-bucket", "file.txt");
314+
315+
const [url] = fetchSpy.mock.calls[0];
316+
expect(url).toBe("https://my-bucket.s3.example.com/gateway/file.txt");
317+
});
318+
319+
it("应当在 endpoint 带路径前缀时把前缀纳入签名的 canonical URI", async () => {
320+
// 两个 client 的绝对请求路径完全相同(/storage/v1/s3/my-bucket/file.txt),
321+
// 只有 endpoint 前缀与 bucket/key 的切分位置不同;签名只取决于绝对路径,因此必须一致。
322+
const viaEndpointPrefix = new S3Client({
323+
...defaultConfig,
324+
endpoint: "https://abcdefg.supabase.co/storage/v1/s3",
325+
});
326+
const viaBucketPath = new S3Client({
327+
...defaultConfig,
328+
endpoint: "https://abcdefg.supabase.co/storage/v1",
329+
});
330+
vi.useFakeTimers();
331+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
332+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
333+
334+
await viaEndpointPrefix.request("GET", "my-bucket", "file.txt");
335+
await viaBucketPath.request("GET", "s3", "my-bucket/file.txt");
336+
vi.useRealTimers();
337+
338+
const [url1, options1] = fetchSpy.mock.calls[0];
339+
const [url2, options2] = fetchSpy.mock.calls[1];
340+
expect(url1).toBe(url2);
341+
expect(options1.headers["authorization"]).toBe(options2.headers["authorization"]);
342+
});
343+
344+
it("应当忽略 endpoint 路径前缀末尾的斜杠", async () => {
345+
const prefixClient = new S3Client({
346+
...defaultConfig,
347+
endpoint: "https://abcdefg.supabase.co/storage/v1/s3/",
348+
});
349+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
350+
351+
await prefixClient.request("HEAD", "my-bucket");
352+
353+
const [url] = fetchSpy.mock.calls[0];
354+
expect(url).toBe("https://abcdefg.supabase.co/storage/v1/s3/my-bucket");
355+
});
356+
283357
it("应当正确处理包含特殊字符的 key", async () => {
284358
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
285359

packages/filesystem/s3/client.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ export class S3Client {
130130
private config: Required<Pick<S3ClientConfig, "region" | "credentials" | "forcePathStyle">>;
131131
private parsedEndpoint: URL;
132132
private customEndpoint: boolean;
133+
/** endpoint 自带的路径前缀(如 Supabase 的 /storage/v1/s3),根路径时为空串 */
134+
private basePath: string;
133135

134136
constructor(config: S3ClientConfig) {
135137
this.config = {
@@ -151,6 +153,7 @@ export class S3Client {
151153
// 去除尾部斜杠
152154
endpoint = endpoint.replace(/\/+$/, "");
153155
this.parsedEndpoint = new URL(endpoint);
156+
this.basePath = this.parsedEndpoint.pathname.replace(/\/+$/, "");
154157
}
155158

156159
/** 获取请求的 Host */
@@ -163,30 +166,24 @@ export class S3Client {
163166
return `${bucket}.${hostWithPort}`;
164167
}
165168

166-
/** 获取签名用的 Canonical URI */
167-
private getCanonicalUri(bucket: string, key?: string): string {
168-
if (this.config.forcePathStyle) {
169-
let uri = `/${awsUriEncode(bucket)}`;
170-
if (key) uri += `/${awsUriEncode(key, false)}`;
171-
return uri;
172-
}
173-
if (key) return `/${awsUriEncode(key, false)}`;
174-
return "/";
169+
/**
170+
* 获取请求资源路径
171+
* endpoint 自带的路径前缀(如 Supabase 的 /storage/v1/s3)必须保留,否则请求会打到服务的根路径上。
172+
* 由 URL 解析出的前缀已完成百分号编码,直接拼接,不重复编码。
173+
*/
174+
private getResourcePath(bucket: string, key?: string): string {
175+
let path = this.basePath;
176+
if (this.config.forcePathStyle) path += `/${awsUriEncode(bucket)}`;
177+
if (key) path += `/${awsUriEncode(key, false)}`;
178+
return path || "/";
175179
}
176180

177181
/** 构建请求 URL */
178182
private buildUrl(bucket: string, key?: string, queryParams?: Record<string, string>): string {
179183
const proto = this.parsedEndpoint.protocol;
180184
const host = this.getHost(bucket);
181-
let path: string;
182-
if (this.config.forcePathStyle) {
183-
path = `/${bucket}`;
184-
if (key) path += `/${awsUriEncode(key, false)}`;
185-
} else {
186-
path = key ? `/${awsUriEncode(key, false)}` : "/";
187-
}
188185

189-
let url = `${proto}//${host}${path}`;
186+
let url = `${proto}//${host}${this.getResourcePath(bucket, key)}`;
190187
if (queryParams && Object.keys(queryParams).length > 0) {
191188
const qs = Object.entries(queryParams)
192189
.sort(([a], [b]) => a.localeCompare(b))
@@ -219,7 +216,7 @@ export class S3Client {
219216
headers["x-amz-content-sha256"] = payloadHash;
220217

221218
// 构建 Canonical Request
222-
const canonicalUri = this.getCanonicalUri(bucket, key);
219+
const canonicalUri = this.getResourcePath(bucket, key);
223220
const canonicalQueryString = Object.entries(queryParams)
224221
.sort(([a], [b]) => a.localeCompare(b))
225222
.map(([k, v]) => `${awsUriEncode(k)}=${awsUriEncode(v)}`)
@@ -322,9 +319,9 @@ export class S3Client {
322319
return response;
323320
}
324321

325-
/** 获取 endpoint URL */
322+
/** 获取 endpoint URL(含路径前缀) */
326323
getEndpointUrl(): string {
327-
return this.parsedEndpoint.origin;
324+
return this.parsedEndpoint.origin + this.basePath;
328325
}
329326

330327
/** 是否使用了自定义 endpoint */

0 commit comments

Comments
 (0)