Skip to content

Commit 0cf1151

Browse files
CodFrmcyfung1031
andauthored
✨ 增加 Amazon S3 存储 #1146 (#1189)
* 增加s3存储 #1146 * fix * fix * fix 02 * lint * PutObjectCommand 的 Body 可以直接写入 Blob content * added comment * lint * implementation fix * 3.981.0 -> 3.989.0 * Update pnpm-lock.yaml * 重构s3 * 从config中获取region * 使用 fast-xml-parser 处理xml解析 * 添加单元测试 --------- Co-authored-by: cyfung1031 <44498510+cyfung1031@users.noreply.github.com>
1 parent f14b538 commit 0cf1151

17 files changed

Lines changed: 1497 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"dexie": "^4.0.10",
3939
"eslint-linter-browserify": "9.26.0",
4040
"eventemitter3": "^5.0.1",
41+
"fast-xml-parser": "^5.3.6",
4142
"i18next": "^23.16.4",
4243
"monaco-editor": "^0.52.2",
4344
"react": "^18.3.1",

packages/filesystem/factory.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import OneDriveFileSystem from "./onedrive/onedrive";
55
import DropboxFileSystem from "./dropbox/dropbox";
66
import WebDAVFileSystem from "./webdav/webdav";
77
import ZipFileSystem from "./zip/zip";
8+
import S3FileSystem from "./s3/s3";
89
import { t } from "@App/locales/locales";
910
import LimiterFileSystem from "./limiter";
1011

11-
export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox";
12+
export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox" | "s3";
1213

