diff --git a/package.json b/package.json index fa7228c66..67aa9f829 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "dexie": "^4.0.10", "eslint-linter-browserify": "9.26.0", "eventemitter3": "^5.0.1", + "fast-xml-parser": "^5.3.6", "i18next": "^23.16.4", "monaco-editor": "^0.52.2", "react": "^18.3.1", diff --git a/packages/filesystem/factory.ts b/packages/filesystem/factory.ts index 24aca3e4c..bdeb26e16 100644 --- a/packages/filesystem/factory.ts +++ b/packages/filesystem/factory.ts @@ -5,10 +5,11 @@ import OneDriveFileSystem from "./onedrive/onedrive"; import DropboxFileSystem from "./dropbox/dropbox"; import WebDAVFileSystem from "./webdav/webdav"; import ZipFileSystem from "./zip/zip"; +import S3FileSystem from "./s3/s3"; import { t } from "@App/locales/locales"; import LimiterFileSystem from "./limiter"; -export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox"; +export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox" | "s3"; export type FileSystemParams = { [key: string]: { @@ -40,6 +41,15 @@ export default class FileSystemFactory { case "dropbox": fs = new DropboxFileSystem(); break; + case "s3": + fs = new S3FileSystem( + params.bucket, + params.region, + params.accessKeyId, + params.secretAccessKey, + params.endpoint + ); + break; default: throw new Error("not found filesystem"); } @@ -63,6 +73,13 @@ export default class FileSystemFactory { onedrive: {}, googledrive: {}, dropbox: {}, + s3: { + bucket: { title: t("s3_bucket_name") }, + region: { title: t("s3_region") }, + accessKeyId: { title: t("s3_access_key_id") }, + secretAccessKey: { title: t("s3_secret_access_key"), type: "password" }, + endpoint: { title: t("s3_custom_endpoint") }, + }, }; } diff --git a/packages/filesystem/s3/client.test.ts b/packages/filesystem/s3/client.test.ts new file mode 100644 index 000000000..29bc234a8 --- /dev/null +++ b/packages/filesystem/s3/client.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { S3Client, S3Error } from "./client"; +import type { S3ClientConfig } from "./client"; + +// ---- S3Error ---- +describe("S3Error", () => { + it("应当正确设置 code、message、statusCode 属性", () => { + const err = new S3Error("NoSuchKey", "The specified key does not exist", 404); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(S3Error); + expect(err.code).toBe("NoSuchKey"); + expect(err.name).toBe("NoSuchKey"); // 兼容 SDK error.name 检查 + expect(err.message).toBe("The specified key does not exist"); + expect(err.statusCode).toBe(404); + }); + + it("应当可被 try/catch 捕获并通过 instanceof 判断", () => { + try { + throw new S3Error("AccessDenied", "Access Denied", 403); + } catch (e) { + expect(e).toBeInstanceOf(S3Error); + if (e instanceof S3Error) { + expect(e.code).toBe("AccessDenied"); + expect(e.statusCode).toBe(403); + } + } + }); +}); + +// ---- S3Client 构造函数与 getter 方法 ---- +describe("S3Client", () => { + const defaultConfig: S3ClientConfig = { + region: "us-west-2", + credentials: { + accessKeyId: "AKIAIOSFODNN7EXAMPLE", + secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + }, + }; + + describe("constructor", () => { + it("应当使用默认 AWS endpoint 当未指定 endpoint 时", () => { + const client = new S3Client(defaultConfig); + + expect(client.getEndpointUrl()).toBe("https://s3.us-west-2.amazonaws.com"); + expect(client.hasCustomEndpoint()).toBe(false); + }); + + it("应当使用自定义 endpoint", () => { + const client = new S3Client({ + ...defaultConfig, + endpoint: "https://minio.example.com:9000", + }); + + expect(client.getEndpointUrl()).toBe("https://minio.example.com:9000"); + expect(client.hasCustomEndpoint()).toBe(true); + }); + + it("应当为无协议前缀的 endpoint 自动添加 https://", () => { + const client = new S3Client({ + ...defaultConfig, + endpoint: "s3.custom.com", + }); + + expect(client.getEndpointUrl()).toBe("https://s3.custom.com"); + }); + + it("应当去除 endpoint 末尾的斜杠", () => { + const client = new S3Client({ + ...defaultConfig, + endpoint: "https://minio.example.com///", + }); + + expect(client.getEndpointUrl()).toBe("https://minio.example.com"); + }); + + it("应当支持 http:// 协议的 endpoint", () => { + const client = new S3Client({ + ...defaultConfig, + endpoint: "http://localhost:9000", + }); + + expect(client.getEndpointUrl()).toBe("http://localhost:9000"); + }); + + it("应当默认 forcePathStyle 为 true", () => { + const client = new S3Client(defaultConfig); + + expect(client.isForcePathStyle()).toBe(true); + }); + + it("应当允许设置 forcePathStyle 为 false", () => { + const client = new S3Client({ + ...defaultConfig, + forcePathStyle: false, + }); + + expect(client.isForcePathStyle()).toBe(false); + }); + + it("应当正确返回 region", () => { + const client = new S3Client(defaultConfig); + expect(client.getRegion()).toBe("us-west-2"); + }); + + it("应当在 region 为空字符串时默认使用 us-east-1", () => { + const client = new S3Client({ + ...defaultConfig, + region: "", + }); + + expect(client.getRegion()).toBe("us-east-1"); + }); + }); + + // ---- request 方法 ---- + describe("request", () => { + let client: S3Client; + let fetchSpy: ReturnType; + + beforeEach(() => { + client = new S3Client({ + ...defaultConfig, + endpoint: "https://s3.us-west-2.amazonaws.com", + }); + fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("应当发送 GET 请求并返回 Response", async () => { + const mockResponse = new Response("hello", { status: 200, statusText: "OK" }); + fetchSpy.mockResolvedValue(mockResponse); + + const resp = await client.request("GET", "my-bucket", "test-key.txt"); + + expect(resp).toBe(mockResponse); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // 验证 URL(path-style) + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toContain("/my-bucket/test-key.txt"); + expect(options.method).toBe("GET"); + }); + + it("应当在 path-style 模式下构建正确的 URL", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("GET", "my-bucket", "folder/file.txt"); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket/folder/file.txt"); + }); + + it("应当在 virtual-hosted 模式下构建正确的 URL", async () => { + const vhClient = new S3Client({ + ...defaultConfig, + forcePathStyle: false, + }); + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await vhClient.request("GET", "my-bucket", "file.txt"); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://my-bucket.s3.us-west-2.amazonaws.com/file.txt"); + }); + + it("应当在请求头中包含 AWS Signature V4 签名", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("GET", "my-bucket", "test.txt"); + + const [, options] = fetchSpy.mock.calls[0]; + const headers = options.headers; + + // 验证签名头存在 + expect(headers["authorization"]).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE\//); + expect(headers["authorization"]).toContain("SignedHeaders="); + expect(headers["authorization"]).toContain("Signature="); + expect(headers["x-amz-date"]).toMatch(/^\d{8}T\d{6}Z$/); + expect(headers["x-amz-content-sha256"]).toBeDefined(); + }); + + it("应当正确传递 query parameters", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("GET", "my-bucket", undefined, { + queryParams: { "list-type": "2", prefix: "docs/" }, + }); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain("list-type=2"); + expect(url).toContain("prefix=docs%2F"); + }); + + it("应当正确传递 string body 的 PUT 请求", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("PUT", "my-bucket", "file.txt", { + body: "file content", + headers: { "Content-Type": "text/plain" }, + }); + + const [, options] = fetchSpy.mock.calls[0]; + expect(options.method).toBe("PUT"); + expect(options.body).toBe("file content"); + }); + + it("应当正确传递 Uint8Array body", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + const body = new TextEncoder().encode("binary data"); + await client.request("PUT", "my-bucket", "binary.bin", { body }); + + const [url, options] = fetchSpy.mock.calls[0]; + expect(url).toContain("/my-bucket/binary.bin"); + expect(options.method).toBe("PUT"); + // body 应当是 Uint8Array.buffer(ArrayBuffer 或兼容类型) + expect(options.body).toBeDefined(); + expect(options.body).not.toBeTypeOf("string"); + }); + + it("应当在非 2xx 响应时抛出 S3Error(XML 错误体)", async () => { + const errorXml = ` + + NoSuchKey + The specified key does not exist. + `; + fetchSpy.mockResolvedValue(new Response(errorXml, { status: 404, statusText: "Not Found" })); + + try { + await client.request("GET", "my-bucket", "nonexistent.txt"); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(S3Error); + if (e instanceof S3Error) { + expect(e.code).toBe("NoSuchKey"); + expect(e.statusCode).toBe(404); + expect(e.message).toBe("The specified key does not exist."); + } + } + }); + + it("应当在无 XML 体的错误响应时使用状态码映射", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 403, statusText: "Forbidden" })); + + await expect(client.request("HEAD", "my-bucket")).rejects.toSatisfy((e: S3Error) => { + return e instanceof S3Error && e.statusCode === 403; + }); + }); + + it("应当在 DELETE 请求成功时返回 Response", async () => { + // jsdom 不支持 204 状态码构造 Response,使用 200 代替验证 DELETE 请求逻辑 + fetchSpy.mockResolvedValue(new Response(null, { status: 200, statusText: "OK" })); + + const resp = await client.request("DELETE", "my-bucket", "file.txt"); + expect(resp.status).toBe(200); + }); + + it("应当不在 fetch headers 中包含 host 头", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("GET", "my-bucket", "test.txt"); + + const [, options] = fetchSpy.mock.calls[0]; + expect(options.headers["host"]).toBeUndefined(); + }); + + it("应当将自定义 headers 的 key 转为小写", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("PUT", "my-bucket", "test.txt", { + body: "data", + headers: { "Content-Type": "text/plain", "X-Custom-Header": "value" }, + }); + + const [, options] = fetchSpy.mock.calls[0]; + // authorization 中 SignedHeaders 应包含小写 key + expect(options.headers["authorization"]).toContain("content-type"); + expect(options.headers["authorization"]).toContain("x-custom-header"); + }); + + it("应当在不传 key 时只使用 bucket 路径", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("HEAD", "my-bucket"); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://s3.us-west-2.amazonaws.com/my-bucket"); + }); + + it("应当正确处理包含特殊字符的 key", async () => { + fetchSpy.mockResolvedValue(new Response("", { status: 200 })); + + await client.request("GET", "my-bucket", "path/to/file with spaces.txt"); + + const [url] = fetchSpy.mock.calls[0]; + expect(url).toContain("file%20with%20spaces.txt"); + }); + }); +}); diff --git a/packages/filesystem/s3/client.ts b/packages/filesystem/s3/client.ts new file mode 100644 index 000000000..7b28f87aa --- /dev/null +++ b/packages/filesystem/s3/client.ts @@ -0,0 +1,344 @@ +/** + * 轻量级 Amazon S3 HTTP 客户端 + * 使用 AWS Signature V4 签名,通过原生 fetch 发送请求 + * 不依赖 @aws-sdk/client-s3 + */ + +import { XMLParser } from "fast-xml-parser"; + +export interface S3ClientConfig { + region: string; + credentials: { + accessKeyId: string; + secretAccessKey: string; + }; + endpoint?: string; + /** 强制路径式访问(默认 true,兼容 MinIO 等) */ + forcePathStyle?: boolean; +} + +// ---- 加密工具函数 (使用 Web Crypto API) ---- + +async function sha256(data: string | Uint8Array): Promise { + const encoded = typeof data === "string" ? new TextEncoder().encode(data) : data; + return crypto.subtle.digest("SHA-256", encoded.buffer as ArrayBuffer); +} + +function toHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +async function sha256Hex(data: string | Uint8Array): Promise { + return toHex(await sha256(data)); +} + +async function hmacSha256(key: ArrayBuffer, data: string): Promise { + const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const encoded = new TextEncoder().encode(data); + return crypto.subtle.sign("HMAC", cryptoKey, encoded.buffer as ArrayBuffer); +} + +/** 派生 AWS Signature V4 签名密钥 */ +async function deriveSigningKey( + secretKey: string, + dateStamp: string, + region: string, + service: string +): Promise { + const kDate = await hmacSha256(new TextEncoder().encode(`AWS4${secretKey}`).buffer as ArrayBuffer, dateStamp); + const kRegion = await hmacSha256(kDate, region); + const kService = await hmacSha256(kRegion, service); + return hmacSha256(kService, "aws4_request"); +} + +// ---- URI 编码 ---- + +/** AWS Signature V4 要求的 URI 编码,仅保留 A-Za-z0-9_-.~ 不编码 */ +function awsUriEncode(str: string, encodeSlash: boolean = true): string { + let result = ""; + const bytes = new TextEncoder().encode(str); + for (const b of bytes) { + const ch = String.fromCharCode(b); + if ( + (ch >= "A" && ch <= "Z") || + (ch >= "a" && ch <= "z") || + (ch >= "0" && ch <= "9") || + ch === "_" || + ch === "-" || + ch === "~" || + ch === "." + ) { + result += ch; + } else if (ch === "/" && !encodeSlash) { + result += ch; + } else { + result += `%${b.toString(16).toUpperCase().padStart(2, "0")}`; + } + } + return result; +} + +// ---- S3 错误处理 ---- + +/** S3 服务端返回的错误 */ +export class S3Error extends Error { + code: string; + statusCode: number; + + constructor(code: string, message: string, statusCode: number) { + super(message); + this.name = code; // 兼容原 SDK 行为 (error.name === "NotFound" 等) + this.code = code; + this.statusCode = statusCode; + } +} + +/** 从 S3 响应解析错误信息 */ +async function parseS3Error(response: Response): Promise { + // 状态码到错误名称的映射(用于 HEAD 等无响应体的请求) + const statusCodeMap: Record = { + 301: "PermanentRedirect", + 400: "BadRequest", + 403: "AccessDenied", + 404: "NotFound", + 409: "Conflict", + }; + + try { + const text = await response.text(); + if (text) { + const parser = new XMLParser(); + const parsed = parser.parse(text); + const error = parsed.Error; + if (error?.Code) { + return new S3Error(String(error.Code), String(error.Message || response.statusText), response.status); + } + } + } catch { + // 解析失败则使用状态码映射 + } + + const code = statusCodeMap[response.status] || `HTTP${response.status}`; + return new S3Error(code, response.statusText, response.status); +} + +// ---- S3 客户端 ---- + +export class S3Client { + private config: Required>; + private parsedEndpoint: URL; + private customEndpoint: boolean; + + constructor(config: S3ClientConfig) { + this.config = { + region: config.region || "us-east-1", + credentials: config.credentials, + forcePathStyle: config.forcePathStyle ?? true, + }; + this.customEndpoint = !!config.endpoint; + + let endpoint: string; + if (config.endpoint) { + endpoint = config.endpoint.trim(); + if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) { + endpoint = `https://${endpoint}`; + } + } else { + endpoint = `https://s3.${this.config.region}.amazonaws.com`; + } + // 去除尾部斜杠 + endpoint = endpoint.replace(/\/+$/, ""); + this.parsedEndpoint = new URL(endpoint); + } + + /** 获取请求的 Host */ + private getHost(bucket: string): string { + const { hostname, port } = this.parsedEndpoint; + const hostWithPort = port ? `${hostname}:${port}` : hostname; + if (this.config.forcePathStyle) { + return hostWithPort; + } + 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 "/"; + } + + /** 构建请求 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}`; + if (queryParams && Object.keys(queryParams).length > 0) { + const qs = Object.entries(queryParams) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${awsUriEncode(k)}=${awsUriEncode(v)}`) + .join("&"); + url += `?${qs}`; + } + return url; + } + + /** AWS Signature V4 签名 */ + private async signRequest( + method: string, + bucket: string, + key: string | undefined, + queryParams: Record, + headers: Record, + payloadHash: string + ): Promise { + const now = new Date(); + // ISO 8601 基本格式: 20210101T000000Z + const amzDate = now + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}/, ""); + const dateStamp = amzDate.substring(0, 8); + + headers["host"] = this.getHost(bucket); + headers["x-amz-date"] = amzDate; + headers["x-amz-content-sha256"] = payloadHash; + + // 构建 Canonical Request + const canonicalUri = this.getCanonicalUri(bucket, key); + const canonicalQueryString = Object.entries(queryParams) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => `${awsUriEncode(k)}=${awsUriEncode(v)}`) + .join("&"); + + const signedHeaderKeys = Object.keys(headers) + .map((k) => k.toLowerCase()) + .sort(); + const canonicalHeaders = signedHeaderKeys.map((k) => `${k}:${(headers[k] ?? "").trim()}\n`).join(""); + const signedHeaders = signedHeaderKeys.join(";"); + + const canonicalRequest = [ + method, + canonicalUri, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + payloadHash, + ].join("\n"); + + // 构建 String to Sign + const credentialScope = `${dateStamp}/${this.config.region}/s3/aws4_request`; + const canonicalRequestHash = await sha256Hex(canonicalRequest); + const stringToSign = ["AWS4-HMAC-SHA256", amzDate, credentialScope, canonicalRequestHash].join("\n"); + + // 计算签名 + const signingKey = await deriveSigningKey( + this.config.credentials.secretAccessKey, + dateStamp, + this.config.region, + "s3" + ); + const signature = toHex(await hmacSha256(signingKey, stringToSign)); + + // 添加 Authorization 头 + headers["authorization"] = + `AWS4-HMAC-SHA256 Credential=${this.config.credentials.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; + } + + /** + * 发送 S3 请求 + * @param method HTTP 方法 + * @param bucket Bucket 名称 + * @param key 对象 Key(可选) + * @param options 请求选项 + * @returns 成功时返回 Response + * @throws {S3Error} S3 服务端错误 + */ + async request( + method: string, + bucket: string, + key?: string, + options?: { + queryParams?: Record; + body?: string | Uint8Array; + headers?: Record; + } + ): Promise { + const queryParams = options?.queryParams || {}; + // 规范化 headers(全部小写 key) + const headers: Record = {}; + if (options?.headers) { + for (const [k, v] of Object.entries(options.headers)) { + headers[k.toLowerCase()] = v; + } + } + + // 计算 payload SHA-256 + let payloadHash: string; + if (options?.body) { + const bodyBytes = typeof options.body === "string" ? new TextEncoder().encode(options.body) : options.body; + payloadHash = await sha256Hex(bodyBytes); + } else { + payloadHash = await sha256Hex(""); + } + + // 签名 + await this.signRequest(method, bucket, key, queryParams, headers, payloadHash); + + // 发送请求(移除 host 头,fetch 会自动设置) + const url = this.buildUrl(bucket, key, queryParams); + const fetchHeaders = { ...headers }; + delete fetchHeaders["host"]; + + const response = await fetch(url, { + method, + headers: fetchHeaders, + body: options?.body + ? options.body instanceof Uint8Array + ? (options.body.buffer as ArrayBuffer) + : options.body + : undefined, + }); + + // 非 2xx 响应视为错误 + if (!response.ok) { + throw await parseS3Error(response); + } + + return response; + } + + /** 获取 endpoint URL */ + getEndpointUrl(): string { + return this.parsedEndpoint.origin; + } + + /** 是否使用了自定义 endpoint */ + hasCustomEndpoint(): boolean { + return this.customEndpoint; + } + + /** 获取 region */ + getRegion(): string { + return this.config.region; + } + + /** 获取 forcePathStyle */ + isForcePathStyle(): boolean { + return this.config.forcePathStyle; + } +} diff --git a/packages/filesystem/s3/rw.ts b/packages/filesystem/s3/rw.ts new file mode 100644 index 000000000..fff7ee4e2 --- /dev/null +++ b/packages/filesystem/s3/rw.ts @@ -0,0 +1,77 @@ +import type { S3Client } from "./client"; +import type { FileReader, FileWriter } from "../filesystem"; + +/** + * S3 文件读取器 + * 通过 GET 请求下载 S3 对象 + */ +export class S3FileReader implements FileReader { + client: S3Client; + + bucket: string; + + key: string; + + constructor(client: S3Client, bucket: string, key: string) { + this.client = client; + this.bucket = bucket; + this.key = key; + } + + /** + * 读取文件内容 + * @param type 输出格式:"string" 为文本,"blob" 为二进制(默认) + * @returns 文件内容 + * @throws {S3Error} 文件不存在或读取失败 + */ + async read(type: "string" | "blob" = "blob"): Promise { + const response = await this.client.request("GET", this.bucket, this.key); + if (type === "string") { + return response.text(); + } + return response.blob(); + } +} + +/** + * S3 文件写入器 + * 通过 PUT 请求上传内容到 S3 + */ +export class S3FileWriter implements FileWriter { + client: S3Client; + + bucket: string; + + key: string; + + modifiedDate?: number; + + constructor(client: S3Client, bucket: string, key: string, modifiedDate?: number) { + this.client = client; + this.bucket = bucket; + this.key = key; + this.modifiedDate = modifiedDate; + } + + /** + * 写入文件内容 + * @param content 文件内容(字符串或 Blob) + * @throws {S3Error} 上传失败 + */ + async write(content: string | Blob): Promise { + const body = content instanceof Blob ? new Uint8Array(await content.arrayBuffer()) : content; + + const headers: Record = { + "content-type": "application/octet-stream", + }; + if (this.modifiedDate) { + // 通过自定义元数据保存创建时间(ISO 8601 格式) + headers["x-amz-meta-createtime"] = new Date(this.modifiedDate).toISOString(); + } + + await this.client.request("PUT", this.bucket, this.key, { + body: typeof body === "string" ? body : body, + headers, + }); + } +} diff --git a/packages/filesystem/s3/s3.test.ts b/packages/filesystem/s3/s3.test.ts new file mode 100644 index 000000000..c82cc1ebd --- /dev/null +++ b/packages/filesystem/s3/s3.test.ts @@ -0,0 +1,413 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import S3FileSystem from "./s3"; +import { S3Client, S3Error } from "./client"; +import type { FileInfo } from "../filesystem"; + +/** 创建 mock S3Client 实例(通过 prototype 伪装为 S3Client 的实例) */ +function createMockClient(overrides?: Partial): S3Client { + const mock = { + request: vi.fn(), + getEndpointUrl: vi.fn().mockReturnValue("https://s3.us-east-1.amazonaws.com"), + hasCustomEndpoint: vi.fn().mockReturnValue(false), + getRegion: vi.fn().mockReturnValue("us-east-1"), + isForcePathStyle: vi.fn().mockReturnValue(true), + ...overrides, + }; + // 让 instanceof S3Client 检查通过 + Object.setPrototypeOf(mock, S3Client.prototype); + return mock as unknown as S3Client; +} + +/** 创建 mock Response */ +function createMockResponse(options: { + ok?: boolean; + status?: number; + statusText?: string; + text?: string; + blob?: Blob; +}): Response { + const { ok = true, status = 200, statusText = "OK", text = "" } = options; + return { + ok, + status, + statusText, + headers: new Headers(), + text: vi.fn().mockResolvedValue(text), + blob: vi.fn().mockResolvedValue(options.blob ?? new Blob([text])), + } as unknown as Response; +} + +describe("S3FileSystem", () => { + let mockClient: S3Client; + let fs: S3FileSystem; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + fs = new S3FileSystem("test-bucket", mockClient); + }); + + // ---- verify ---- + describe("verify", () => { + it("应当成功验证 bucket", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); + + await expect(fs.verify()).resolves.toBeUndefined(); + expect(mockClient.request).toHaveBeenCalledWith("HEAD", "test-bucket"); + }); + + it("应当在 bucket 不存在时抛出 NotFound 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("NoSuchBucket", "The specified bucket does not exist", 404) + ); + + await expect(fs.verify()).rejects.toThrow("NotFound"); + }); + + it("应当在 404 状态码时抛出 NotFound 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue(new S3Error("NotFound", "Not Found", 404)); + + await expect(fs.verify()).rejects.toThrow("NotFound"); + }); + + it("应当在认证失败时抛出 WarpTokenError", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("InvalidAccessKeyId", "The AWS Access Key Id you provided does not exist", 403) + ); + + await expect(fs.verify()).rejects.toMatchObject({ + error: expect.any(S3Error), + }); + }); + + it("应当在 SignatureDoesNotMatch 时抛出 WarpTokenError", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("SignatureDoesNotMatch", "Signature does not match", 403) + ); + + await expect(fs.verify()).rejects.toMatchObject({ + error: expect.any(S3Error), + }); + }); + + it("应当在 AccessDenied 时抛出 Access Denied 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("AccessDenied", "Access Denied", 403) + ); + + await expect(fs.verify()).rejects.toThrow("Access Denied"); + }); + + it("应当在 PermanentRedirect (301) 时抛出 Access Denied 错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("PermanentRedirect", "Permanent Redirect", 301) + ); + + await expect(fs.verify()).rejects.toThrow("Access Denied"); + }); + + it("应当在网络错误时抛出网络连接失败错误", async () => { + const networkError = new Error("fetch failed"); + (mockClient.request as ReturnType).mockRejectedValue(networkError); + + await expect(fs.verify()).rejects.toThrow("Network connection failed"); + }); + + it("应当将未知错误原样抛出", async () => { + const unknownError = new Error("unknown error"); + (mockClient.request as ReturnType).mockRejectedValue(unknownError); + + await expect(fs.verify()).rejects.toThrow("unknown error"); + }); + }); + + // ---- open ---- + describe("open", () => { + it("应当返回 S3FileReader", async () => { + const fileInfo: FileInfo = { + name: "test.txt", + path: "/docs", + size: 100, + digest: "abc", + createtime: 1000, + updatetime: 2000, + }; + const reader = await fs.open(fileInfo); + + expect(reader).toBeDefined(); + expect(reader.read).toBeTypeOf("function"); + }); + + it("S3FileReader.read 应调用 client.request GET", async () => { + const fileInfo: FileInfo = { + name: "hello.txt", + path: "/data", + size: 50, + digest: "xyz", + createtime: 1000, + updatetime: 2000, + }; + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ text: "file content" })); + + const reader = await fs.open(fileInfo); + const content = await reader.read("string"); + + expect(mockClient.request).toHaveBeenCalledWith("GET", "test-bucket", "data/hello.txt"); + expect(content).toBe("file content"); + }); + }); + + // ---- create ---- + describe("create", () => { + it("应当返回 S3FileWriter", async () => { + const writer = await fs.create("test.txt"); + + expect(writer).toBeDefined(); + expect(writer.write).toBeTypeOf("function"); + }); + + it("S3FileWriter.write 应调用 client.request PUT", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true })); + + const writer = await fs.create("output.txt"); + await writer.write("hello world"); + + expect(mockClient.request).toHaveBeenCalledWith( + "PUT", + "test-bucket", + "output.txt", + expect.objectContaining({ + body: "hello world", + headers: expect.objectContaining({ + "content-type": "application/octet-stream", + }), + }) + ); + }); + }); + + // ---- createDir ---- + describe("createDir", () => { + it("应当静默成功(S3 中目录是隐式的)", async () => { + await expect(fs.createDir("new-dir")).resolves.toBeUndefined(); + }); + }); + + // ---- delete ---- + describe("delete", () => { + it("应当成功删除文件", async () => { + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ ok: true, status: 204 })); + + await expect(fs.delete("test.txt")).resolves.toBeUndefined(); + expect(mockClient.request).toHaveBeenCalledWith("DELETE", "test-bucket", "test.txt"); + }); + + it("应当在 NoSuchKey 时静默成功(幂等删除)", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("NoSuchKey", "The specified key does not exist", 404) + ); + + await expect(fs.delete("nonexistent.txt")).resolves.toBeUndefined(); + }); + + it("应当在其它 S3 错误时抛出异常", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("AccessDenied", "Access Denied", 403) + ); + + await expect(fs.delete("test.txt")).rejects.toThrow(); + }); + }); + + // ---- list ---- + describe("list", () => { + it("应当列出当前目录下的文件", async () => { + const xml = ` + + false + + file1.txt + 2024-01-01T00:00:00.000Z + "abc123" + 1024 + + + file2.txt + 2024-01-02T00:00:00.000Z + "def456" + 2048 + + `; + + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ text: xml })); + + const files = await fs.list(); + + expect(files).toHaveLength(2); + expect(files[0]).toMatchObject({ + name: "file1.txt", + path: "/", + size: 1024, + digest: "abc123", + }); + expect(files[1]).toMatchObject({ + name: "file2.txt", + path: "/", + size: 2048, + digest: "def456", + }); + }); + + it("应当正确处理带 basePath 的目录列表", async () => { + const subFs = new S3FileSystem("test-bucket", mockClient, "/docs"); + + const xml = ` + + false + + docs/readme.md + 2024-06-15T12:00:00.000Z + "aaa" + 512 + + `; + + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ text: xml })); + + const files = await subFs.list(); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + name: "readme.md", + path: "/docs", + size: 512, + }); + }); + + it("应当跳过目录占位符(以 / 结尾的 key)", async () => { + const xml = ` + + false + + subdir/ + 2024-01-01T00:00:00.000Z + "" + 0 + + + file.txt + 2024-01-01T00:00:00.000Z + "xyz" + 100 + + `; + + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ text: xml })); + + const files = await fs.list(); + + expect(files).toHaveLength(1); + expect(files[0].name).toBe("file.txt"); + }); + + it("应当处理分页(isTruncated + continuationToken)", async () => { + const xmlPage1 = ` + + true + token123 + + file1.txt + 2024-01-01T00:00:00.000Z + "aaa" + 100 + + `; + + const xmlPage2 = ` + + false + + file2.txt + 2024-01-02T00:00:00.000Z + "bbb" + 200 + + `; + + (mockClient.request as ReturnType) + .mockResolvedValueOnce(createMockResponse({ text: xmlPage1 })) + .mockResolvedValueOnce(createMockResponse({ text: xmlPage2 })); + + const files = await fs.list(); + + expect(files).toHaveLength(2); + expect(files[0].name).toBe("file1.txt"); + expect(files[1].name).toBe("file2.txt"); + expect(mockClient.request).toHaveBeenCalledTimes(2); + }); + + it("应当返回空数组当目录为空时", async () => { + const xml = ` + + false + `; + + (mockClient.request as ReturnType).mockResolvedValue(createMockResponse({ text: xml })); + + const files = await fs.list(); + expect(files).toHaveLength(0); + }); + + it("应当在 AccessDenied 时抛出权限错误", async () => { + (mockClient.request as ReturnType).mockRejectedValue( + new S3Error("AccessDenied", "Access Denied", 403) + ); + + await expect(fs.list()).rejects.toThrow("Permission denied"); + }); + }); + + // ---- openDir ---- + describe("openDir", () => { + it("应当返回新的 S3FileSystem 实例并拼接路径", async () => { + const subFs = (await fs.openDir("subdir")) as S3FileSystem; + + expect(subFs).toBeInstanceOf(S3FileSystem); + expect(subFs.bucket).toBe("test-bucket"); + expect(subFs.basePath).toBe("/subdir"); + }); + + it("应当支持嵌套 openDir", async () => { + const sub1 = (await fs.openDir("a")) as S3FileSystem; + const sub2 = (await sub1.openDir("b")) as S3FileSystem; + + expect(sub2.basePath).toBe("/a/b"); + }); + }); + + // ---- getDirUrl ---- + describe("getDirUrl", () => { + it("自定义 endpoint 应当返回 endpoint + bucket/prefix 路径", async () => { + const customClient = createMockClient({ + hasCustomEndpoint: vi.fn().mockReturnValue(true), + getEndpointUrl: vi.fn().mockReturnValue("https://minio.example.com"), + }); + const customFs = new S3FileSystem("my-bucket", customClient, "/data"); + + const url = await customFs.getDirUrl(); + expect(url).toBe("https://minio.example.com/my-bucket/data"); + }); + + it("AWS S3 应当返回控制台 URL", async () => { + const url = await fs.getDirUrl(); + expect(url).toContain("s3.console.aws.amazon.com"); + expect(url).toContain("test-bucket"); + expect(url).toContain("us-east-1"); + }); + + it("根目录时 prefix 应为空", async () => { + const url = await fs.getDirUrl(); + expect(url).toContain("prefix=&"); + }); + }); +}); diff --git a/packages/filesystem/s3/s3.ts b/packages/filesystem/s3/s3.ts new file mode 100644 index 000000000..41ce89e6a --- /dev/null +++ b/packages/filesystem/s3/s3.ts @@ -0,0 +1,274 @@ +import { XMLParser } from "fast-xml-parser"; +import { S3Client, S3Error } from "./client"; +import type { S3ClientConfig } from "./client"; +import type FileSystem from "../filesystem"; +import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; +import { joinPath } from "../utils"; +import { S3FileReader, S3FileWriter } from "./rw"; +import { WarpTokenError } from "../error"; + +// ---- ListObjectsV2 XML 解析 ---- + +interface ListObjectsV2Result { + contents: Array<{ + key: string; + lastModified: string; + etag: string; + size: number; + }>; + isTruncated: boolean; + nextContinuationToken?: string; +} + +const xmlParser = new XMLParser({ + // 不将单个元素包装成数组,后续手动处理 + isArray: (name) => name === "Contents", +}); + +/** 从 ListObjectsV2 XML 响应中解析对象列表 */ +function parseListObjectsV2(xml: string): ListObjectsV2Result { + const parsed = xmlParser.parse(xml); + const result = parsed.ListBucketResult || parsed; + + const rawContents: any[] = result.Contents || []; + const contents: ListObjectsV2Result["contents"] = rawContents.map((obj: any) => ({ + key: String(obj.Key || ""), + lastModified: String(obj.LastModified || ""), + etag: String(obj.ETag || ""), + size: Number(obj.Size) || 0, + })); + + const isTruncated = result.IsTruncated === true || result.IsTruncated === "true"; + const nextToken = result.NextContinuationToken ? String(result.NextContinuationToken) : undefined; + + return { contents, isTruncated, nextContinuationToken: nextToken }; +} + +// ---- S3 文件系统 ---- + +/** + * Amazon S3 文件系统实现 + * 支持 AWS S3 及兼容服务(MinIO、Wasabi 等) + * 使用原生 fetch + AWS Signature V4 签名,不依赖 @aws-sdk/client-s3 + */ +export default class S3FileSystem implements FileSystem { + client: S3Client; + + bucket: string; + + basePath: string = "/"; + + constructor(bucket: string, client: S3Client, basePath?: string); + constructor( + bucket: string, + region: string, + accessKeyId: string, + secretAccessKey: string, + endpoint?: string, + basePath?: string + ); + constructor( + bucket: string, + regionOrClient: string | S3Client, + accessKeyIdOrBasePath?: string, + secretAccessKey?: string, + endpoint?: string, + basePath?: string + ) { + this.bucket = bucket; + if (regionOrClient instanceof S3Client) { + this.client = regionOrClient; + this.basePath = accessKeyIdOrBasePath || "/"; + return; + } + this.basePath = basePath || "/"; + + const config: S3ClientConfig = { + region: regionOrClient || "us-east-1", + credentials: { + accessKeyId: accessKeyIdOrBasePath!, + secretAccessKey: secretAccessKey!, + }, + forcePathStyle: true, // 强制路径式访问,兼容大多数 S3 服务 + }; + + if (endpoint) { + let fixedEndpoint = `${endpoint}`.trim(); + if (!fixedEndpoint.startsWith("http://") && !fixedEndpoint.startsWith("https://")) { + fixedEndpoint = `https://${fixedEndpoint}`; + } + config.endpoint = fixedEndpoint; + // amazonaws.com 域名使用虚拟主机风格 + if (endpoint.includes("amazonaws.com")) config.forcePathStyle = false; + } + + this.client = new S3Client(config); + } + + /** + * 验证 Bucket 访问权限和凭证 + * @throws {WarpTokenError} 认证失败 + * @throws {Error} Bucket 不存在或网络错误 + */ + async verify(): Promise { + try { + await this.client.request("HEAD", this.bucket); + } catch (error: any) { + if (error instanceof S3Error) { + if (error.code === "NotFound" || error.code === "NoSuchBucket" || error.statusCode === 404) { + throw new Error("NotFound"); + } + if ( + error.code === "InvalidAccessKeyId" || + error.code === "SignatureDoesNotMatch" || + error.code === "InvalidClientTokenId" + ) { + throw new WarpTokenError(error); + } + if ( + error.code === "AccessDenied" || + error.code === "PermanentRedirect" || + error.statusCode === 403 || + error.statusCode === 301 + ) { + throw new Error("Access Denied"); + } + } + if (error.message?.includes("getaddrinfo") || error.message?.includes("fetch failed")) { + throw new Error("Network connection failed. Please check your internet connection."); + } + throw error; + } + } + + /** + * 打开文件用于读取 + * @param file 文件信息 + * @returns 文件读取器 + */ + async open(file: FileInfo): Promise { + const key = joinPath(file.path, file.name).substring(1); // 去除前导 / + return new S3FileReader(this.client, this.bucket, key); + } + + /** + * 打开子目录(返回新的 S3FileSystem 实例) + * @param path 相对于当前 basePath 的目录路径 + * @returns 新的 S3FileSystem 实例 + */ + async openDir(path: string): Promise { + return new S3FileSystem(this.bucket, this.client, joinPath(this.basePath, path)); + } + + /** + * 创建文件用于写入 + * @param path 相对于当前 basePath 的文件路径 + * @param opts 可选的文件创建选项 + * @returns 文件写入器 + */ + async create(path: string, opts?: FileCreateOptions): Promise { + return new S3FileWriter(this.client, this.bucket, joinPath(this.basePath, path).substring(1), opts?.modifiedDate); + } + + /** + * 创建目录(S3 中目录是隐式的,无需操作) + */ + async createDir(_path: string, _opts?: FileCreateOptions): Promise { + return Promise.resolve(); + } + + /** + * 删除文件 + * 此操作幂等——删除不存在的文件也会成功 + * @param path 相对于当前 basePath 的文件路径 + */ + async delete(path: string): Promise { + try { + await this.client.request("DELETE", this.bucket, joinPath(this.basePath, path).substring(1)); + } catch (error: any) { + // S3 delete 是幂等的,key 不存在时也视为成功 + if (error instanceof S3Error && error.code === "NoSuchKey") { + return; + } + throw error; + } + } + + /** + * 列出当前目录下的文件 + * 自动处理分页 + * @returns 文件信息数组 + */ + async list(): Promise { + let prefix = this.basePath === "/" ? "" : this.basePath.substring(1); + // 确保 prefix 以 / 结尾(除了根目录),这样才能正确列出目录下的文件 + if (prefix && !prefix.endsWith("/")) { + prefix += "/"; + } + const files: FileInfo[] = []; + let continuationToken: string | undefined; + + try { + do { + const queryParams: Record = { + "list-type": "2", + delimiter: "/", + "max-keys": "1000", + }; + if (prefix) queryParams["prefix"] = prefix; + if (continuationToken) queryParams["continuation-token"] = continuationToken; + + const response = await this.client.request("GET", this.bucket, undefined, { queryParams }); + const xml = await response.text(); + const result = parseListObjectsV2(xml); + + for (const obj of result.contents) { + if (!obj.key) continue; + if (obj.key.endsWith("/")) continue; // 跳过目录占位符 + if (prefix && obj.key === prefix) continue; // 跳过 prefix 本身 + + const relativeKey = prefix ? obj.key.slice(prefix.length) : obj.key; + if (!relativeKey) continue; + + const lastModified = new Date(obj.lastModified).getTime() || Date.now(); + + files.push({ + name: relativeKey, + path: this.basePath, + size: obj.size || 0, + digest: obj.etag?.replace(/"/g, "") || "", + createtime: lastModified, + updatetime: lastModified, + }); + } + + continuationToken = result.nextContinuationToken; + } while (continuationToken); + + if (files.length > 10000) { + console.warn(`Directory listing truncated: >10000 items under ${this.basePath}`); + } + + return files; + } catch (error: any) { + if (error instanceof S3Error && error.code === "AccessDenied") { + throw new Error(`Permission denied. Check your IAM permissions for bucket: ${this.bucket}`); + } + throw error; + } + } + + /** + * 获取当前目录的 URL + * 自定义 endpoint 返回 endpoint + bucket/prefix 路径 + * AWS S3 返回控制台 URL + */ + async getDirUrl(): Promise { + const prefix = this.basePath === "/" ? "" : this.basePath.substring(1); + if (this.client.hasCustomEndpoint()) { + const url = this.client.getEndpointUrl(); + return `${url}/${this.bucket}/${prefix}`; + } + return `https://s3.console.aws.amazon.com/s3/buckets/${this.bucket}?prefix=${encodeURIComponent(prefix)}®ion=${this.client.getRegion()}`; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5012eaaec..d8413c881 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: eventemitter3: specifier: ^5.0.1 version: 5.0.1 + fast-xml-parser: + specifier: ^5.3.6 + version: 5.3.6 i18next: specifier: ^23.16.4 version: 23.16.4 @@ -982,56 +985,67 @@ packages: resolution: {integrity: sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.44.2': resolution: {integrity: sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.44.2': resolution: {integrity: sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.44.2': resolution: {integrity: sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.44.2': resolution: {integrity: sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-powerpc64le-gnu@4.44.2': resolution: {integrity: sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.44.2': resolution: {integrity: sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.44.2': resolution: {integrity: sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.44.2': resolution: {integrity: sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.44.2': resolution: {integrity: sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.44.2': resolution: {integrity: sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.44.2': resolution: {integrity: sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==} @@ -1062,21 +1076,25 @@ packages: resolution: {integrity: sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@1.7.6': resolution: {integrity: sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@1.7.6': resolution: {integrity: sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@1.7.6': resolution: {integrity: sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-wasm32-wasi@1.7.6': resolution: {integrity: sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==} @@ -2302,8 +2320,8 @@ packages: fast-uri@3.0.3: resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} - fast-xml-parser@5.3.5: - resolution: {integrity: sha512-JeaA2Vm9ffQKp9VjvfzObuMCjUYAp5WDYhRYL5LrBPY/jUDlUtOvDfot0vKSkB9tuX885BDHjtw4fZadD95wnA==} + fast-xml-parser@5.3.6: + resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} hasBin: true fastq@1.17.1: @@ -6576,7 +6594,7 @@ snapshots: fast-uri@3.0.3: {} - fast-xml-parser@5.3.5: + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -8465,7 +8483,7 @@ snapshots: base-64: 1.0.0 byte-length: 1.0.2 entities: 6.0.1 - fast-xml-parser: 5.3.5 + fast-xml-parser: 5.3.6 hot-patcher: 2.0.1 layerr: 3.0.0 md5: 2.3.0 diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index 7b984f867..f42de208e 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -377,6 +377,11 @@ "url": "crwdns8544:0crwdne8544:0", "username": "crwdns8546:0crwdne8546:0", "password": "crwdns8548:0crwdne8548:0", + "s3_bucket_name": "Bucket Name", + "s3_region": "Region", + "s3_access_key_id": "Access Key ID", + "s3_secret_access_key": "Secret Access Key", + "s3_custom_endpoint": "Custom Endpoint (Optional)", "skip": "crwdns8550:0crwdne8550:0", "next": "crwdns8552:0crwdne8552:0", "next_with_progress": "crwdns8920:0{step}crwdnd8920:0{steps}crwdne8920:0", diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index dc389e7ea..e831d4e3f 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -386,6 +386,11 @@ "url": "URL", "username": "Benutzername", "password": "Passwort", + "s3_bucket_name": "Bucket-Name", + "s3_region": "Region", + "s3_access_key_id": "Zugriffs-Schlüssel-ID", + "s3_secret_access_key": "Geheimer Zugriffsschlüssel", + "s3_custom_endpoint": "Benutzerdefinierter Endpunkt (optional)", "skip": "Überspringen", "next": "Weiter", "next_with_progress": "Nächster Schritt (Schritt {step} von {steps})", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 006a3af2d..e2e3b17f7 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -386,6 +386,11 @@ "url": "URL", "username": "Username", "password": "Password", + "s3_bucket_name": "Bucket Name", + "s3_region": "Region", + "s3_access_key_id": "Access Key ID", + "s3_secret_access_key": "Secret Access Key", + "s3_custom_endpoint": "Custom Endpoint (Optional)", "skip": "Skip", "next": "Next", "next_with_progress": "Next (Step {step} of {steps})", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index 68a3db516..dda67f369 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -386,6 +386,11 @@ "url": "URL", "username": "ユーザー名", "password": "パスワード", + "s3_bucket_name": "バケット名", + "s3_region": "リージョン", + "s3_access_key_id": "アクセスキーID", + "s3_secret_access_key": "シークレットアクセスキー", + "s3_custom_endpoint": "カスタムエンドポイント(オプション)", "skip": "スキップ", "next": "次へ", "next_with_progress": "次へ(ステップ{step}の全{steps}ステップ中)", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index f3da00dc2..50034b5d4 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -386,6 +386,11 @@ "url": "URL", "username": "Имя пользователя", "password": "Пароль", + "s3_bucket_name": "Имя корзины", + "s3_region": "Регион", + "s3_access_key_id": "Идентификатор ключа доступа", + "s3_secret_access_key": "Секретный ключ доступа", + "s3_custom_endpoint": "Пользовательская конечная точка (необязательно)", "skip": "Пропустить", "next": "Далее", "next_with_progress": "Следующий шаг (шаг {step} из {steps})", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index ed482f3ed..69edf55b8 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -386,6 +386,11 @@ "url": "Url", "username": "Tên người dùng", "password": "Mật khẩu", + "s3_bucket_name": "Tên Bucket", + "s3_region": "Vùng", + "s3_access_key_id": "ID Khóa Truy Cập", + "s3_secret_access_key": "Khóa Truy Cập Bí Mật", + "s3_custom_endpoint": "Điểm Cuối Tùy Chỉnh (Tùy Chọn)", "skip": "Bỏ qua", "next": "Tiếp theo", "next_with_progress": "Tiếp theo (bước {step} trên {steps})", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 80be7c663..8441de96e 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -386,6 +386,11 @@ "url": "URL", "username": "用户名", "password": "密码", + "s3_bucket_name": "存储桶名称", + "s3_region": "区域", + "s3_access_key_id": "访问密钥 ID", + "s3_secret_access_key": "访问密钥密文", + "s3_custom_endpoint": "自定义端点(可选)", "skip": "跳过", "next": "下一步", "next_with_progress": "下一步(第 {step} 步 / 共 {steps} 步)", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index c384e2ab4..5d73a636a 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -386,6 +386,11 @@ "url": "網址", "username": "使用者名稱", "password": "密碼", + "s3_bucket_name": "儲存貯體名稱", + "s3_region": "區域", + "s3_access_key_id": "存取金鑰 ID", + "s3_secret_access_key": "私密存取金鑰", + "s3_custom_endpoint": "自訂端點(選用)", "skip": "跳過", "next": "下一步", "next_with_progress": "下一步(第 {step} 步 / 共 {steps} 步)", diff --git a/src/pages/components/FileSystemParams/index.tsx b/src/pages/components/FileSystemParams/index.tsx index f05dd5e28..8e7549985 100644 --- a/src/pages/components/FileSystemParams/index.tsx +++ b/src/pages/components/FileSystemParams/index.tsx @@ -47,6 +47,10 @@ const FileSystemParams: React.FC<{ key: "dropbox", name: "Dropbox", }, + { + key: "s3", + name: "Amazon S3", + }, ]; const netDiskType = netDiskTypeMap[fileSystemType];