From 16233337cbd7690d4098f687e5ea9042e5862004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 8 Sep 2026 11:50:03 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=B8=A6?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=89=8D=E7=BC=80=E7=9A=84=20S3=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=EF=BC=88=E5=A6=82=20Supabase=EF=BC=89=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3Client 只取 endpoint 的协议/主机/端口,丢掉了 endpoint 自带的路径前缀, 请求与签名的 canonical URI 都落在服务根路径上,Supabase 的 https://.storage.supabase.co/storage/v1/s3 因此在验证阶段返回 404 NotFound。 统一由 getResourcePath 生成请求路径与 canonical URI,保留 endpoint 路径前缀。 close #1723 --- packages/filesystem/s3/client.test.ts | 74 +++++++++++++++++++++++++++ packages/filesystem/s3/client.ts | 37 ++++++-------- 2 files changed, 91 insertions(+), 20 deletions(-) diff --git a/packages/filesystem/s3/client.test.ts b/packages/filesystem/s3/client.test.ts index bd9504278..341e80f5f 100644 --- a/packages/filesystem/s3/client.test.ts +++ b/packages/filesystem/s3/client.test.ts @@ -62,6 +62,15 @@ describe("S3Client", () => { expect(client.getEndpointUrl()).toBe("https://minio.example.com"); }); + it("应当保留 endpoint 中的路径前缀", () => { + const client = new S3Client({ + ...defaultConfig, + endpoint: "https://abcdefg.supabase.co/storage/v1/s3", + }); + + expect(client.getEndpointUrl()).toBe("https://abcdefg.supabase.co/storage/v1/s3"); + }); + it("应当支持 http:// 协议的 endpoint", () => { const client = new S3Client({ ...defaultConfig, @@ -280,6 +289,71 @@ describe("S3Client", () => { expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket"); }); + it("应当在 endpoint 带路径前缀时把前缀带进 path-style 请求 URL", async () => { + const prefixClient = new S3Client({ + ...defaultConfig, + endpoint: "https://abcdefg.supabase.co/storage/v1/s3", + }); + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await prefixClient.request("GET", "my-bucket", "folder/file.txt"); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://abcdefg.supabase.co/storage/v1/s3/my-bucket/folder/file.txt"); + }); + + it("应当在 endpoint 带路径前缀时把前缀带进 virtual-hosted 请求 URL", async () => { + const prefixClient = new S3Client({ + ...defaultConfig, + endpoint: "https://s3.example.com/gateway", + forcePathStyle: false, + }); + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await prefixClient.request("GET", "my-bucket", "file.txt"); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://my-bucket.s3.example.com/gateway/file.txt"); + }); + + it("应当在 endpoint 带路径前缀时把前缀纳入签名的 canonical URI", async () => { + // 两个 client 的绝对请求路径完全相同(/storage/v1/s3/my-bucket/file.txt), + // 只有 endpoint 前缀与 bucket/key 的切分位置不同;签名只取决于绝对路径,因此必须一致。 + const viaEndpointPrefix = new S3Client({ + ...defaultConfig, + endpoint: "https://abcdefg.supabase.co/storage/v1/s3", + }); + const viaBucketPath = new S3Client({ + ...defaultConfig, + endpoint: "https://abcdefg.supabase.co/storage/v1", + }); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await viaEndpointPrefix.request("GET", "my-bucket", "file.txt"); + await viaBucketPath.request("GET", "s3", "my-bucket/file.txt"); + vi.useRealTimers(); + + const [url1, options1] = fetchSpy.mock.calls[0]; + const [url2, options2] = fetchSpy.mock.calls[1]; + expect(url1).toBe(url2); + expect(options1.headers["authorization"]).toBe(options2.headers["authorization"]); + }); + + it("应当忽略 endpoint 路径前缀末尾的斜杠", async () => { + const prefixClient = new S3Client({ + ...defaultConfig, + endpoint: "https://abcdefg.supabase.co/storage/v1/s3/", + }); + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await prefixClient.request("HEAD", "my-bucket"); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://abcdefg.supabase.co/storage/v1/s3/my-bucket"); + }); + it("应当正确处理包含特殊字符的 key", async () => { fetchSpy.mockResolvedValue(new Response("", { status: 200 })); diff --git a/packages/filesystem/s3/client.ts b/packages/filesystem/s3/client.ts index 7b28f87aa..3b1142331 100644 --- a/packages/filesystem/s3/client.ts +++ b/packages/filesystem/s3/client.ts @@ -130,6 +130,8 @@ export class S3Client { private config: Required>; private parsedEndpoint: URL; private customEndpoint: boolean; + /** endpoint 自带的路径前缀(如 Supabase 的 /storage/v1/s3),根路径时为空串 */ + private basePath: string; constructor(config: S3ClientConfig) { this.config = { @@ -151,6 +153,7 @@ export class S3Client { // 去除尾部斜杠 endpoint = endpoint.replace(/\/+$/, ""); this.parsedEndpoint = new URL(endpoint); + this.basePath = this.parsedEndpoint.pathname.replace(/\/+$/, ""); } /** 获取请求的 Host */ @@ -163,30 +166,24 @@ export class S3Client { return `${bucket}.${hostWithPort}`; } - /** 获取签名用的 Canonical URI */ - private getCanonicalUri(bucket: string, key?: string): string { - if (this.config.forcePathStyle) { - let uri = `/${awsUriEncode(bucket)}`; - if (key) uri += `/${awsUriEncode(key, false)}`; - return uri; - } - if (key) return `/${awsUriEncode(key, false)}`; - return "/"; + /** + * 获取请求资源路径 + * endpoint 自带的路径前缀(如 Supabase 的 /storage/v1/s3)必须保留,否则请求会打到服务的根路径上。 + * 由 URL 解析出的前缀已完成百分号编码,直接拼接,不重复编码。 + */ + private getResourcePath(bucket: string, key?: string): string { + let path = this.basePath; + if (this.config.forcePathStyle) path += `/${awsUriEncode(bucket)}`; + if (key) path += `/${awsUriEncode(key, false)}`; + return path || "/"; } /** 构建请求 URL */ private buildUrl(bucket: string, key?: string, queryParams?: Record): string { const proto = this.parsedEndpoint.protocol; const host = this.getHost(bucket); - let path: string; - if (this.config.forcePathStyle) { - path = `/${bucket}`; - if (key) path += `/${awsUriEncode(key, false)}`; - } else { - path = key ? `/${awsUriEncode(key, false)}` : "/"; - } - let url = `${proto}//${host}${path}`; + let url = `${proto}//${host}${this.getResourcePath(bucket, key)}`; if (queryParams && Object.keys(queryParams).length > 0) { const qs = Object.entries(queryParams) .sort(([a], [b]) => a.localeCompare(b)) @@ -219,7 +216,7 @@ export class S3Client { headers["x-amz-content-sha256"] = payloadHash; // 构建 Canonical Request - const canonicalUri = this.getCanonicalUri(bucket, key); + const canonicalUri = this.getResourcePath(bucket, key); const canonicalQueryString = Object.entries(queryParams) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${awsUriEncode(k)}=${awsUriEncode(v)}`) @@ -322,9 +319,9 @@ export class S3Client { return response; } - /** 获取 endpoint URL */ + /** 获取 endpoint URL(含路径前缀) */ getEndpointUrl(): string { - return this.parsedEndpoint.origin; + return this.parsedEndpoint.origin + this.basePath; } /** 是否使用了自定义 endpoint */