1314
export type FileSystemParams = {
1415
[key: string]: {
@@ -40,6 +41,15 @@ export default class FileSystemFactory {
4041
case "dropbox":
4142
fs = new DropboxFileSystem();
4243
break;
44+
case "s3":
45+
fs = new S3FileSystem(
46+
params.bucket,
47+
params.region,
48+
params.accessKeyId,
49+
params.secretAccessKey,
50+
params.endpoint
51+
);
52+
break;
4353
default:
4454
throw new Error("not found filesystem");
4555
}
@@ -63,6 +73,13 @@ export default class FileSystemFactory {
6373
onedrive: {},
6474
googledrive: {},
6575
dropbox: {},
76+
s3: {
77+
bucket: { title: t("s3_bucket_name") },
78+
region: { title: t("s3_region") },
79+
accessKeyId: { title: t("s3_access_key_id") },
80+
secretAccessKey: { title: t("s3_secret_access_key"), type: "password" },
81+
endpoint: { title: t("s3_custom_endpoint") },
82+
},
6683
};
6784
}
6885

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2+
import { S3Client, S3Error } from "./client";
3+
import type { S3ClientConfig } from "./client";
4+
5+
// ---- S3Error ----
6+
describe("S3Error", () => {
7+
it("应当正确设置 code、message、statusCode 属性", () => {
8+
const err = new S3Error("NoSuchKey", "The specified key does not exist", 404);
9+
10+
expect(err).toBeInstanceOf(Error);
11+
expect(err).toBeInstanceOf(S3Error);
12+
expect(err.code).toBe("NoSuchKey");
13+
expect(err.name).toBe("NoSuchKey"); // 兼容 SDK error.name 检查
14+
expect(err.message).toBe("The specified key does not exist");
15+
expect(err.statusCode).toBe(404);
16+
});
17+
18+
it("应当可被 try/catch 捕获并通过 instanceof 判断", () => {
19+
try {
20+
throw new S3Error("AccessDenied", "Access Denied", 403);
21+
} catch (e) {
22+
expect(e).toBeInstanceOf(S3Error);
23+
if (e instanceof S3Error) {
24+
expect(e.code).toBe("AccessDenied");
25+
expect(e.statusCode).toBe(403);
26+
}
27+
}
28+
});
29+
});
30+
31+
// ---- S3Client 构造函数与 getter 方法 ----
32+
describe("S3Client", () => {
33+
const defaultConfig: S3ClientConfig = {
34+
region: "us-west-2",
35+
credentials: {
36+
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
37+
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
38+
},
39+
};
40+
41+
describe("constructor", () => {
42+
it("应当使用默认 AWS endpoint 当未指定 endpoint 时", () => {
43+
const client = new S3Client(defaultConfig);
44+
45+
expect(client.getEndpointUrl()).toBe("https://s3.us-west-2.amazonaws.com");
46+
expect(client.hasCustomEndpoint()).toBe(false);
47+
});
48+
49+
it("应当使用自定义 endpoint", () => {
50+
const client = new S3Client({
51+
...defaultConfig,
52+
endpoint: "https://minio.example.com:9000",
53+
});
54+
55+
expect(client.getEndpointUrl()).toBe("https://minio.example.com:9000");
56+
expect(client.hasCustomEndpoint()).toBe(true);
57+
});
58+
59+
it("应当为无协议前缀的 endpoint 自动添加 https://", () => {
60+
const client = new S3Client({
61+
...defaultConfig,
62+
endpoint: "s3.custom.com",
63+
});
64+
65+
expect(client.getEndpointUrl()).toBe("https://s3.custom.com");
66+
});
67+
68+
it("应当去除 endpoint 末尾的斜杠", () => {
69+
const client = new S3Client({
70+
...defaultConfig,
71+
endpoint: "https://minio.example.com///",
72+
});
73+
74+
expect(client.getEndpointUrl()).toBe("https://minio.example.com");
75+
});
76+
77+
it("应当支持 http:// 协议的 endpoint", () => {
78+
const client = new S3Client({
79+
...defaultConfig,
80+
endpoint: "http://localhost:9000",
81+
});
82+
83+
expect(client.getEndpointUrl()).toBe("http://localhost:9000");
84+
});
85+
86+
it("应当默认 forcePathStyle 为 true", () => {
87+
const client = new S3Client(defaultConfig);
88+
89+
expect(client.isForcePathStyle()).toBe(true);
90+
});
91+
92+
it("应当允许设置 forcePathStyle 为 false", () => {
93+
const client = new S3Client({
94+
...defaultConfig,
95+
forcePathStyle: false,
96+
});
97+
98+
expect(client.isForcePathStyle()).toBe(false);
99+
});
100+
101+
it("应当正确返回 region", () => {
102+
const client = new S3Client(defaultConfig);
103+
expect(client.getRegion()).toBe("us-west-2");
104+
});
105+
106+
it("应当在 region 为空字符串时默认使用 us-east-1", () => {
107+
const client = new S3Client({
108+
...defaultConfig,
109+
region: "",
110+
});
111+
112+
expect(client.getRegion()).toBe("us-east-1");
113+
});
114+
});
115+
116+
// ---- request 方法 ----
117+
describe("request", () => {
118+
let client: S3Client;
119+
let fetchSpy: ReturnType<typeof vi.fn>;
120+
121+
beforeEach(() => {
122+
client = new S3Client({
123+
...defaultConfig,
124+
endpoint: "https://s3.us-west-2.amazonaws.com",
125+
});
126+
fetchSpy = vi.fn();
127+
vi.stubGlobal("fetch", fetchSpy);
128+
});
129+
130+
afterEach(() => {
131+
vi.unstubAllGlobals();
132+
});
133+
134+
it("应当发送 GET 请求并返回 Response", async () => {
135+
const mockResponse = new Response("hello", { status: 200, statusText: "OK" });
136+
fetchSpy.mockResolvedValue(mockResponse);
137+
138+
const resp = await client.request("GET", "my-bucket", "test-key.txt");
139+
140+
expect(resp).toBe(mockResponse);
141+
expect(fetchSpy).toHaveBeenCalledTimes(1);
142+
143+
// 验证 URL(path-style)
144+
const [url, options] = fetchSpy.mock.calls[0];
145+
expect(url).toContain("/my-bucket/test-key.txt");
146+
expect(options.method).toBe("GET");
147+
});
148+
149+
it("应当在 path-style 模式下构建正确的 URL", async () => {
150+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
151+
152+
await client.request("GET", "my-bucket", "folder/file.txt");
153+
154+
const [url] = fetchSpy.mock.calls[0];
155+
expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket/folder/file.txt");
156+
});
157+
158+
it("应当在 virtual-hosted 模式下构建正确的 URL", async () => {
159+
const vhClient = new S3Client({
160+
...defaultConfig,
161+
forcePathStyle: false,
162+
});
163+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
164+
165+
await vhClient.request("GET", "my-bucket", "file.txt");
166+
167+
const [url] = fetchSpy.mock.calls[0];
168+
expect(url).toBe("https://my-bucket.s3.us-west-2.amazonaws.com/file.txt");
169+
});
170+
171+
it("应当在请求头中包含 AWS Signature V4 签名", async () => {
172+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
173+
174+
await client.request("GET", "my-bucket", "test.txt");
175+
176+
const [, options] = fetchSpy.mock.calls[0];
177+
const headers = options.headers;
178+
179+
// 验证签名头存在
180+
expect(headers["authorization"]).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\//);
181+
expect(headers["authorization"]).toContain("SignedHeaders=");
182+
expect(headers["authorization"]).toContain("Signature=");
183+
expect(headers["x-amz-date"]).toMatch(/^\d{8}T\d{6}Z$/);
184+
expect(headers["x-amz-content-sha256"]).toBeDefined();
185+
});
186+
187+
it("应当正确传递 query parameters", async () => {
188+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
189+
190+
await client.request("GET", "my-bucket", undefined, {
191+
queryParams: { "list-type": "2", prefix: "docs/" },
192+
});
193+
194+
const [url] = fetchSpy.mock.calls[0];
195+
expect(url).toContain("list-type=2");
196+
expect(url).toContain("prefix=docs%2F");
197+
});
198+
199+
it("应当正确传递 string body 的 PUT 请求", async () => {
200+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
201+
202+
await client.request("PUT", "my-bucket", "file.txt", {
203+
body: "file content",
204+
headers: { "Content-Type": "text/plain" },
205+
});
206+
207+
const [, options] = fetchSpy.mock.calls[0];
208+
expect(options.method).toBe("PUT");
209+
expect(options.body).toBe("file content");
210+
});
211+
212+
it("应当正确传递 Uint8Array body", async () => {
213+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
214+
215+
const body = new TextEncoder().encode("binary data");
216+
await client.request("PUT", "my-bucket", "binary.bin", { body });
217+
218+
const [url, options] = fetchSpy.mock.calls[0];
219+
expect(url).toContain("/my-bucket/binary.bin");
220+
expect(options.method).toBe("PUT");
221+
// body 应当是 Uint8Array.buffer(ArrayBuffer 或兼容类型)
222+
expect(options.body).toBeDefined();
223+
expect(options.body).not.toBeTypeOf("string");
224+
});
225+
226+
it("应当在非 2xx 响应时抛出 S3Error(XML 错误体)", async () => {
227+
const errorXml = `<?xml version="1.0" encoding="UTF-8"?>
228+
<Error>
229+
<Code>NoSuchKey</Code>
230+
<Message>The specified key does not exist.</Message>
231+
</Error>`;
232+
fetchSpy.mockResolvedValue(new Response(errorXml, { status: 404, statusText: "Not Found" }));
233+
234+
try {
235+
await client.request("GET", "my-bucket", "nonexistent.txt");
236+
expect.unreachable("should have thrown");
237+
} catch (e) {
238+
expect(e).toBeInstanceOf(S3Error);
239+
if (e instanceof S3Error) {
240+
expect(e.code).toBe("NoSuchKey");
241+
expect(e.statusCode).toBe(404);
242+
expect(e.message).toBe("The specified key does not exist.");
243+
}
244+
}
245+
});
246+
247+
it("应当在无 XML 体的错误响应时使用状态码映射", async () => {
248+
fetchSpy.mockResolvedValue(new Response("", { status: 403, statusText: "Forbidden" }));
249+
250+
await expect(client.request("HEAD", "my-bucket")).rejects.toSatisfy((e: S3Error) => {
251+
return e instanceof S3Error && e.statusCode === 403;
252+
});
253+
});
254+
255+
it("应当在 DELETE 请求成功时返回 Response", async () => {
256+
// jsdom 不支持 204 状态码构造 Response,使用 200 代替验证 DELETE 请求逻辑
257+
fetchSpy.mockResolvedValue(new Response(null, { status: 200, statusText: "OK" }));
258+
259+
const resp = await client.request("DELETE", "my-bucket", "file.txt");
260+
expect(resp.status).toBe(200);
261+
});
262+
263+
it("应当不在 fetch headers 中包含 host 头", async () => {
264+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
265+
266+
await client.request("GET", "my-bucket", "test.txt");
267+
268+
const [, options] = fetchSpy.mock.calls[0];
269+
expect(options.headers["host"]).toBeUndefined();
270+
});
271+
272+
it("应当将自定义 headers 的 key 转为小写", async () => {
273+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
274+
275+
await client.request("PUT", "my-bucket", "test.txt", {
276+
body: "data",
277+
headers: { "Content-Type": "text/plain", "X-Custom-Header": "value" },
278+
});
279+
280+
const [, options] = fetchSpy.mock.calls[0];
281+
// authorization 中 SignedHeaders 应包含小写 key
282+
expect(options.headers["authorization"]).toContain("content-type");
283+
expect(options.headers["authorization"]).toContain("x-custom-header");
284+
});
285+
286+
it("应当在不传 key 时只使用 bucket 路径", async () => {
287+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
288+
289+
await client.request("HEAD", "my-bucket");
290+
291+
const [url] = fetchSpy.mock.calls[0];
292+
expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket");
293+
});
294+
295+
it("应当正确处理包含特殊字符的 key", async () => {
296+
fetchSpy.mockResolvedValue(new Response("", { status: 200 }));
297+
298+
await client.request("GET", "my-bucket", "path/to/file with spaces.txt");
299+
300+
const [url] = fetchSpy.mock.calls[0];
301+
expect(url).toContain("file%20with%20spaces.txt");
302+
});
303+
});
304+
});

0 commit comments

Comments
 (0)