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
74 changes: 74 additions & 0 deletions packages/filesystem/s3/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }));

Expand Down
37 changes: 17 additions & 20 deletions packages/filesystem/s3/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export class S3Client {
private config: Required<Pick<S3ClientConfig, "region" | "credentials" | "forcePathStyle">>;
private parsedEndpoint: URL;
private customEndpoint: boolean;
/** endpoint 自带的路径前缀(如 Supabase 的 /storage/v1/s3),根路径时为空串 */
private basePath: string;

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

/** 获取请求的 Host */
Expand All @@ -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, string>): 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))
Expand Down Expand Up @@ -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)}`)
Expand Down Expand Up @@ -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 */
Expand Down
Loading