diff --git a/packages/filesystem/factory.ts b/packages/filesystem/factory.ts index bdeb26e16..99dfa5d0e 100644 --- a/packages/filesystem/factory.ts +++ b/packages/filesystem/factory.ts @@ -8,6 +8,7 @@ import ZipFileSystem from "./zip/zip"; import S3FileSystem from "./s3/s3"; import { t } from "@App/locales/locales"; import LimiterFileSystem from "./limiter"; +import type { WebDAVClientOptions, OAuthToken } from "webdav"; export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox" | "s3"; @@ -16,18 +17,47 @@ export type FileSystemParams = { title: string; type?: "select" | "authorize" | "password"; options?: string[]; + visibilityFor?: string[]; + minWidth?: string; }; }; export default class FileSystemFactory { static create(type: FileSystemType, params: any): Promise { let fs: FileSystem; + let options; switch (type) { case "zip": fs = new ZipFileSystem(params); break; case "webdav": - fs = new WebDAVFileSystem(params.authType, params.url, params.username, params.password); + /* + Auto = "auto", + Digest = "digest", // 需要避免密码直接传输 + None = "none", // 公开资源 / 自定义认证 + Password = "password", // 普通 WebDAV 服务,需要确保 HTTPS / Nextcloud 生产环境 + Token = "token" // OAuth2 / 现代云服务 / Nextcloud 生产环境 + */ + if (params.authType === "none") { + options = { + authType: params.authType, + } satisfies WebDAVClientOptions; + } else if (params.authType === "token") { + options = { + authType: params.authType, + token: { + token_type: "Bearer", + access_token: params.accessToken, + } satisfies OAuthToken, + } satisfies WebDAVClientOptions; + } else { + options = { + authType: params.authType || "auto", // UI 问题,有undefined机会。undefined等价于 password, 但此处用 webdav 本身的 auto 侦测算了 + username: params.username, + password: params.password, + } satisfies WebDAVClientOptions; + } + fs = WebDAVFileSystem.fromCredentials(params.url, options); break; case "baidu-netdsik": fs = new BaiduFileSystem(); @@ -64,10 +94,12 @@ export default class FileSystemFactory { title: t("auth_type"), type: "select", options: ["password", "digest", "none", "token"], + minWidth: "140px", }, url: { title: t("url") }, - username: { title: t("username") }, - password: { title: t("password"), type: "password" }, + username: { title: t("username"), visibilityFor: ["password", "digest"] }, + password: { title: t("password"), type: "password", visibilityFor: ["password", "digest"] }, + accessToken: { title: t("access_token_bearer"), visibilityFor: ["token"] }, }, "baidu-netdsik": {}, onedrive: {}, diff --git a/packages/filesystem/webdav/webdav.test.ts b/packages/filesystem/webdav/webdav.test.ts new file mode 100644 index 000000000..cf389c0d0 --- /dev/null +++ b/packages/filesystem/webdav/webdav.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { WebDAVClient } from "webdav"; +import { getPatcher } from "webdav"; +import WebDAVFileSystem from "./webdav"; +import { WarpTokenError } from "../error"; + +/** 创建 mock WebDAVClient */ +function createMockClient(overrides?: Partial): WebDAVClient { + return { + getQuota: vi.fn().mockResolvedValue({}), + getDirectoryContents: vi.fn().mockResolvedValue([]), + getFileContents: vi.fn().mockResolvedValue("content"), + putFileContents: vi.fn().mockResolvedValue(true), + createDirectory: vi.fn().mockResolvedValue(undefined), + deleteFile: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as WebDAVClient; +} + +/** 创建可测试的 WebDAVFileSystem 实例(替换 client 为 mock) */ +function createTestFS(mockClient: WebDAVClient, url = "https://dav.example.com"): WebDAVFileSystem { + const fs = WebDAVFileSystem.fromCredentials(url, {}); + fs.client = mockClient; + return fs; +} + +describe("WebDAVFileSystem", () => { + let mockClient: WebDAVClient; + + beforeEach(() => { + vi.clearAllMocks(); + mockClient = createMockClient(); + }); + + describe("initWebDAVPatch", () => { + it("应当通过 getPatcher 注册 fetch patch,设置 credentials 为 omit", () => { + // fromCredentials 内部调用 initWebDAVPatch,验证 patcher 已注册 fetch + WebDAVFileSystem.fromCredentials("https://dav.example.com", {}); + + const patcher = getPatcher(); + // 验证 fetch 已被 patch(patcher 内部有 fetch 注册) + expect(patcher.isPatched("fetch")).toBe(true); + }); + }); + + describe("fromCredentials", () => { + it("应当创建 WebDAVFileSystem 实例并设置 url 和 basePath", () => { + const fs = WebDAVFileSystem.fromCredentials("https://dav.example.com", { + authType: "password" as any, + username: "user", + password: "pass", + }); + + expect(fs).toBeInstanceOf(WebDAVFileSystem); + expect(fs.url).toBe("https://dav.example.com"); + expect(fs.basePath).toBe("/"); + }); + }); + + describe("fromSameClient", () => { + it("应当复用已有 client 并设置新 basePath", () => { + const fs = createTestFS(mockClient); + const subFs = WebDAVFileSystem.fromSameClient(fs, "/subdir"); + + expect(subFs).toBeInstanceOf(WebDAVFileSystem); + expect(subFs.url).toBe("https://dav.example.com"); + expect(subFs.basePath).toBe("/subdir"); + expect(subFs.client).toBe(mockClient); + }); + }); + + describe("verify", () => { + it("应当成功验证", async () => { + const fs = createTestFS(mockClient); + + await expect(fs.verify()).resolves.toBeUndefined(); + expect(mockClient.getQuota).toHaveBeenCalled(); + }); + + it("应当在 401 时抛出 WarpTokenError", async () => { + (mockClient.getQuota as ReturnType).mockRejectedValue({ + response: { status: 401 }, + message: "Unauthorized", + }); + const fs = createTestFS(mockClient); + + await expect(fs.verify()).rejects.toBeInstanceOf(WarpTokenError); + }); + + it("应当在其他错误时抛出包含原始信息的 Error", async () => { + (mockClient.getQuota as ReturnType).mockRejectedValue({ + message: "Network error", + }); + const fs = createTestFS(mockClient); + + await expect(fs.verify()).rejects.toThrow("WebDAV verify failed: Network error"); + }); + }); + + describe("openDir", () => { + it("应当返回新实例并拼接路径", async () => { + const fs = createTestFS(mockClient); + const subFs = (await fs.openDir("docs")) as WebDAVFileSystem; + + expect(subFs).toBeInstanceOf(WebDAVFileSystem); + expect(subFs.basePath).toBe("/docs"); + expect(subFs.client).toBe(mockClient); + }); + + it("应当支持嵌套 openDir", async () => { + const fs = createTestFS(mockClient); + const sub1 = (await fs.openDir("a")) as WebDAVFileSystem; + const sub2 = (await sub1.openDir("b")) as WebDAVFileSystem; + + expect(sub2.basePath).toBe("/a/b"); + }); + }); + + describe("createDir", () => { + it("应当调用 createDirectory", async () => { + const fs = createTestFS(mockClient); + + await fs.createDir("new-folder"); + + expect(mockClient.createDirectory).toHaveBeenCalledWith("/new-folder"); + }); + + it("应当在 405 错误时静默成功(目录已存在)", async () => { + (mockClient.createDirectory as ReturnType).mockRejectedValue({ + response: { status: 405 }, + message: "405 Method Not Allowed", + }); + const fs = createTestFS(mockClient); + + await expect(fs.createDir("existing")).resolves.toBeUndefined(); + }); + + it("应当在 message 包含 405 时也静默成功", async () => { + (mockClient.createDirectory as ReturnType).mockRejectedValue({ + message: "Request failed with status code 405", + }); + const fs = createTestFS(mockClient); + + await expect(fs.createDir("existing")).resolves.toBeUndefined(); + }); + + it("应当在其他错误时抛出异常", async () => { + const err = new Error("Forbidden"); + (mockClient.createDirectory as ReturnType).mockRejectedValue(err); + const fs = createTestFS(mockClient); + + await expect(fs.createDir("denied")).rejects.toThrow("Forbidden"); + }); + }); + + describe("delete", () => { + it("应当调用 deleteFile", async () => { + const fs = createTestFS(mockClient); + + await fs.delete("test.txt"); + + expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt"); + }); + }); + + describe("list", () => { + it("应当列出文件并过滤目录", async () => { + (mockClient.getDirectoryContents as ReturnType).mockResolvedValue([ + { + type: "file", + basename: "test.txt", + lastmod: "2024-01-01T00:00:00Z", + etag: '"abc"', + size: 1024, + }, + { + type: "directory", + basename: "subdir", + lastmod: "2024-01-01T00:00:00Z", + etag: "", + size: 0, + }, + ]); + const fs = createTestFS(mockClient); + + const files = await fs.list(); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + name: "test.txt", + path: "/", + digest: '"abc"', + size: 1024, + }); + }); + + it("应当在 404 时返回空数组", async () => { + (mockClient.getDirectoryContents as ReturnType).mockRejectedValue({ + response: { status: 404 }, + }); + const fs = createTestFS(mockClient); + + const files = await fs.list(); + expect(files).toHaveLength(0); + }); + + it("应当在其他错误时抛出异常", async () => { + const err = new Error("Server Error"); + (err as any).response = { status: 500 }; + (mockClient.getDirectoryContents as ReturnType).mockRejectedValue(err); + const fs = createTestFS(mockClient); + + await expect(fs.list()).rejects.toThrow("Server Error"); + }); + }); + + describe("getDirUrl", () => { + it("应当返回 url + basePath", async () => { + const fs = createTestFS(mockClient); + const subFs = (await fs.openDir("docs")) as WebDAVFileSystem; + + expect(await subFs.getDirUrl()).toBe("https://dav.example.com/docs"); + }); + + it("根路径应返回 url + /", async () => { + const fs = createTestFS(mockClient); + + expect(await fs.getDirUrl()).toBe("https://dav.example.com/"); + }); + }); +}); diff --git a/packages/filesystem/webdav/webdav.ts b/packages/filesystem/webdav/webdav.ts index a3dde3e86..10ea10b21 100644 --- a/packages/filesystem/webdav/webdav.ts +++ b/packages/filesystem/webdav/webdav.ts @@ -1,11 +1,28 @@ -import type { AuthType, FileStat, WebDAVClient } from "webdav"; -import { createClient } from "webdav"; +import type { FileStat, WebDAVClient, WebDAVClientOptions } from "webdav"; +import { createClient, getPatcher } from "webdav"; import type FileSystem from "../filesystem"; import type { FileInfo, FileCreateOptions, FileReader, FileWriter } from "../filesystem"; import { joinPath } from "../utils"; import { WebDAVFileReader, WebDAVFileWriter } from "./rw"; import { WarpTokenError } from "../error"; +// 禁止 WebDAV 请求携带浏览器 cookies,只通过账号密码认证 (#1297) +// 全局单次注册 +let patchInited = false; +const initWebDAVPatch = () => { + if (patchInited) return; + patchInited = true; + return getPatcher().patch("fetch", (...args: unknown[]) => { + const options = (args[1] as RequestInit) || {}; + const headers = new Headers((options.headers as HeadersInit) || {}); + return fetch(args[0] as RequestInfo | URL, { + ...options, + headers, + credentials: "omit", + }); + }); +}; + export default class WebDAVFileSystem implements FileSystem { client: WebDAVClient; @@ -13,29 +30,36 @@ export default class WebDAVFileSystem implements FileSystem { basePath: string = "/"; - constructor(authType: AuthType | WebDAVClient, url?: string, username?: string, password?: string) { - if (typeof authType === "object") { - this.client = authType; - this.basePath = joinPath(url || ""); - this.url = username!; - } else { - this.url = url!; - this.client = createClient(url!, { - authType, - username, - password, - }); - } + static fromCredentials(url: string, options: WebDAVClientOptions) { + initWebDAVPatch(); + options = { + ...options, + headers: { + "X-Requested-With": "XMLHttpRequest", // Nextcloud 等需要 + // "requesttoken": csrfToken, // 按账号各自传入 + }, + }; + return new WebDAVFileSystem(createClient(url, options), url, "/"); + } + + static fromSameClient(fs: WebDAVFileSystem, basePath: string) { + return new WebDAVFileSystem(fs.client, fs.url, basePath); + } + + private constructor(client: WebDAVClient, url: string, basePath: string) { + this.client = client; + this.url = url; + this.basePath = basePath; } async verify(): Promise { try { await this.client.getQuota(); } catch (e: any) { - if (e.response && e.response.status === 401) { + if (e.response?.status === 401) { throw new WarpTokenError(e); } - throw new Error("verify failed"); + throw new Error(`WebDAV verify failed: ${e.message}`); // 保留原始信息 } } @@ -44,7 +68,7 @@ export default class WebDAVFileSystem implements FileSystem { } async openDir(path: string): Promise { - return new WebDAVFileSystem(this.client, joinPath(this.basePath, path), this.url); + return WebDAVFileSystem.fromSameClient(this, joinPath(this.basePath, path)); } async create(path: string, _opts?: FileCreateOptions): Promise { @@ -56,7 +80,7 @@ export default class WebDAVFileSystem implements FileSystem { await this.client.createDirectory(joinPath(this.basePath, path)); } catch (e: any) { // 如果是405错误,则忽略 - if (e.message.includes("405")) { + if (e.response?.status === 405 || e.message?.includes("405")) { return; } throw e; @@ -68,7 +92,13 @@ export default class WebDAVFileSystem implements FileSystem { } async list(): Promise { - const dir = (await this.client.getDirectoryContents(this.basePath)) as FileStat[]; + let dir: FileStat[]; + try { + dir = (await this.client.getDirectoryContents(this.basePath)) as FileStat[]; + } catch (e: any) { + if (e.response?.status === 404) return [] as FileInfo[]; // 目录不存在视为空 + throw e; + } const ret: FileInfo[] = []; for (const item of dir) { if (item.type !== "file") { diff --git a/src/locales/ach-UG/translation.json b/src/locales/ach-UG/translation.json index e6f3850b5..c1b8b441f 100644 --- a/src/locales/ach-UG/translation.json +++ b/src/locales/ach-UG/translation.json @@ -378,6 +378,7 @@ "url": "crwdns8544:0crwdne8544:0", "username": "crwdns8546:0crwdne8546:0", "password": "crwdns8548:0crwdne8548:0", + "access_token_bearer": "Access Token (Bearer)", "s3_bucket_name": "Bucket Name", "s3_region": "Region", "s3_access_key_id": "Access Key ID", diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 9df67cfd8..27ed68826 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -387,6 +387,7 @@ "url": "URL", "username": "Benutzername", "password": "Passwort", + "access_token_bearer": "Zugriffstoken (Bearer)", "s3_bucket_name": "Bucket-Name", "s3_region": "Region", "s3_access_key_id": "Zugriffs-Schlüssel-ID", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 9769ef7f7..e24a9def9 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -387,6 +387,7 @@ "url": "URL", "username": "Username", "password": "Password", + "access_token_bearer": "Access Token (Bearer)", "s3_bucket_name": "Bucket Name", "s3_region": "Region", "s3_access_key_id": "Access Key ID", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index 823949c52..af059852e 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -387,6 +387,7 @@ "url": "URL", "username": "ユーザー名", "password": "パスワード", + "access_token_bearer": "アクセストークン(Bearer)", "s3_bucket_name": "バケット名", "s3_region": "リージョン", "s3_access_key_id": "アクセスキーID", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 1b6b72973..553a2d196 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -387,6 +387,7 @@ "url": "URL", "username": "Имя пользователя", "password": "Пароль", + "access_token_bearer": "Токен доступа (Bearer)", "s3_bucket_name": "Имя корзины", "s3_region": "Регион", "s3_access_key_id": "Идентификатор ключа доступа", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index c69cb10be..8f3124057 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -387,6 +387,7 @@ "url": "Url", "username": "Tên người dùng", "password": "Mật khẩu", + "access_token_bearer": "Mã truy cập (Bearer)", "s3_bucket_name": "Tên Bucket", "s3_region": "Vùng", "s3_access_key_id": "ID Khóa Truy Cập", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 1267d3472..f6b36020d 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -387,6 +387,7 @@ "url": "URL", "username": "用户名", "password": "密码", + "access_token_bearer": "访问令牌(Bearer)", "s3_bucket_name": "存储桶名称", "s3_region": "区域", "s3_access_key_id": "访问密钥 ID", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index a56ddd57e..275b20d19 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -387,6 +387,7 @@ "url": "網址", "username": "使用者名稱", "password": "密碼", + "access_token_bearer": "存取權杖(Bearer)", "s3_bucket_name": "儲存貯體名稱", "s3_region": "區域", "s3_access_key_id": "存取金鑰 ID", diff --git a/src/pages/components/FileSystemParams/index.tsx b/src/pages/components/FileSystemParams/index.tsx index ebf74473a..2cec8cfb9 100644 --- a/src/pages/components/FileSystemParams/index.tsx +++ b/src/pages/components/FileSystemParams/index.tsx @@ -65,6 +65,7 @@ const FileSystemParams: React.FC<{ ]; const netDiskName = netDiskType ? fileSystemList.find((item) => item.key === fileSystemType)?.name : null; + const fsParam = fsParams[fileSystemType]; return ( <> @@ -110,58 +111,66 @@ const FileSystemParams: React.FC<{ marginTop: 4, }} > - {Object.keys(fsParams[fileSystemType]).map((key) => ( -
- {fsParams[fileSystemType][key].type === "select" && ( - <> - {fsParams[fileSystemType][key].title} - - - )} - {fsParams[fileSystemType][key].type === "password" && ( - <> - {fsParams[fileSystemType][key].title} - { - onChangeFileSystemParams({ - ...fileSystemParams, - [key]: value, - }); - }} - /> - - )} - {!fsParams[fileSystemType][key].type && ( - <> - {fsParams[fileSystemType][key].title} - { - onChangeFileSystemParams({ - ...fileSystemParams, - [key]: value, - }); - }} - /> - - )} -
- ))} + {Object.keys(fsParam).map((key) => { + const props = fsParam[key]; + const selectAuth = fsParam?.authType?.options?.[0]; // webDAV + if (selectAuth && props?.visibilityFor?.includes(fileSystemParams?.authType || selectAuth) === false) { + return null; + } + return ( +
+ {props.type === "select" && ( + <> + {props.title} + + + )} + {props.type === "password" && ( + <> + {props.title} + { + onChangeFileSystemParams({ + ...fileSystemParams, + [key]: value, + }); + }} + /> + + )} + {!props.type && ( + <> + {props.title} + { + onChangeFileSystemParams({ + ...fileSystemParams, + [key]: value, + }); + }} + /> + + )} +
+ ); + })} );