From a250ff1d9b11a28a0f0b67f51a223a0e9ca93d5f Mon Sep 17 00:00:00 2001 From: Astroptis <250784029+Astroptis@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:32:25 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(s3,share):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=E5=88=A0=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E6=95=88=E4=B8=8E=E5=88=86=E4=BA=AB=E5=A4=8D=E5=88=B6=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E4=B8=BA=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 的 remove/move/copy 按「目录 + name」拼接路径,但 op/storage.ts 传入的 physicalPath 已经是目标项自身的物理路径,结果指向 /:HEAD 404 后退化成按目录递归删除,listObjects 同样查不到对象,最后静默返回成功—— 接口报 Success,对象仍在(move/copy 同根因)。新增 s3_remove.test.ts 用内存 S3 锁定删除/移动实际命中的 key。 分享摘要模板 share_summary_content 被 seed 成空串,前端复制链接 = matchTemplate(getSetting("share_summary_content"), data),模板为空时 writeText("") 会清空剪贴板,却照样弹「已复制」。默认值改为与 Go setting.go 一致的模板,并加迁移把已部署实例 KV 里的空串换成默认值;/api/public/settings 同步补默认值兜底。 Co-Authored-By: opencode --- src/backend/drivers/s3/driver.ts | 44 +++---- src/backend/drivers/s3/s3_remove.test.ts | 127 +++++++++++++++++++++ src/backend/internal/model/db.ts | 18 ++- src/backend/server/public.ts | 5 + src/backend/server/public_settings.test.ts | 45 +++++++- 5 files changed, 215 insertions(+), 24 deletions(-) create mode 100644 src/backend/drivers/s3/s3_remove.test.ts diff --git a/src/backend/drivers/s3/driver.ts b/src/backend/drivers/s3/driver.ts index b2eb6304..1ef759b0 100644 --- a/src/backend/drivers/s3/driver.ts +++ b/src/backend/drivers/s3/driver.ts @@ -239,10 +239,12 @@ export class S3Driver implements StorageDriver { await this.checkDogeToken() const srcBase = this.getRemotePath(srcPhys) const dstBase = this.getRemotePath(dstPhys) + const expand = names && names.length > 1 + const pairs = expand + ? names.map((n) => [joinPath(srcBase, n), joinPath(dstBase, n)] as const) + : [[srcBase, dstBase] as const] - for (const name of names) { - const srcPath = joinPath(srcBase, name) - const dstPath = joinPath(dstBase, name) + for (const [srcPath, dstPath] of pairs) { const head = await this.client.headObject(srcPath) if (head) { await this.client.copyObject(srcPath, dstPath, head.size) @@ -264,10 +266,12 @@ export class S3Driver implements StorageDriver { await this.checkDogeToken() const srcBase = this.getRemotePath(srcPhys) const dstBase = this.getRemotePath(dstPhys) + const expand = names && names.length > 1 + const pairs = expand + ? names.map((n) => [joinPath(srcBase, n), joinPath(dstBase, n)] as const) + : [[srcBase, dstBase] as const] - for (const name of names) { - const srcPath = joinPath(srcBase, name) - const dstPath = joinPath(dstBase, name) + for (const [srcPath, dstPath] of pairs) { const head = await this.client.headObject(srcPath) if (head) { await this.client.copyObject(srcPath, dstPath, head.size) @@ -297,24 +301,20 @@ export class S3Driver implements StorageDriver { names: string[], ): Promise { await this.checkDogeToken() - const basePath = this.getRemotePath(physicalPath) - - if (names && names.length > 0) { - for (const name of names) { - const targetPath = joinPath(basePath, name) - const head = await this.client.headObject(targetPath) - if (head) { - await this.client.deleteObject(targetPath) - } else { - await this.removeDirRecursive(targetPath) - } - } - } else { - const head = await this.client.headObject(basePath) + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 直接删除它即可;若再拼一次 name 会指向 `/`,HEAD 404 后退化成 + // 按目录递归删除,prefix 同样查不到对象,最终静默返回成功但对象仍在。 + const expand = names && names.length > 1 + const targets = expand + ? names.map((n) => joinPath(this.getRemotePath(physicalPath), n)) + : [this.getRemotePath(physicalPath)] + + for (const targetPath of targets) { + const head = await this.client.headObject(targetPath) if (head) { - await this.client.deleteObject(basePath) + await this.client.deleteObject(targetPath) } else { - await this.removeDirRecursive(basePath) + await this.removeDirRecursive(targetPath) } } } diff --git a/src/backend/drivers/s3/s3_remove.test.ts b/src/backend/drivers/s3/s3_remove.test.ts new file mode 100644 index 00000000..12cb84ef --- /dev/null +++ b/src/backend/drivers/s3/s3_remove.test.ts @@ -0,0 +1,127 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { S3Driver } from "./driver" + +/** + * 调用约定(src/backend/internal/op/storage.ts 的 removeItems/moveItems/copyItems): + * + * const resolved = await resolvePath(`${dir}/${name}`) + * await driver.remove(virtualPath, resolved.physical, [name]) + * + * 即 physicalPath 是【目标项自身】的物理路径(文件路径),与 123pan/github/ + * azure_blob 等 driver 的用法一致。driver 不得再把 name 拼接到 physicalPath 后面, + * 否则会得到 `/` 这种不存在的 key:HEAD 404 → 退化成按目录递归删 → + * prefix 同样查不到任何对象 → 静默返回成功(接口报 success,对象仍在)。 + */ + +type Call = { method: string; url: string } + +/** 内存版 S3:object key 存 URL pathname,记录所有请求以便断言目标 key */ +function mockS3(objects: Set, calls: Call[]) { + return async (input: any, init: any = {}) => { + const url = new URL(String(typeof input === "string" ? input : input.url)) + const method = String(init.method || "GET").toUpperCase() + calls.push({ method, url: url.toString() }) + + if (method === "HEAD") { + if (objects.has(url.pathname)) { + return new Response(null, { + status: 200, + headers: { + "content-length": "10", + etag: '"etag"', + "last-modified": "Wed, 01 Jan 2025 00:00:00 GMT", + }, + }) + } + return new Response(null, { status: 404 }) + } + if (method === "DELETE") { + objects.delete(url.pathname) + return new Response(null, { status: 204 }) + } + return new Response( + `false`, + { status: 200, headers: { "content-type": "application/xml" } }, + ) + } +} + +function withMock( + objects: Set, + calls: Call[], + fn: () => Promise, +): Promise { + const original = globalThis.fetch + globalThis.fetch = mockS3(objects, calls) as any + return fn().finally(() => { + globalThis.fetch = original + }) +} + +function makeDriver() { + return new S3Driver({ + bucket: "mybucket", + endpoint: "https://s3.amazonaws.com", + access_key_id: "key", + secret_access_key: "secret", + }) +} + +const FILE_KEY = "/Cloudflare%20R2/AzkiDeck.html" + +test("S3 remove() deletes the object key itself, not /", async () => { + const objects = new Set([FILE_KEY]) + const calls: Call[] = [] + + await withMock(objects, calls, () => + makeDriver().remove( + "/Cloudflare R2/AzkiDeck.html", + "/Cloudflare R2/AzkiDeck.html", + ["AzkiDeck.html"], + ), + ) + + assert.equal( + objects.size, + 0, + "object must be deleted (interface reported success)", + ) + const deletes = calls + .filter((c) => c.method === "DELETE") + .map((c) => new URL(c.url).pathname) + assert.ok( + deletes.includes(FILE_KEY), + `DELETE must target ${FILE_KEY}, got: ${deletes.join(", ") || "(none)"}`, + ) +}) + +test("S3 move() copies from / to the object key itself", async () => { + const objects = new Set([FILE_KEY]) + const calls: Call[] = [] + + await withMock(objects, calls, () => + makeDriver().move( + "/Cloudflare R2", + "/Cloudflare R2/bak", + ["AzkiDeck.html"], + "/Cloudflare R2/AzkiDeck.html", + "/Cloudflare R2/bak/AzkiDeck.html", + ), + ) + + const deletes = calls + .filter((c) => c.method === "DELETE") + .map((c) => new URL(c.url).pathname) + const puts = calls + .filter((c) => c.method === "PUT") + .map((c) => new URL(c.url).pathname) + assert.ok( + deletes.includes(FILE_KEY), + `move must delete source key ${FILE_KEY}, got: ${deletes.join(", ") || "(none)"}`, + ) + assert.ok( + puts.includes("/Cloudflare%20R2/bak/AzkiDeck.html"), + `move must write destination key, got: ${puts.join(", ") || "(none)"}`, + ) +}) diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index a1814a6f..08569022 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -41,6 +41,16 @@ const DEFAULT_PROXY_IGNORE_HEADERS = "authorization,referer" const LEGACY_TEXT_TYPES = "txt,htm,html,xml,java,properties,sql,js,json,c,cpp,python,py,php,go,rst,css,typescript,ts,log,conf,yaml,yml,cmd,bash,sh,vue,ini" +/** + * 分享摘要默认模板,与上游 Go 后端 internal/bootstrap/data/setting.go 的 + * ShareSummaryContent 保持一致(flag=PUBLIC,随 /api/public/settings 下发)。 + * + * 前端「复制链接」= matchTemplate(getSetting("share_summary_content"), data): + * 模板为空时结果就是空串,`writeText("")` 会清空剪贴板,于是按钮弹出「已复制」 + * 但剪贴板里什么都没有——分享页/分享管理页的复制链接因此完全不可用。 + */ +const DEFAULT_SHARE_SUMMARY_CONTENT = `@{{creator}} shared {{#each files}}{{#if @first}}"{{filename this}}"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}` + // Global default configuration payload for Cloudflare Workers export const defaultDb = { settings: [ @@ -509,7 +519,7 @@ export const defaultDb = { }, { key: "share_summary_content", - value: "", + value: DEFAULT_SHARE_SUMMARY_CONTENT, type: "text", help: "Share Summary Content", group: 4, @@ -944,6 +954,12 @@ const LEGACY_SETTING_MIGRATIONS: Record = { from: [LEGACY_TEXT_TYPES], to: DEFAULT_TEXT_TYPES, }, + // 早期 seed 把分享摘要模板初始化成了空串,已写入 KV 的空值会让 + // 「复制链接」复制出空内容(writeText("") 直接清空剪贴板)。 + share_summary_content: { + from: [""], + to: DEFAULT_SHARE_SUMMARY_CONTENT, + }, } const ensureDefaultSettings = (db: any) => { diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index 9016c823..d6a743d9 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -398,6 +398,11 @@ publicRouter.get("/settings", async (c) => { // IMPORTANT: share_preview must be "true" — frontend blocks ALL previews when false share_preview: "true", share_archive_preview: "true", + // 分享摘要模板(对齐 Go setting.go 的 ShareSummaryContent)。前端 + // 「复制链接」= matchTemplate(getSetting("share_summary_content"), data): + // 缺失或为空时模板渲染成空串,`navigator.clipboard.writeText("")` 会清空 + // 剪贴板,按钮照常弹「已复制」但粘贴出来是空的。 + share_summary_content: `@{{creator}} shared {{#each files}}{{#if @first}}"{{filename this}}"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}`, // --- Global --- hide_files: "/\\.DS_Store/i", diff --git a/src/backend/server/public_settings.test.ts b/src/backend/server/public_settings.test.ts index aee244a0..85c0c28e 100644 --- a/src/backend/server/public_settings.test.ts +++ b/src/backend/server/public_settings.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { test } from "node:test" import { Hono } from "hono" -import { saveDb } from "../internal/model/db" +import { getDb, saveDb } from "../internal/model/db" import { publicRouter } from "./public" const env: any = {} @@ -100,3 +100,46 @@ test("Security(F-14): keys the frontend actually reads are still echoed", async assert.equal(json.data.ldap_login_tips, "use your corp account") assert.equal(json.data.sso_login_platform, "github") }) + +const assertShareTemplateUsable = (tpl: string) => { + assert.notEqual( + tpl.trim(), + "", + "share_summary_content must not be empty — the share page's copy button " + + "renders this template and writeText('') silently clears the clipboard", + ) + assert.ok( + tpl.includes("{{base_url}}/@s/{{id}}"), + `share template must render a share link, got: ${tpl}`, + ) +} + +test("share copy link: an empty persisted share_summary_content is migrated", async () => { + // 模拟线上已部署实例:空串已经落盘,之后通过 loadDb() 读取(该路径会执行 + // ensureDefaultSettings 的 LEGACY_SETTING_MIGRATIONS 迁移)。 + // 直接 saveDb() 的结果会写进 dbCache,短 TTL 内 getDb() 不会走 loadDb, + // 所以这里先落盘、等缓存过期,再验证迁移后的对外表现。 + const env: any = { + DB_DRIVER: "auto", + DB_FORMAT: "map", + JWT_SECRET: "test-secret-for-share-summary", + ENCRYPTION_SECRET: "test-secret-for-share-summary", + } + await saveDb( + { settings: [{ key: "share_summary_content", value: "" }], users: [], storages: [], shares: [] }, + env, + { force: true }, + ) + await new Promise((r) => setTimeout(r, 1100)) + const db = await getDb(env) + const tpl = String( + db.settings.find((s: any) => s.key === "share_summary_content")?.value || "", + ) + assertShareTemplateUsable(tpl) +}) + +test("share copy link: a missing share_summary_content falls back to default", async () => { + await seed([]) + const json = await fetchSettings() + assertShareTemplateUsable(String(json.data.share_summary_content || "")) +}) From 7c10180cee9b37cdd4529d576396c1d34ed05934 Mon Sep 17 00:00:00 2001 From: Astroptis <250784029+Astroptis@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:13:03 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(share):=20=E3=80=8C=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E3=80=8D=E6=94=B9=E4=B8=BA=E5=8F=AA=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E7=BA=AF=E5=88=86=E4=BA=AB=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理后台「复制链接」渲染 share_summary_content 模板。此前该模板被 seed 成空串,复制出的是空白(writeText("") 直接清空剪贴板却照样弹「已复制」); 上一版修空值时改用了上游 Go 的整段社交文案,按钮叫 Copy link 却复制出一堆 非链接内容——而文件工具栏「分享」对话框里的同名按钮 copy(link()) 复制的 一直是纯 URL。 默认模板改为 {{base_url}}/@s/{{id}},与按钮文案及另一处「复制链接」行为 保持一致;空串与旧文案两种已落盘的值都由 LEGACY_SETTING_MIGRATIONS 迁移 成纯链接,/api/public/settings 的默认值同步更新。 Co-Authored-By: opencode --- src/backend/internal/model/db.ts | 23 ++++++++---- src/backend/server/public.ts | 9 ++--- src/backend/server/public_settings.test.ts | 43 ++++++++++++++++------ 3 files changed, 51 insertions(+), 24 deletions(-) diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index 08569022..4c3ca665 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -42,14 +42,21 @@ const LEGACY_TEXT_TYPES = "txt,htm,html,xml,java,properties,sql,js,json,c,cpp,python,py,php,go,rst,css,typescript,ts,log,conf,yaml,yml,cmd,bash,sh,vue,ini" /** - * 分享摘要默认模板,与上游 Go 后端 internal/bootstrap/data/setting.go 的 - * ShareSummaryContent 保持一致(flag=PUBLIC,随 /api/public/settings 下发)。 + * 分享摘要/「复制链接」默认模板。 * - * 前端「复制链接」= matchTemplate(getSetting("share_summary_content"), data): + * 前端「复制链接」= matchTemplate(getSetting("share_summary_content"), data)。 * 模板为空时结果就是空串,`writeText("")` 会清空剪贴板,于是按钮弹出「已复制」 - * 但剪贴板里什么都没有——分享页/分享管理页的复制链接因此完全不可用。 + * 但剪贴板里什么都没有——分享管理页的复制链接因此完全不可用。 + * + * 这里输出**纯分享 URL**,与按钮文案(Copy link / 复制链接)以及文件工具栏 + * 「分享」对话框里 `copy(link())` 的行为保持一致。上游 Go 的默认值是一整段 + * 社交分享文案(LEGACY_SHARE_SUMMARY_GO),对「复制链接」按钮而言属于误导性 + * 行为,故不采用;已写入该文案的实例由 LEGACY_SETTING_MIGRATIONS 迁移过来。 */ -const DEFAULT_SHARE_SUMMARY_CONTENT = `@{{creator}} shared {{#each files}}{{#if @first}}"{{filename this}}"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}` +const DEFAULT_SHARE_SUMMARY_CONTENT = "{{base_url}}/@s/{{id}}" + +/** 上一版曾采用的上游 Go 分享摘要文案(仅用于迁移到纯链接) */ +const LEGACY_SHARE_SUMMARY_GO = `@{{creator}} shared {{#each files}}{{#if @first}}"{{filename this}}"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}` // Global default configuration payload for Cloudflare Workers export const defaultDb = { @@ -954,10 +961,10 @@ const LEGACY_SETTING_MIGRATIONS: Record = { from: [LEGACY_TEXT_TYPES], to: DEFAULT_TEXT_TYPES, }, - // 早期 seed 把分享摘要模板初始化成了空串,已写入 KV 的空值会让 - // 「复制链接」复制出空内容(writeText("") 直接清空剪贴板)。 + // 「复制链接」:早期 seed 为空串(复制出空白),上一版一度采用上游 Go 的 + // 整段分享文案(复制出一堆非链接内容)。两种已写入的值都迁移到纯分享 URL。 share_summary_content: { - from: [""], + from: ["", LEGACY_SHARE_SUMMARY_GO], to: DEFAULT_SHARE_SUMMARY_CONTENT, }, } diff --git a/src/backend/server/public.ts b/src/backend/server/public.ts index d6a743d9..0b41523f 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -398,11 +398,10 @@ publicRouter.get("/settings", async (c) => { // IMPORTANT: share_preview must be "true" — frontend blocks ALL previews when false share_preview: "true", share_archive_preview: "true", - // 分享摘要模板(对齐 Go setting.go 的 ShareSummaryContent)。前端 - // 「复制链接」= matchTemplate(getSetting("share_summary_content"), data): - // 缺失或为空时模板渲染成空串,`navigator.clipboard.writeText("")` 会清空 - // 剪贴板,按钮照常弹「已复制」但粘贴出来是空的。 - share_summary_content: `@{{creator}} shared {{#each files}}{{#if @first}}"{{filename this}}"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}`, + // 「复制链接」渲染的模板。缺失或为空时会渲染成空串, + // `navigator.clipboard.writeText("")` 会清空剪贴板,按钮照常弹「已复制」 + // 但粘贴出来是空的。默认输出纯分享 URL,与按钮文案一致。 + share_summary_content: "{{base_url}}/@s/{{id}}", // --- Global --- hide_files: "/\\.DS_Store/i", diff --git a/src/backend/server/public_settings.test.ts b/src/backend/server/public_settings.test.ts index 85c0c28e..fe54ed77 100644 --- a/src/backend/server/public_settings.test.ts +++ b/src/backend/server/public_settings.test.ts @@ -108,17 +108,26 @@ const assertShareTemplateUsable = (tpl: string) => { "share_summary_content must not be empty — the share page's copy button " + "renders this template and writeText('') silently clears the clipboard", ) - assert.ok( - tpl.includes("{{base_url}}/@s/{{id}}"), - `share template must render a share link, got: ${tpl}`, + assert.equal( + tpl, + "{{base_url}}/@s/{{id}}", + 'the "copy link" button must render a bare share URL, not a share blurb', ) } -test("share copy link: an empty persisted share_summary_content is migrated", async () => { - // 模拟线上已部署实例:空串已经落盘,之后通过 loadDb() 读取(该路径会执行 - // ensureDefaultSettings 的 LEGACY_SETTING_MIGRATIONS 迁移)。 - // 直接 saveDb() 的结果会写进 dbCache,短 TTL 内 getDb() 不会走 loadDb, - // 所以这里先落盘、等缓存过期,再验证迁移后的对外表现。 +/** + * 上一版曾采用的上游 Go 分享摘要文案(与 db.ts 的 LEGACY_SHARE_SUMMARY_GO 一致)。 + * 迁移必须把这类已落盘的值换成纯链接。 + */ +const LEGACY_GO_BLURB = `@{{creator}} shared {{#each files}}{{#if @first}}"{{filename this}}"{{/if}}{{#if @last}}{{#unless (eq @index 0)}} and {{@index}} more files{{/unless}}{{/if}}{{/each}} from {{site_title}}: {{base_url}}/@s/{{id}}{{#if pwd}} , the share code is {{pwd}}{{/if}}{{#if expires}}, please access before {{dateLocaleString expires}}.{{/if}}` + +/** + * 模拟已部署实例:某个值已经落盘,之后通过 loadDb() 读取(该路径会执行 + * ensureDefaultSettings 的 LEGACY_SETTING_MIGRATIONS 迁移)。 + * 直接 saveDb() 的结果会写进 dbCache,短 TTL 内 getDb() 不会走 loadDb, + * 所以先落盘、等缓存过期,再取迁移后的值。 + */ +const readMigratedShareSummary = async (persistedValue: string) => { const env: any = { DB_DRIVER: "auto", DB_FORMAT: "map", @@ -126,16 +135,28 @@ test("share copy link: an empty persisted share_summary_content is migrated", as ENCRYPTION_SECRET: "test-secret-for-share-summary", } await saveDb( - { settings: [{ key: "share_summary_content", value: "" }], users: [], storages: [], shares: [] }, + { + settings: [{ key: "share_summary_content", value: persistedValue }], + users: [], + storages: [], + shares: [], + }, env, { force: true }, ) await new Promise((r) => setTimeout(r, 1100)) const db = await getDb(env) - const tpl = String( + return String( db.settings.find((s: any) => s.key === "share_summary_content")?.value || "", ) - assertShareTemplateUsable(tpl) +} + +test("share copy link: an empty persisted share_summary_content is migrated", async () => { + assertShareTemplateUsable(await readMigratedShareSummary("")) +}) + +test("share copy link: the legacy upstream share blurb is migrated", async () => { + assertShareTemplateUsable(await readMigratedShareSummary(LEGACY_GO_BLURB)) }) test("share copy link: a missing share_summary_content falls back to default", async () => { From e221c9eacab160d15479e7854ee83360066184ca Mon Sep 17 00:00:00 2001 From: Astroptis <250784029+Astroptis@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:41:46 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(drivers):=20=E4=BF=AE=E5=A4=8D=20remove?= =?UTF-8?q?/move/copy=20=E6=8A=8A=20name=20=E9=87=8D=E5=A4=8D=E6=8B=BC?= =?UTF-8?q?=E6=8E=A5=E5=88=B0=E5=B7=B2=E5=90=AB=E6=96=87=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E7=9A=84=E7=89=A9=E7=90=86=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit op/storage.ts 的 removeItems/moveItems/copyItems 逐项调用 driver,传入的 physicalPath / srcPhys / dstPhys 是目标项自身的物理路径(resolvePath 已经把 name 解析进去)。多数 driver 却把它当作目录再拼一次 name,得到 `/` 这种不存在的路径: - remove:删除不存在的路径。接口静默返回成功,对象仍在——用户报的 「对象存储删除显示 Success 但文件还在」就是这个根因(s3 已单独修复); - move/copy:源/目标路径错位,操作静默失败或作用到错误对象。 按同一约定修复 20 个 driver:azure_blob, uss, chunk, terabox, mopan, onedrive, onedrive_app, pikpak, mediatrack, google_drive, mega, dropbox, wps, yandex, seafile, ftp, sftp, webdav, alias, 139。 各 API 的语义差异按「physical 即目标项」重新推导:如 terabox filemanager 的 dest 是目标目录取 dstPhys 父级、名字取末段;pikpak/mediatrack 的 resolveParentId 实际返回路径自身 id,故先去掉末段再解析。names.length > 1 时保留「目录 + 多个 name」的展开分支(调用方恒传 1 个,属防御性代码)。 审查后确认本就正确的实现未改动:kodbox, halalcloud_open, url_tree, netease_music, 189, 189_tv, smb, google_drive/util 等。 Co-Authored-By: opencode --- src/backend/drivers/139/driver.ts | 11 ++- src/backend/drivers/alias/driver.ts | 42 +++++++++-- src/backend/drivers/azure_blob/driver.ts | 34 +++++++-- src/backend/drivers/chunk/driver.ts | 7 +- src/backend/drivers/dropbox/driver.ts | 54 ++++++++++---- src/backend/drivers/ftp/driver.ts | 38 +++++----- src/backend/drivers/google_drive/driver.ts | 20 +++-- src/backend/drivers/mediatrack/driver.ts | 75 +++++++++++++++---- src/backend/drivers/mega/driver.ts | 51 ++++++++----- src/backend/drivers/mopan/driver.ts | 31 ++++++-- src/backend/drivers/onedrive/driver.ts | 72 ++++++++++++++---- src/backend/drivers/onedrive_app/driver.ts | 70 ++++++++++++++---- src/backend/drivers/pikpak/driver.ts | 78 +++++++++++++++----- src/backend/drivers/seafile/driver.ts | 59 +++++++++++---- src/backend/drivers/sftp/driver.ts | 38 +++++----- src/backend/drivers/terabox/driver.ts | 51 +++++++++---- src/backend/drivers/uss/driver.ts | 34 +++++++-- src/backend/drivers/webdav/driver.ts | 43 +++++++---- src/backend/drivers/wps/driver.ts | 85 +++++++++++++++------- src/backend/drivers/yandex/driver.ts | 56 ++++++++++---- 20 files changed, 704 insertions(+), 245 deletions(-) diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index 1a26fb5a..b52fcfa0 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -204,7 +204,16 @@ export class Yun139Driver implements StorageDriver { names: string[], ): Promise { const clean = this.cleanPath(physicalPath) - const catalogId = await this.resolveCatalogId(clean) + // physicalPath 是目标项自身的路径(op/storage.ts removeItems 逐项调用), + // 而下面要按 name 在目录里查找删除;把项路径当目录解析会走进目标项内部 + //(目录项尤其明显),相当于找 /,永远落空并静默返回成功。 + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(调用方恒传 1 个, + // 此分支是防御性的)。 + const expand = names && names.length > 1 + const dirPath = expand + ? clean + : clean.substring(0, clean.lastIndexOf("/")) || "/" + const catalogId = await this.resolveCatalogId(dirPath) const disk = await this.client.listFiles(catalogId) for (const name of names) { diff --git a/src/backend/drivers/alias/driver.ts b/src/backend/drivers/alias/driver.ts index 20e1c9fc..1dfc828e 100644 --- a/src/backend/drivers/alias/driver.ts +++ b/src/backend/drivers/alias/driver.ts @@ -22,6 +22,12 @@ export class AliasDriver implements StorageDriver { return s === "/" ? "/" : s } + /** 取所在目录:physicalPath 是目标项自身路径时,去掉最后一段即其父目录 */ + private parentPath(p: string): string { + const clean = this.cleanPath(p) + return clean.substring(0, clean.lastIndexOf("/")) || "/" + } + private parsePaths(): void { const raw = this.addition.paths || "" const lines = raw @@ -231,7 +237,15 @@ export class AliasDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const targets = this.getTargetsForPath(physicalPath) + // physicalPath 是目标项自身的路径,而 removeItems 需要「目录 + names」 + //(内部会再拼 name);直接把项路径当目录传入会得到 /, + // 目标不存在导致删除静默失败。仅当 names.length > 1 时按「目录 + 多个 + // name」展开(调用方恒传 1 个,此分支是防御性的)。 + const expand = names && names.length > 1 + const dirPath = expand + ? this.cleanPath(physicalPath) + : this.parentPath(physicalPath) + const targets = this.getTargetsForPath(dirPath) if (targets.length === 0) return const { removeItems } = await import("../../internal/op/storage") await removeItems(targets[0].targetFullPath, names) @@ -244,8 +258,18 @@ export class AliasDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcTargets = this.getTargetsForPath(srcPhys) - const dstTargets = this.getTargetsForPath(dstPhys) + // srcPhys/dstPhys 是源/目标项自身的路径,moveItems 需要的是各自所在 + // 目录(内部会再拼 name);传项路径会得到 /,操作静默失败。 + // 仅当 names.length > 1 时按「目录 + 多个 name」展开(防御性分支)。 + const expand = names && names.length > 1 + const srcDirPath = expand + ? this.cleanPath(srcPhys) + : this.parentPath(srcPhys) + const dstDirPath = expand + ? this.cleanPath(dstPhys) + : this.parentPath(dstPhys) + const srcTargets = this.getTargetsForPath(srcDirPath) + const dstTargets = this.getTargetsForPath(dstDirPath) if (srcTargets.length === 0 || dstTargets.length === 0) { throw new Error("[Alias] cannot resolve source or destination path") } @@ -264,8 +288,16 @@ export class AliasDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcTargets = this.getTargetsForPath(srcPhys) - const dstTargets = this.getTargetsForPath(dstPhys) + // 同 move:srcPhys/dstPhys 是项自身路径,copyItems 需要所在目录。 + const expand = names && names.length > 1 + const srcDirPath = expand + ? this.cleanPath(srcPhys) + : this.parentPath(srcPhys) + const dstDirPath = expand + ? this.cleanPath(dstPhys) + : this.parentPath(dstPhys) + const srcTargets = this.getTargetsForPath(srcDirPath) + const dstTargets = this.getTargetsForPath(dstDirPath) if (srcTargets.length === 0 || dstTargets.length === 0) { throw new Error("[Alias] cannot resolve source or destination path") } diff --git a/src/backend/drivers/azure_blob/driver.ts b/src/backend/drivers/azure_blob/driver.ts index 8987df42..97821a34 100644 --- a/src/backend/drivers/azure_blob/driver.ts +++ b/src/backend/drivers/azure_blob/driver.ts @@ -302,9 +302,21 @@ export class AzureBlobDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - for (const name of names) { - const srcKey = joinPath(this.getKey(srcPhys), name) - const dstKey = joinPath(this.getKey(dstPhys), name) + // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 不得再拼 name,否则会指向 `/` 这种不存在的 blob; + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => + [ + joinPath(this.getKey(srcPhys), n), + joinPath(this.getKey(dstPhys), n), + ] as const, + ) + : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) + + for (const [srcKey, dstKey] of pairs) { await this.copyBlob(srcKey, dstKey) await this.deleteBlob(srcKey) } @@ -317,9 +329,19 @@ export class AzureBlobDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - for (const name of names) { - const srcKey = joinPath(this.getKey(srcPhys), name) - const dstKey = joinPath(this.getKey(dstPhys), name) + // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => + [ + joinPath(this.getKey(srcPhys), n), + joinPath(this.getKey(dstPhys), n), + ] as const, + ) + : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) + + for (const [srcKey, dstKey] of pairs) { await this.copyBlob(srcKey, dstKey) } } diff --git a/src/backend/drivers/chunk/driver.ts b/src/backend/drivers/chunk/driver.ts index 9ad91afb..87d2f0e1 100644 --- a/src/backend/drivers/chunk/driver.ts +++ b/src/backend/drivers/chunk/driver.ts @@ -239,12 +239,14 @@ export class ChunkDriver implements StorageDriver { await this.remoteDriver!.get("", srcRemote) await this.remoteDriver!.move(srcDir, dstDir, names, srcRemote, dstRemote) } catch { + // 分片文件:dst 端必须是目标项自己的分片目录(与虚拟文件同级), + // 拼成 `<目标项>/` 会指向不存在的路径。 await this.remoteDriver!.move( srcDir, dstDir, names, this.chunkDirRemote(srcPhysical), - joinPath(dstRemote, this.chunkPrefix + basename(srcPhysical)), + this.chunkDirRemote(dstPhysical), ) } } @@ -263,12 +265,13 @@ export class ChunkDriver implements StorageDriver { await this.remoteDriver!.get("", srcRemote) await this.remoteDriver!.copy(srcDir, dstDir, names, srcRemote, dstRemote) } catch { + // 分片文件:同 move,dst 端用目标项自己的分片目录。 await this.remoteDriver!.copy( srcDir, dstDir, names, this.chunkDirRemote(srcPhysical), - joinPath(dstRemote, this.chunkPrefix + basename(srcPhysical)), + this.chunkDirRemote(dstPhysical), ) } } diff --git a/src/backend/drivers/dropbox/driver.ts b/src/backend/drivers/dropbox/driver.ts index 1569727b..89bb295f 100644 --- a/src/backend/drivers/dropbox/driver.ts +++ b/src/backend/drivers/dropbox/driver.ts @@ -127,9 +127,15 @@ export class DropboxDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const cleanDir = this.cleanPath(physicalPath) - for (const name of names) { - const target = cleanDir === "" ? `/${name}` : `${cleanDir}/${name}` + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 直接删除它即可;再拼 name 会指向 `/`,delete 404 报错。 + const expand = names && names.length > 1 + const base = this.cleanPath(physicalPath) + const targets = expand + ? names.map((n) => (base === "" ? `/${n}` : `${base}/${n}`)) + : [base] + + for (const target of targets) { await this.client.delete(target) } } @@ -141,11 +147,22 @@ export class DropboxDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const cleanSrc = this.cleanPath(srcPhys) - const cleanDst = this.cleanPath(dstPhys) - for (const name of names) { - const from = cleanSrc === "" ? `/${name}` : `${cleanSrc}/${name}` - const to = cleanDst === "" ? `/${name}` : `${cleanDst}/${name}` + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 直接作为 from/to 使用;再拼 name 会指向 `/`,源/目标错位。 + const expand = names && names.length > 1 + const srcBase = this.cleanPath(srcPhys) + const dstBase = this.cleanPath(dstPhys) + const pairs = expand + ? names.map( + (n) => + [ + srcBase === "" ? `/${n}` : `${srcBase}/${n}`, + dstBase === "" ? `/${n}` : `${dstBase}/${n}`, + ] as const, + ) + : ([[srcBase, dstBase]] as const) + + for (const [from, to] of pairs) { await this.client.move(from, to) } } @@ -157,11 +174,22 @@ export class DropboxDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const cleanSrc = this.cleanPath(srcPhys) - const cleanDst = this.cleanPath(dstPhys) - for (const name of names) { - const from = cleanSrc === "" ? `/${name}` : `${cleanSrc}/${name}` - const to = cleanDst === "" ? `/${name}` : `${cleanDst}/${name}` + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 直接作为 from/to 使用;再拼 name 会指向 `/`,源/目标错位。 + const expand = names && names.length > 1 + const srcBase = this.cleanPath(srcPhys) + const dstBase = this.cleanPath(dstPhys) + const pairs = expand + ? names.map( + (n) => + [ + srcBase === "" ? `/${n}` : `${srcBase}/${n}`, + dstBase === "" ? `/${n}` : `${dstBase}/${n}`, + ] as const, + ) + : ([[srcBase, dstBase]] as const) + + for (const [from, to] of pairs) { await this.client.copy(from, to) } } diff --git a/src/backend/drivers/ftp/driver.ts b/src/backend/drivers/ftp/driver.ts index 373d6dec..fa68a2cf 100644 --- a/src/backend/drivers/ftp/driver.ts +++ b/src/backend/drivers/ftp/driver.ts @@ -140,33 +140,35 @@ export class FTPDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const targetDir = cleanPosixPath(physicalPath) - if (names && names.length > 0) { - for (const name of names) { - await this.client.removeRecursive(posixJoin(targetDir, name)) - } - } else { - await this.client.removeRecursive(targetDir) + // physicalPath 已是目标项自身的物理路径(op/storage.ts 逐项解析后传入), + // 再拼一次 name 会指向不存在的 /,导致静默删除失败。 + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 + const expand = names && names.length > 1 + const targets = expand + ? names.map((n) => posixJoin(physicalPath, n)) + : [cleanPosixPath(physicalPath)] + for (const target of targets) { + await this.client.removeRecursive(target) } } async move( _srcDir: string, - dstDir: string, + _dstDir: string, names: string[], srcPhys: string, dstPhys: string, ): Promise { - if (names && names.length > 0) { - for (const name of names) { - const src = posixJoin(srcPhys, name) - const dst = posixJoin(dstPhys, name) - await this.client.rename(src, dst) - } - } else { - const filename = srcPhys.split("/").filter(Boolean).pop() || "" - const dst = posixJoin(dstDir, filename) - await this.client.rename(cleanPosixPath(srcPhys), dst) + // srcPhys/dstPhys 已是源/目标项自身的物理路径,直接重命名即可; + // 再拼 name 会让源/目标路径错位。多 name 时才按「目录 + name」展开。 + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => [posixJoin(srcPhys, n), posixJoin(dstPhys, n)] as const, + ) + : [[cleanPosixPath(srcPhys), cleanPosixPath(dstPhys)] as const] + for (const [src, dst] of pairs) { + await this.client.rename(src, dst) } } diff --git a/src/backend/drivers/google_drive/driver.ts b/src/backend/drivers/google_drive/driver.ts index 0da07d4b..ba6bbcbc 100644 --- a/src/backend/drivers/google_drive/driver.ts +++ b/src/backend/drivers/google_drive/driver.ts @@ -106,11 +106,11 @@ export class GoogleDrive implements StorageDriver { } async move( - srcDir: string, - dstDir: string, + _srcDir: string, + _dstDir: string, _names: string[], srcPhysical: string, - _dstPhysical: string, + dstPhysical: string, ): Promise { const fileId = await this.client.resolveFileId(srcPhysical) const srcParts = srcPhysical.split("/").filter(Boolean) @@ -118,20 +118,24 @@ export class GoogleDrive implements StorageDriver { const srcParentId = await this.client.resolveFileId( "/" + srcParts.join("/"), ) - const dstParentId = await this.client.resolveFileId(dstDir) + // dstPhysical 是目标项自身的物理路径,父目录去掉末段即得; + // dstDir 是虚拟路径(含挂载前缀),而 resolveFileId 期望物理路径,直接用会错位。 + const { parentId: dstParentId } = + await this.client.resolveParentAndName(dstPhysical) await this.client.move(fileId, srcParentId, dstParentId) } async copy( _srcDir: string, - dstDir: string, + _dstDir: string, _names: string[], srcPhysical: string, - _dstPhysical: string, + dstPhysical: string, ): Promise { const fileId = await this.client.resolveFileId(srcPhysical) - const name = srcPhysical.split("/").filter(Boolean).pop() || "copy" - const dstParentId = await this.client.resolveFileId(dstDir) + // 目标父目录与目标名字均以 dstPhysical(目标项自身路径)为准。 + const { parentId: dstParentId, name } = + await this.client.resolveParentAndName(dstPhysical) await this.client.copy(fileId, dstParentId, name) } diff --git a/src/backend/drivers/mediatrack/driver.ts b/src/backend/drivers/mediatrack/driver.ts index 398a35c3..a8338c7d 100644 --- a/src/backend/drivers/mediatrack/driver.ts +++ b/src/backend/drivers/mediatrack/driver.ts @@ -202,12 +202,26 @@ export class MediatrackDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const parentId = await this.resolveParentId(physicalPath) + // physicalPath 是目标项自身的物理路径,而 resolveParentId 解析的是路径 + // 自身的 id;拿它列子项再按 title 查找,命中的其实是 /, + // 找不到时静默跳过,对象仍然存在。 + const expand = names && names.length > 1 + const clean = this.cleanPath(physicalPath) + const targets = expand + ? names.map((n) => (clean ? `${clean}/${n}` : `/${n}`)) + : [clean] + // 父目录:展开时 physicalPath 即公共父目录,否则取目标项的父目录 + const parentPath = expand + ? clean + : clean.split("/").slice(0, -1).join("/") + const parentId = await this.resolveParentId(parentPath) const files = await this.client.getFiles(parentId) const ids: string[] = [] - for (const name of names) { - const match = files.find((f) => f.title === name) + for (const target of targets) { + const match = files.find( + (f) => f.title === (target.split("/").pop() || ""), + ) if (match) { ids.push(match.id) } @@ -226,9 +240,8 @@ export class MediatrackDriver implements StorageDriver { }, ) - const clean = this.cleanPath(physicalPath) - for (const name of names) { - this.idCache.delete(clean ? `${clean}/${name}` : `/${name}`) + for (const target of targets) { + this.idCache.delete(target) } } @@ -239,13 +252,31 @@ export class MediatrackDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcParentId = await this.resolveParentId(srcPhys) - const dstParentId = await this.resolveParentId(dstPhys) + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name), + // 直接当父目录解析会指到项本身:源侧会去 的子项里找 title(永远落空), + // 目标侧解析一个尚不存在的目标项必然报错。 + const expand = names && names.length > 1 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + const targets = expand + ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) + : [srcClean] + const srcParentPath = expand + ? srcClean + : srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = expand + ? dstClean + : dstClean.split("/").slice(0, -1).join("/") + + const srcParentId = await this.resolveParentId(srcParentPath) + const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.client.getFiles(srcParentId) const ids: string[] = [] - for (const name of names) { - const match = srcFiles.find((f) => f.title === name) + for (const target of targets) { + const match = srcFiles.find( + (f) => f.title === (target.split("/").pop() || ""), + ) if (match) { ids.push(match.id) } @@ -272,13 +303,29 @@ export class MediatrackDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcParentId = await this.resolveParentId(srcPhys) - const dstParentId = await this.resolveParentId(dstPhys) + // 与 move 同理:源/目标项自身路径先去掉末段得到父目录再解析 id。 + const expand = names && names.length > 1 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + const targets = expand + ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) + : [srcClean] + const srcParentPath = expand + ? srcClean + : srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = expand + ? dstClean + : dstClean.split("/").slice(0, -1).join("/") + + const srcParentId = await this.resolveParentId(srcParentPath) + const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.client.getFiles(srcParentId) const ids: string[] = [] - for (const name of names) { - const match = srcFiles.find((f) => f.title === name) + for (const target of targets) { + const match = srcFiles.find( + (f) => f.title === (target.split("/").pop() || ""), + ) if (match) { ids.push(match.id) } diff --git a/src/backend/drivers/mega/driver.ts b/src/backend/drivers/mega/driver.ts index 7d6b2fa1..1ff8e85b 100644 --- a/src/backend/drivers/mega/driver.ts +++ b/src/backend/drivers/mega/driver.ts @@ -152,17 +152,29 @@ export class MegaDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstNode = this.resolveNodeByPath(this.cleanPath(dstPhys)) - if (!dstNode || !dstNode.is_dir) { - throw new Error("Destination folder not found") - } - - for (const name of names) { - const srcItemPath = - this.cleanPath(srcPhys) === "/" - ? `/${name}` - : `${this.cleanPath(srcPhys)}/${name}` - const srcNode = this.resolveNodeByPath(srcItemPath) + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 源直接用 srcPhys;目标父目录取 dstPhys 的父级,再拼 name 会指向 `/`。 + const expand = names && names.length > 1 + const srcBase = this.cleanPath(srcPhys) + const dstBase = this.cleanPath(dstPhys) + const pairs = expand + ? names.map( + (n) => + [ + srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, + dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, + ] as const, + ) + : ([[srcBase, dstBase]] as const) + + for (const [srcPath, dstPath] of pairs) { + const dstParentPath = + dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" + const dstNode = this.resolveNodeByPath(dstParentPath) + if (!dstNode || !dstNode.is_dir) { + throw new Error("Destination folder not found") + } + const srcNode = this.resolveNodeByPath(srcPath) if (srcNode) { await this.client.moveNode(srcNode.id, dstNode.id) } @@ -186,12 +198,17 @@ export class MegaDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - for (const name of names) { - const itemPath = - this.cleanPath(physicalPath) === "/" - ? `/${name}` - : `${this.cleanPath(physicalPath)}/${name}` - const node = this.resolveNodeByPath(itemPath) + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 直接解析它即可;再拼 name 会指向 `/`,resolveNodeByPath 返回 + // null 后被静默跳过,接口成功但节点仍在。 + const expand = names && names.length > 1 + const base = this.cleanPath(physicalPath) + const targets = expand + ? names.map((n) => (base === "/" ? `/${n}` : `${base}/${n}`)) + : [base] + + for (const targetPath of targets) { + const node = this.resolveNodeByPath(targetPath) if (node) { await this.client.deleteNode(node.id) } diff --git a/src/backend/drivers/mopan/driver.ts b/src/backend/drivers/mopan/driver.ts index 6871f656..5c735f5c 100644 --- a/src/backend/drivers/mopan/driver.ts +++ b/src/backend/drivers/mopan/driver.ts @@ -275,9 +275,20 @@ export class MoPanDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstFolderId = await this.resolveFolderId(dstPhys) - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` + // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 不得再拼 name,否则会指向 `/` 这种不存在的路径; + // 实际调用(单 name)时 dstPhys 是目标项,目标文件夹取其父目录。 + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 + const expand = names && names.length > 1 + const srcPaths = expand + ? names.map((n) => (srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`)) + : [srcPhys] + const dstFolderPath = expand + ? dstPhys + : dstPhys.split("/").slice(0, -1).join("/") + const dstFolderId = await this.resolveFolderId(dstFolderPath) + + for (const srcItemPath of srcPaths) { const srcItem = await this.get(srcDir, srcItemPath) await this.performBatchTask(srcItem, dstFolderId, TaskTypeMove) } @@ -290,9 +301,17 @@ export class MoPanDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstFolderId = await this.resolveFolderId(dstPhys) - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` + // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 + const expand = names && names.length > 1 + const srcPaths = expand + ? names.map((n) => (srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`)) + : [srcPhys] + const dstFolderPath = expand + ? dstPhys + : dstPhys.split("/").slice(0, -1).join("/") + const dstFolderId = await this.resolveFolderId(dstFolderPath) + + for (const srcItemPath of srcPaths) { const srcItem = await this.get(srcDir, srcItemPath) await this.performBatchTask(srcItem, dstFolderId, TaskTypeCopy) } diff --git a/src/backend/drivers/onedrive/driver.ts b/src/backend/drivers/onedrive/driver.ts index 1f648844..fe85ffcd 100644 --- a/src/backend/drivers/onedrive/driver.ts +++ b/src/backend/drivers/onedrive/driver.ts @@ -222,10 +222,17 @@ export class Onedrive implements StorageDriver { physicalPath: string, names: string[], ): Promise { - for (const name of names) { - const itemPath = - physicalPath === "/" ? `/${name}` : `${physicalPath}/${name}` - const url = this.getMetaUrl(false, itemPath) + // physicalPath 是目标项自身的物理路径(op/storage.ts 逐项调用), + // 再拼一次 name 会指向 /,DELETE 404 导致对象仍然存在。 + const expand = names && names.length > 1 + const targets = expand + ? names.map((n) => + physicalPath === "/" ? `/${n}` : `${physicalPath}/${n}`, + ) + : [physicalPath] + + for (const targetPath of targets) { + const url = this.getMetaUrl(false, targetPath) await requestApi(this, url, "DELETE") } } @@ -237,23 +244,39 @@ export class Onedrive implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // Determine the destination parent reference - // Fetch dstPhys details to get its ID, or construct parentReference path - const dstUrl = this.getMetaUrl(false, dstPhys) + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name): + // 目标父目录需去掉末段(直接 GET dstPhys 拿到的是目标项本身,通常还不存在), + // 目标名字以 dstPhys 末段为准,源项路径则直接使用 srcPhys。 + const expand = names && names.length > 1 + const dstParentPath = expand + ? dstPhys + : dstPhys.split("/").slice(0, -1).join("/") || "/" + const dstUrl = this.getMetaUrl(false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` + const targets = expand + ? names.map((n) => ({ + srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, + name: n, + })) + : [ + { + srcPath: srcPhys, + name: dstPhys.split("/").filter(Boolean).pop() || "", + }, + ] + + for (const target of targets) { const data = { parentReference: { id: dstId, ...(driveId ? { driveId } : {}), }, - name, + name: target.name, } - const url = this.getMetaUrl(false, srcItemPath) + const url = this.getMetaUrl(false, target.srcPath) await requestApi(this, url, "PATCH", data) } } @@ -265,21 +288,38 @@ export class Onedrive implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstUrl = this.getMetaUrl(false, dstPhys) + // 与 move 同理:目标父目录 = dstPhys 去掉末段,目标名字取 dstPhys 末段, + // 源项路径直接使用 srcPhys,不再重复拼接 name。 + const expand = names && names.length > 1 + const dstParentPath = expand + ? dstPhys + : dstPhys.split("/").slice(0, -1).join("/") || "/" + const dstUrl = this.getMetaUrl(false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` + const targets = expand + ? names.map((n) => ({ + srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, + name: n, + })) + : [ + { + srcPath: srcPhys, + name: dstPhys.split("/").filter(Boolean).pop() || "", + }, + ] + + for (const target of targets) { const data = { parentReference: { id: dstId, ...(driveId ? { driveId } : {}), }, - name, + name: target.name, } - const url = this.getMetaUrl(false, srcItemPath, "copy") + const url = this.getMetaUrl(false, target.srcPath, "copy") await requestApi(this, url, "POST", data) } } diff --git a/src/backend/drivers/onedrive_app/driver.ts b/src/backend/drivers/onedrive_app/driver.ts index 9b9b37f0..06f086d4 100644 --- a/src/backend/drivers/onedrive_app/driver.ts +++ b/src/backend/drivers/onedrive_app/driver.ts @@ -161,10 +161,17 @@ export class OnedriveAPP implements StorageDriver { physicalPath: string, names: string[], ): Promise { - for (const name of names) { - const itemPath = - physicalPath === "/" ? `/${name}` : `${physicalPath}/${name}` - const url = getMetaUrl(this, false, itemPath) + // physicalPath 是目标项自身的物理路径(op/storage.ts 逐项调用), + // 再拼一次 name 会指向 /,DELETE 404 导致对象仍然存在。 + const expand = names && names.length > 1 + const targets = expand + ? names.map((n) => + physicalPath === "/" ? `/${n}` : `${physicalPath}/${n}`, + ) + : [physicalPath] + + for (const targetPath of targets) { + const url = getMetaUrl(this, false, targetPath) await requestApi(this, url, "DELETE") } } @@ -176,21 +183,39 @@ export class OnedriveAPP implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstUrl = getMetaUrl(this, false, dstPhys) + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name): + // 目标父目录需去掉末段(直接 GET dstPhys 拿到的是目标项本身,通常还不存在), + // 目标名字以 dstPhys 末段为准,源项路径则直接使用 srcPhys。 + const expand = names && names.length > 1 + const dstParentPath = expand + ? dstPhys + : dstPhys.split("/").slice(0, -1).join("/") || "/" + const dstUrl = getMetaUrl(this, false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` + const targets = expand + ? names.map((n) => ({ + srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, + name: n, + })) + : [ + { + srcPath: srcPhys, + name: dstPhys.split("/").filter(Boolean).pop() || "", + }, + ] + + for (const target of targets) { const data = { parentReference: { id: dstId, ...(driveId ? { driveId } : {}), }, - name, + name: target.name, } - const url = getMetaUrl(this, false, srcItemPath) + const url = getMetaUrl(this, false, target.srcPath) await requestApi(this, url, "PATCH", data) } } @@ -202,21 +227,38 @@ export class OnedriveAPP implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstUrl = getMetaUrl(this, false, dstPhys) + // 与 move 同理:目标父目录 = dstPhys 去掉末段,目标名字取 dstPhys 末段, + // 源项路径直接使用 srcPhys,不再重复拼接 name。 + const expand = names && names.length > 1 + const dstParentPath = expand + ? dstPhys + : dstPhys.split("/").slice(0, -1).join("/") || "/" + const dstUrl = getMetaUrl(this, false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` + const targets = expand + ? names.map((n) => ({ + srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, + name: n, + })) + : [ + { + srcPath: srcPhys, + name: dstPhys.split("/").filter(Boolean).pop() || "", + }, + ] + + for (const target of targets) { const data = { parentReference: { id: dstId, ...(driveId ? { driveId } : {}), }, - name, + name: target.name, } - const url = getMetaUrl(this, false, srcItemPath, "copy") + const url = getMetaUrl(this, false, target.srcPath, "copy") await requestApi(this, url, "POST", data) } } diff --git a/src/backend/drivers/pikpak/driver.ts b/src/backend/drivers/pikpak/driver.ts index 40e09df4..3eafa570 100644 --- a/src/backend/drivers/pikpak/driver.ts +++ b/src/backend/drivers/pikpak/driver.ts @@ -257,12 +257,24 @@ export class PikPakDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const parentId = await this.resolveParentId(physicalPath) + // physicalPath 是目标项自身的物理路径,而 resolveParentId 解析的是路径 + // 自身的 id;拿它列子项再按 name 查找,命中的其实是 /, + // 找不到时静默跳过,对象仍然存在。 + const expand = names && names.length > 1 + const clean = this.cleanPath(physicalPath) + const targets = expand + ? names.map((n) => (clean ? `${clean}/${n}` : `/${n}`)) + : [clean] + // 父目录:展开时 physicalPath 即公共父目录,否则取目标项的父目录 + const parentPath = expand + ? clean + : clean.split("/").slice(0, -1).join("/") + const parentId = await this.resolveParentId(parentPath) const files = await this.getFiles(parentId) const ids: string[] = [] - for (const name of names) { - const match = files.find((f) => f.name === name) + for (const target of targets) { + const match = files.find((f) => f.name === (target.split("/").pop() || "")) if (match) { ids.push(match.id) } @@ -280,9 +292,8 @@ export class PikPakDriver implements StorageDriver { }, ) - const clean = this.cleanPath(physicalPath) - for (const name of names) { - this.idCache.delete(clean ? `${clean}/${name}` : `/${name}`) + for (const target of targets) { + this.idCache.delete(target) } } @@ -293,13 +304,31 @@ export class PikPakDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcParentId = await this.resolveParentId(srcPhys) - const dstParentId = await this.resolveParentId(dstPhys) + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name), + // 直接当父目录解析会指到项本身:源侧会去 的子项里找 name(永远落空), + // 目标侧解析一个尚不存在的目标项必然报错。 + const expand = names && names.length > 1 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + const targets = expand + ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) + : [srcClean] + const srcParentPath = expand + ? srcClean + : srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = expand + ? dstClean + : dstClean.split("/").slice(0, -1).join("/") + + const srcParentId = await this.resolveParentId(srcParentPath) + const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.getFiles(srcParentId) const ids: string[] = [] - for (const name of names) { - const match = srcFiles.find((f) => f.name === name) + for (const target of targets) { + const match = srcFiles.find( + (f) => f.name === (target.split("/").pop() || ""), + ) if (match) { ids.push(match.id) } @@ -320,9 +349,8 @@ export class PikPakDriver implements StorageDriver { }, ) - const srcClean = this.cleanPath(srcPhys) - for (const name of names) { - this.idCache.delete(srcClean ? `${srcClean}/${name}` : `/${name}`) + for (const target of targets) { + this.idCache.delete(target) } } @@ -333,13 +361,29 @@ export class PikPakDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcParentId = await this.resolveParentId(srcPhys) - const dstParentId = await this.resolveParentId(dstPhys) + // 与 move 同理:源/目标项自身路径先去掉末段得到父目录再解析 id。 + const expand = names && names.length > 1 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + const targets = expand + ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) + : [srcClean] + const srcParentPath = expand + ? srcClean + : srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = expand + ? dstClean + : dstClean.split("/").slice(0, -1).join("/") + + const srcParentId = await this.resolveParentId(srcParentPath) + const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.getFiles(srcParentId) const ids: string[] = [] - for (const name of names) { - const match = srcFiles.find((f) => f.name === name) + for (const target of targets) { + const match = srcFiles.find( + (f) => f.name === (target.split("/").pop() || ""), + ) if (match) { ids.push(match.id) } diff --git a/src/backend/drivers/seafile/driver.ts b/src/backend/drivers/seafile/driver.ts index 7a432991..7e9d1c28 100644 --- a/src/backend/drivers/seafile/driver.ts +++ b/src/backend/drivers/seafile/driver.ts @@ -264,9 +264,16 @@ export class SeafileDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // resolveRepoAndPath 已解析到目标项;再拼 name 会指向 `/`, + // DELETE 到不存在的路径,接口成功但文件仍在。 + const expand = names && names.length > 1 const { repoId, innerPath } = await this.resolveRepoAndPath(physicalPath) - for (const name of names) { - const targetPath = innerPath === "/" ? `/${name}` : `${innerPath}/${name}` + const targets = expand + ? names.map((n) => (innerPath === "/" ? `/${n}` : `${innerPath}/${n}`)) + : [innerPath] + + for (const targetPath of targets) { await this.client.request( `/api2/repos/${encodeURIComponent(repoId)}/file/`, { @@ -284,22 +291,34 @@ export class SeafileDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 源路径直接用 srcPath;dst_dir 需要目录,取 dstPath 的父级,再拼 name 会指向 + // `/`。 + const expand = names && names.length > 1 const src = await this.resolveRepoAndPath(srcPhys) const dst = await this.resolveRepoAndPath(dstPhys) - - for (const name of names) { - const targetPath = - src.innerPath === "/" ? `/${name}` : `${src.innerPath}/${name}` + const pairs = expand + ? names.map( + (n) => + [ + src.innerPath === "/" ? `/${n}` : `${src.innerPath}/${n}`, + dst.innerPath === "/" ? `/${n}` : `${dst.innerPath}/${n}`, + ] as const, + ) + : ([[src.innerPath, dst.innerPath]] as const) + + for (const [srcPath, dstPath] of pairs) { + const dstDirPath = dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" await this.client.request( `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, { method: "POST", isFormData: true, - params: { p: targetPath }, + params: { p: srcPath }, body: { operation: "move", dst_repo: dst.repoId, - dst_dir: dst.innerPath, + dst_dir: dstDirPath, }, }, ) @@ -313,22 +332,34 @@ export class SeafileDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 源路径直接用 srcPath;dst_dir 需要目录,取 dstPath 的父级,再拼 name 会指向 + // `/`。 + const expand = names && names.length > 1 const src = await this.resolveRepoAndPath(srcPhys) const dst = await this.resolveRepoAndPath(dstPhys) - - for (const name of names) { - const targetPath = - src.innerPath === "/" ? `/${name}` : `${src.innerPath}/${name}` + const pairs = expand + ? names.map( + (n) => + [ + src.innerPath === "/" ? `/${n}` : `${src.innerPath}/${n}`, + dst.innerPath === "/" ? `/${n}` : `${dst.innerPath}/${n}`, + ] as const, + ) + : ([[src.innerPath, dst.innerPath]] as const) + + for (const [srcPath, dstPath] of pairs) { + const dstDirPath = dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" await this.client.request( `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, { method: "POST", isFormData: true, - params: { p: targetPath }, + params: { p: srcPath }, body: { operation: "copy", dst_repo: dst.repoId, - dst_dir: dst.innerPath, + dst_dir: dstDirPath, }, }, ) diff --git a/src/backend/drivers/sftp/driver.ts b/src/backend/drivers/sftp/driver.ts index 7eefe8db..0d74ad92 100644 --- a/src/backend/drivers/sftp/driver.ts +++ b/src/backend/drivers/sftp/driver.ts @@ -192,33 +192,35 @@ export class SFTPDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const targetDir = cleanPosixPath(physicalPath) - if (names && names.length > 0) { - for (const name of names) { - await this.client.removeRecursive(posixJoin(targetDir, name)) - } - } else { - await this.client.removeRecursive(targetDir) + // physicalPath 已是目标项自身的物理路径(op/storage.ts 逐项解析后传入), + // 再拼一次 name 会指向不存在的 /,导致静默删除失败。 + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 + const expand = names && names.length > 1 + const targets = expand + ? names.map((n) => posixJoin(physicalPath, n)) + : [cleanPosixPath(physicalPath)] + for (const target of targets) { + await this.client.removeRecursive(target) } } async move( _srcDir: string, - dstDir: string, + _dstDir: string, names: string[], srcPhys: string, dstPhys: string, ): Promise { - if (names && names.length > 0) { - for (const name of names) { - const src = posixJoin(srcPhys, name) - const dst = posixJoin(dstPhys, name) - await this.client.rename(src, dst) - } - } else { - const filename = srcPhys.split("/").filter(Boolean).pop() || "" - const dst = posixJoin(dstDir, filename) - await this.client.rename(cleanPosixPath(srcPhys), dst) + // srcPhys/dstPhys 已是源/目标项自身的物理路径,直接重命名即可; + // 再拼 name 会让源/目标路径错位。多 name 时才按「目录 + name」展开。 + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => [posixJoin(srcPhys, n), posixJoin(dstPhys, n)] as const, + ) + : [[cleanPosixPath(srcPhys), cleanPosixPath(dstPhys)] as const] + for (const [src, dst] of pairs) { + await this.client.rename(src, dst) } } diff --git a/src/backend/drivers/terabox/driver.ts b/src/backend/drivers/terabox/driver.ts index 0f1cef51..bb2d45c5 100644 --- a/src/backend/drivers/terabox/driver.ts +++ b/src/backend/drivers/terabox/driver.ts @@ -200,8 +200,13 @@ export class TeraboxDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 直接删除它即可;不得再拼 name,否则会指向 `/` 这种不存在的路径。 const clean = this.cleanPath(physicalPath) - const paths = names.map((n) => (clean === "/" ? `/${n}` : `${clean}/${n}`)) + const expand = names && names.length > 1 + const paths = expand + ? names.map((n) => (clean === "/" ? `/${n}` : `${clean}/${n}`)) + : [clean] await this.client.manage("delete", paths) } @@ -214,12 +219,23 @@ export class TeraboxDriver implements StorageDriver { ): Promise { const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) - - const fileList = names.map((name) => ({ - path: srcClean === "/" ? `/${name}` : `${srcClean}/${name}`, - dest: dstClean, - newname: name, - })) + // srcPhys/dstPhys 是目标项自身的路径:path 不得再拼 name; + // filemanager 的 dest 是目标目录,实际调用(单 name)时取 dstPhys 的父目录。 + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 + const expand = names && names.length > 1 + const fileList = expand + ? names.map((name) => ({ + path: srcClean === "/" ? `/${name}` : `${srcClean}/${name}`, + dest: dstClean, + newname: name, + })) + : [ + { + path: srcClean, + dest: dstClean.split("/").slice(0, -1).join("/") || "/", + newname: dstClean.split("/").pop() || "", + }, + ] await this.client.manage("move", fileList) } @@ -233,12 +249,21 @@ export class TeraboxDriver implements StorageDriver { ): Promise { const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) - - const fileList = names.map((name) => ({ - path: srcClean === "/" ? `/${name}` : `${srcClean}/${name}`, - dest: dstClean, - newname: name, - })) + // 同 move:srcPhys/dstPhys 已是目标项自身路径,path 不得再拼 name。 + const expand = names && names.length > 1 + const fileList = expand + ? names.map((name) => ({ + path: srcClean === "/" ? `/${name}` : `${srcClean}/${name}`, + dest: dstClean, + newname: name, + })) + : [ + { + path: srcClean, + dest: dstClean.split("/").slice(0, -1).join("/") || "/", + newname: dstClean.split("/").pop() || "", + }, + ] await this.client.manage("copy", fileList) } diff --git a/src/backend/drivers/uss/driver.ts b/src/backend/drivers/uss/driver.ts index c12f4f9c..3b7af3c8 100644 --- a/src/backend/drivers/uss/driver.ts +++ b/src/backend/drivers/uss/driver.ts @@ -180,9 +180,21 @@ export class UssDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - for (const name of names) { - const srcKey = joinPath(this.getKey(srcPhys), name) - const dstKey = joinPath(this.getKey(dstPhys), name) + // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 不得再拼 name,否则会指向 `/` 这种不存在的路径; + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => + [ + joinPath(this.getKey(srcPhys), n), + joinPath(this.getKey(dstPhys), n), + ] as const, + ) + : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) + + for (const [srcKey, dstKey] of pairs) { await this.moveOrCopy(srcKey, dstKey, "move") } } @@ -194,9 +206,19 @@ export class UssDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - for (const name of names) { - const srcKey = joinPath(this.getKey(srcPhys), name) - const dstKey = joinPath(this.getKey(dstPhys), name) + // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => + [ + joinPath(this.getKey(srcPhys), n), + joinPath(this.getKey(dstPhys), n), + ] as const, + ) + : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) + + for (const [srcKey, dstKey] of pairs) { await this.moveOrCopy(srcKey, dstKey, "copy") } } diff --git a/src/backend/drivers/webdav/driver.ts b/src/backend/drivers/webdav/driver.ts index 3add883f..2d29f85a 100644 --- a/src/backend/drivers/webdav/driver.ts +++ b/src/backend/drivers/webdav/driver.ts @@ -105,11 +105,18 @@ export class WebdavDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { + // srcPhys/dstPhys 已是源/目标项自身的路径,直接移动即可; + // 再拼一次 name 会指向不存在的 /,MOVE 源/目标错位。 + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 const srcBasePath = this.getRemotePath(srcPhys) const dstBasePath = this.getRemotePath(dstPhys) - for (const name of names) { - const srcPath = joinPath(srcBasePath, name) - const dstPath = joinPath(dstBasePath, name) + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => [joinPath(srcBasePath, n), joinPath(dstBasePath, n)] as const, + ) + : [[srcBasePath, dstBasePath] as const] + for (const [srcPath, dstPath] of pairs) { await this.client.move(srcPath, dstPath, true) } } @@ -121,11 +128,18 @@ export class WebdavDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { + // srcPhys/dstPhys 已是源/目标项自身的路径,直接复制即可; + // 再拼一次 name 会让 COPY 源/目标路径错位。 + // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 const srcBasePath = this.getRemotePath(srcPhys) const dstBasePath = this.getRemotePath(dstPhys) - for (const name of names) { - const srcPath = joinPath(srcBasePath, name) - const dstPath = joinPath(dstBasePath, name) + const expand = names && names.length > 1 + const pairs = expand + ? names.map( + (n) => [joinPath(srcBasePath, n), joinPath(dstBasePath, n)] as const, + ) + : [[srcBasePath, dstBasePath] as const] + for (const [srcPath, dstPath] of pairs) { await this.client.copy(srcPath, dstPath, true) } } @@ -135,14 +149,15 @@ export class WebdavDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const basePath = this.getRemotePath(physicalPath) - if (names && names.length > 0) { - for (const name of names) { - const targetPath = joinPath(basePath, name) - await this.client.remove(targetPath) - } - } else { - await this.client.remove(basePath) + // physicalPath 已是目标项自身的路径(op/storage.ts 逐项解析后传入), + // 再拼一次 name 会指向 /,WebDAV DELETE 对 404 视作成功, + // 结果接口静默返回成功但对象仍在。多 name 时才按「目录 + name」展开。 + const expand = names && names.length > 1 + const targets = expand + ? names.map((n) => joinPath(this.getRemotePath(physicalPath), n)) + : [this.getRemotePath(physicalPath)] + for (const targetPath of targets) { + await this.client.remove(targetPath) } } diff --git a/src/backend/drivers/wps/driver.ts b/src/backend/drivers/wps/driver.ts index 29dd1619..849b9ac6 100644 --- a/src/backend/drivers/wps/driver.ts +++ b/src/backend/drivers/wps/driver.ts @@ -217,17 +217,29 @@ export class WpsDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstNode = await this.resolvePath(this.cleanPath(dstPhys)) - if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { - throw new Error("Target destination directory not found") - } - - for (const name of names) { - const srcItemPath = - this.cleanPath(srcPhys) === "/" - ? `/${name}` - : `${this.cleanPath(srcPhys)}/${name}` - const srcNode = await this.resolvePath(srcItemPath) + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 源直接用 srcPath;目标文件夹是 dstPath 的父级,再拼 name 会指向 `/`。 + const expand = names && names.length > 1 + const srcBase = this.cleanPath(srcPhys) + const dstBase = this.cleanPath(dstPhys) + const pairs = expand + ? names.map( + (n) => + [ + srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, + dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, + ] as const, + ) + : ([[srcBase, dstBase]] as const) + + for (const [srcPath, dstPath] of pairs) { + const dstParentPath = + dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" + const dstNode = await this.resolvePath(dstParentPath) + if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { + throw new Error("Target destination directory not found") + } + const srcNode = await this.resolvePath(srcPath) if (srcNode) { await this.client.move( srcNode.groupId, @@ -259,17 +271,29 @@ export class WpsDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstNode = await this.resolvePath(this.cleanPath(dstPhys)) - if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { - throw new Error("Target destination directory not found") - } - - for (const name of names) { - const srcItemPath = - this.cleanPath(srcPhys) === "/" - ? `/${name}` - : `${this.cleanPath(srcPhys)}/${name}` - const srcNode = await this.resolvePath(srcItemPath) + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 源直接用 srcPath;目标文件夹是 dstPath 的父级,再拼 name 会指向 `/`。 + const expand = names && names.length > 1 + const srcBase = this.cleanPath(srcPhys) + const dstBase = this.cleanPath(dstPhys) + const pairs = expand + ? names.map( + (n) => + [ + srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, + dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, + ] as const, + ) + : ([[srcBase, dstBase]] as const) + + for (const [srcPath, dstPath] of pairs) { + const dstParentPath = + dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" + const dstNode = await this.resolvePath(dstParentPath) + if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { + throw new Error("Target destination directory not found") + } + const srcNode = await this.resolvePath(srcPath) if (srcNode) { await this.client.copy( srcNode.groupId, @@ -286,12 +310,17 @@ export class WpsDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - for (const name of names) { - const itemPath = - this.cleanPath(physicalPath) === "/" - ? `/${name}` - : `${this.cleanPath(physicalPath)}/${name}` - const node = await this.resolvePath(itemPath) + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 直接解析它即可;再拼 name 会指向 `/`,resolvePath 返回 null + // 后被静默跳过,接口成功但文件仍在。 + const expand = names && names.length > 1 + const base = this.cleanPath(physicalPath) + const targets = expand + ? names.map((n) => (base === "/" ? `/${n}` : `${base}/${n}`)) + : [base] + + for (const targetPath of targets) { + const node = await this.resolvePath(targetPath) if (node && node.kind !== "root" && node.kind !== "group") { await this.client.delete(node.groupId, node.fileId) } diff --git a/src/backend/drivers/yandex/driver.ts b/src/backend/drivers/yandex/driver.ts index 37f5abcd..d5ad0a5f 100644 --- a/src/backend/drivers/yandex/driver.ts +++ b/src/backend/drivers/yandex/driver.ts @@ -136,9 +136,15 @@ export class YandexDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const clean = this.cleanPath(physicalPath) - for (const name of names) { - const targetPath = clean === "/" ? `/${name}` : `${clean}/${name}` + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 直接删除它即可;再拼 name 会指向 `/`,DELETE 404 报错。 + const expand = names && names.length > 1 + const base = this.cleanPath(physicalPath) + const targets = expand + ? names.map((n) => (base === "/" ? `/${n}` : `${base}/${n}`)) + : [base] + + for (const targetPath of targets) { await this.client.request("", { method: "DELETE", params: { path: targetPath }, @@ -153,12 +159,22 @@ export class YandexDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcClean = this.cleanPath(srcPhys) - const dstClean = this.cleanPath(dstPhys) - - for (const name of names) { - const fromPath = srcClean === "/" ? `/${name}` : `${srcClean}/${name}` - const toPath = dstClean === "/" ? `/${name}` : `${dstClean}/${name}` + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 直接作为 from/path 使用;再拼 name 会指向 `/`,源/目标错位。 + const expand = names && names.length > 1 + const srcBase = this.cleanPath(srcPhys) + const dstBase = this.cleanPath(dstPhys) + const pairs = expand + ? names.map( + (n) => + [ + srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, + dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, + ] as const, + ) + : ([[srcBase, dstBase]] as const) + + for (const [fromPath, toPath] of pairs) { await this.client.request("/move", { method: "POST", params: { @@ -177,12 +193,22 @@ export class YandexDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcClean = this.cleanPath(srcPhys) - const dstClean = this.cleanPath(dstPhys) - - for (const name of names) { - const fromPath = srcClean === "/" ? `/${name}` : `${srcClean}/${name}` - const toPath = dstClean === "/" ? `/${name}` : `${dstClean}/${name}` + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 直接作为 from/path 使用;再拼 name 会指向 `/`,源/目标错位。 + const expand = names && names.length > 1 + const srcBase = this.cleanPath(srcPhys) + const dstBase = this.cleanPath(dstPhys) + const pairs = expand + ? names.map( + (n) => + [ + srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, + dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, + ] as const, + ) + : ([[srcBase, dstBase]] as const) + + for (const [fromPath, toPath] of pairs) { await this.client.request("/copy", { method: "POST", params: { From 0e34ebac05268fa8933c315276e02bc2d3abe4dc Mon Sep 17 00:00:00 2001 From: Astroptis <250784029+Astroptis@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:49:11 +0800 Subject: [PATCH 4/7] =?UTF-8?q?style(drivers):=20=E6=8C=89=20prettier=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E6=9C=AC=E6=AC=A1=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mediatrack/pikpak 的三元表达式换行与 public_settings 测试断言 折行不符合仓库 prettier 配置,重排为单行/标准折行。 Co-Authored-By: opencode --- src/backend/drivers/mediatrack/driver.ts | 4 +--- src/backend/drivers/pikpak/driver.ts | 8 ++++---- src/backend/server/public_settings.test.ts | 3 ++- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/backend/drivers/mediatrack/driver.ts b/src/backend/drivers/mediatrack/driver.ts index a8338c7d..f3fbcb6f 100644 --- a/src/backend/drivers/mediatrack/driver.ts +++ b/src/backend/drivers/mediatrack/driver.ts @@ -211,9 +211,7 @@ export class MediatrackDriver implements StorageDriver { ? names.map((n) => (clean ? `${clean}/${n}` : `/${n}`)) : [clean] // 父目录:展开时 physicalPath 即公共父目录,否则取目标项的父目录 - const parentPath = expand - ? clean - : clean.split("/").slice(0, -1).join("/") + const parentPath = expand ? clean : clean.split("/").slice(0, -1).join("/") const parentId = await this.resolveParentId(parentPath) const files = await this.client.getFiles(parentId) const ids: string[] = [] diff --git a/src/backend/drivers/pikpak/driver.ts b/src/backend/drivers/pikpak/driver.ts index 3eafa570..d49716c9 100644 --- a/src/backend/drivers/pikpak/driver.ts +++ b/src/backend/drivers/pikpak/driver.ts @@ -266,15 +266,15 @@ export class PikPakDriver implements StorageDriver { ? names.map((n) => (clean ? `${clean}/${n}` : `/${n}`)) : [clean] // 父目录:展开时 physicalPath 即公共父目录,否则取目标项的父目录 - const parentPath = expand - ? clean - : clean.split("/").slice(0, -1).join("/") + const parentPath = expand ? clean : clean.split("/").slice(0, -1).join("/") const parentId = await this.resolveParentId(parentPath) const files = await this.getFiles(parentId) const ids: string[] = [] for (const target of targets) { - const match = files.find((f) => f.name === (target.split("/").pop() || "")) + const match = files.find( + (f) => f.name === (target.split("/").pop() || ""), + ) if (match) { ids.push(match.id) } diff --git a/src/backend/server/public_settings.test.ts b/src/backend/server/public_settings.test.ts index fe54ed77..3b28aa63 100644 --- a/src/backend/server/public_settings.test.ts +++ b/src/backend/server/public_settings.test.ts @@ -147,7 +147,8 @@ const readMigratedShareSummary = async (persistedValue: string) => { await new Promise((r) => setTimeout(r, 1100)) const db = await getDb(env) return String( - db.settings.find((s: any) => s.key === "share_summary_content")?.value || "", + db.settings.find((s: any) => s.key === "share_summary_content")?.value || + "", ) } From 7b303b787c3e9aee83314149931fc0a7eba126fe Mon Sep 17 00:00:00 2001 From: Astroptis <250784029+Astroptis@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:38:28 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(local):=20=E4=BF=AE=E5=A4=8D=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E7=9B=98=E9=A9=B1=E5=8A=A8=20remove/move/copy=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E9=87=8D=E5=A4=8D=E6=8B=BC=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审指出 drivers/local.ts 是单文件(非 drivers/local/driver.ts),此前批量 修复时被漏检。remove 把项路径当目录再拼一次 name,对 / 执行 fs.rm({force:true}) 时不存在的路径被 force 忽略,静默成功但文件仍在; move/copy 则直接 ENOENT。 与其余 21 个驱动按同一约定修复:physicalPath/srcPhys/dstPhys 即目标项 自身,仅 names.length > 1 时按目录展开。新增 local_remove.test.ts, 用临时目录真实 fs 断言 remove/move/copy 命中目标项。 Co-Authored-By: opencode --- src/backend/drivers/local.ts | 36 +++++++--- src/backend/drivers/local_remove.test.ts | 88 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 src/backend/drivers/local_remove.test.ts diff --git a/src/backend/drivers/local.ts b/src/backend/drivers/local.ts index e6bf8cb9..9731a63f 100644 --- a/src/backend/drivers/local.ts +++ b/src/backend/drivers/local.ts @@ -101,8 +101,15 @@ export class LocalDriver implements StorageDriver { await initNodeModules() if (!fs || !path) throw new Error("LocalDriver is not supported in Edge Runtime") - for (const name of names) { - const itemPath = path.join(physicalPath, name) + // physicalPath 是目标项自身的路径(op/storage.ts removeItems 逐项调用), + // 直接删除即可;再拼一次 name 会指向 /,配合 force:true 会 + // 静默"成功"但什么都没删。仅当 names.length > 1 时按「目录 + 多个 name」 + // 展开(防御性分支,当前调用链恒传 1 个)。 + const expand = names && names.length > 1 + const targets = expand + ? names.map((n) => path.join(physicalPath, n)) + : [physicalPath] + for (const itemPath of targets) { await fs.rm(itemPath, { recursive: true, force: true }) } } @@ -117,11 +124,16 @@ export class LocalDriver implements StorageDriver { await initNodeModules() if (!fs || !path) throw new Error("LocalDriver is not supported in Edge Runtime") - for (const name of names) { - const src = path.join(srcPhys, name) - const dst = path.join(dstPhys, name) + // srcPhys/dstPhys 是源/目标项自身的路径(已含 name),直接移动即可; + // 再拼一次 name 会指向不存在的 / 而 ENOENT。 + // 仅当 names.length > 1 时按「目录 + 多个 name」展开(防御性分支)。 + const expand = names && names.length > 1 + const srcs = expand ? names.map((n) => path.join(srcPhys, n)) : [srcPhys] + const dsts = expand ? names.map((n) => path.join(dstPhys, n)) : [dstPhys] + for (let i = 0; i < srcs.length; i++) { + const dst = dsts[i] await fs.mkdir(path.dirname(dst), { recursive: true }) - await fs.rename(src, dst) + await fs.rename(srcs[i], dst) } } @@ -135,11 +147,15 @@ export class LocalDriver implements StorageDriver { await initNodeModules() if (!fs || !path) throw new Error("LocalDriver is not supported in Edge Runtime") - for (const name of names) { - const src = path.join(srcPhys, name) - const dst = path.join(dstPhys, name) + // 同 move:srcPhys/dstPhys 已是源/目标项自身路径,不再拼 name。 + // 仅当 names.length > 1 时按「目录 + 多个 name」展开(防御性分支)。 + const expand = names && names.length > 1 + const srcs = expand ? names.map((n) => path.join(srcPhys, n)) : [srcPhys] + const dsts = expand ? names.map((n) => path.join(dstPhys, n)) : [dstPhys] + for (let i = 0; i < srcs.length; i++) { + const dst = dsts[i] await fs.mkdir(path.dirname(dst), { recursive: true }) - await fs.cp(src, dst, { recursive: true }) + await fs.cp(srcs[i], dst, { recursive: true }) } } diff --git a/src/backend/drivers/local_remove.test.ts b/src/backend/drivers/local_remove.test.ts new file mode 100644 index 00000000..580395b4 --- /dev/null +++ b/src/backend/drivers/local_remove.test.ts @@ -0,0 +1,88 @@ +import { test } from "node:test" +import assert from "node:assert/strict" +import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import * as path from "node:path" +import { LocalDriver } from "./local" + +async function exists(p: string): Promise { + try { + await readFile(p) + return true + } catch { + try { + await import("node:fs/promises").then((fs) => fs.stat(p)) + return true + } catch { + return false + } + } +} + +async function makeDir(): Promise { + return await mkdtemp(path.join(tmpdir(), "openlist-local-test-")) +} + +test("LocalDriver.remove 删除目标项自身", async (t) => { + const dir = await makeDir() + t.after(() => rm(dir, { recursive: true, force: true })) + const file = path.join(dir, "a.txt") + await writeFile(file, "x") + + const driver = new LocalDriver() + await driver.remove("/d/a.txt", file, ["a.txt"]) + + assert.equal(await exists(file), false, "目标文件应被删除") +}) + +test("LocalDriver.remove 删除目录", async (t) => { + const dir = await makeDir() + t.after(() => rm(dir, { recursive: true, force: true })) + const sub = path.join(dir, "sub") + await mkdir(sub) + await writeFile(path.join(sub, "inner.txt"), "x") + + const driver = new LocalDriver() + await driver.remove("/d/sub", sub, ["sub"]) + + assert.equal(await exists(path.join(sub, "inner.txt")), false, "目录应被删除") + assert.equal(await exists(sub), false, "目录应被删除") +}) + +test("LocalDriver.move 移动目标项自身", async (t) => { + const dir = await makeDir() + t.after(() => rm(dir, { recursive: true, force: true })) + const src = path.join(dir, "a.txt") + const dstDir = path.join(dir, "dst") + await mkdir(dstDir) + await writeFile(src, "hello") + + const driver = new LocalDriver() + await driver.move("/d", "/d/dst", ["a.txt"], src, path.join(dstDir, "a.txt")) + + assert.equal(await exists(src), false, "源文件应被移走") + assert.equal( + await readFile(path.join(dstDir, "a.txt"), "utf8"), + "hello", + "目标位置应有文件", + ) +}) + +test("LocalDriver.copy 复制目标项自身", async (t) => { + const dir = await makeDir() + t.after(() => rm(dir, { recursive: true, force: true })) + const src = path.join(dir, "a.txt") + const dstDir = path.join(dir, "dst") + await mkdir(dstDir) + await writeFile(src, "hello") + + const driver = new LocalDriver() + await driver.copy("/d", "/d/dst", ["a.txt"], src, path.join(dstDir, "a.txt")) + + assert.equal(await exists(src), true, "源文件应保留") + assert.equal( + await readFile(path.join(dstDir, "a.txt"), "utf8"), + "hello", + "目标位置应有副本", + ) +}) From 3f9329cb0c87a92347c4f0cc7be835c48681b327 Mon Sep 17 00:00:00 2001 From: Astroptis <250784029+Astroptis@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:08:51 +0800 Subject: [PATCH 6/7] =?UTF-8?q?refactor(drivers):=20=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E8=BE=BE=E7=9A=84=20names.length=20>=201=20?= =?UTF-8?q?=E9=98=B2=E5=BE=A1=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审指出:各驱动修复时加的 expand 分支在真实调用链里恒不可达 (op/storage.ts 的 removeItems/moveItems/copyItems 恒传单元素 names), 且它把参数语义从「项路径」反转成「目录」,属潜伏陷阱——将来若有 调用方传多 name,行为会与单 name 时不一致。 统一删除:所有 remove/move/copy 一律按「physicalPath/srcPhys/dstPhys 即目标项自身路径」直接处理,names 保留于签名(接口要求)。 行为不变(该分支本就不可达),342 项测试全过。 Co-Authored-By: opencode --- src/backend/drivers/139/driver.ts | 29 +++-- src/backend/drivers/alias/driver.ts | 38 +++---- src/backend/drivers/azure_blob/driver.ts | 39 ++----- src/backend/drivers/dropbox/driver.ts | 55 ++-------- src/backend/drivers/ftp/driver.ts | 26 ++--- src/backend/drivers/local.ts | 39 ++----- src/backend/drivers/mediatrack/driver.ts | 100 +++++------------ src/backend/drivers/mega/driver.ts | 56 +++------- src/backend/drivers/mopan/driver.ts | 37 ++----- src/backend/drivers/onedrive/driver.ts | 94 +++++----------- src/backend/drivers/onedrive_app/driver.ts | 94 +++++----------- src/backend/drivers/pikpak/driver.ts | 104 +++++------------- src/backend/drivers/s3/driver.ts | 74 ++++++------- src/backend/drivers/seafile/driver.ts | 118 ++++++++------------- src/backend/drivers/sftp/driver.ts | 25 +---- src/backend/drivers/terabox/driver.ts | 60 ++++------- src/backend/drivers/uss/driver.ts | 35 +----- src/backend/drivers/webdav/driver.ts | 50 +++------ src/backend/drivers/wps/driver.ts | 110 +++++++------------ src/backend/drivers/yandex/driver.ts | 84 ++++----------- 20 files changed, 370 insertions(+), 897 deletions(-) diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index b52fcfa0..f5b24c47 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -205,26 +205,21 @@ export class Yun139Driver implements StorageDriver { ): Promise { const clean = this.cleanPath(physicalPath) // physicalPath 是目标项自身的路径(op/storage.ts removeItems 逐项调用), - // 而下面要按 name 在目录里查找删除;把项路径当目录解析会走进目标项内部 - //(目录项尤其明显),相当于找 /,永远落空并静默返回成功。 - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(调用方恒传 1 个, - // 此分支是防御性的)。 - const expand = names && names.length > 1 - const dirPath = expand - ? clean - : clean.substring(0, clean.lastIndexOf("/")) || "/" + // 目标名取参数末段、父目录取去掉末段后的部分;把项路径当目录解析会走进 + // 目标项内部(目录项尤其明显),相当于找 /,永远落空并静默 + // 返回成功。 + const name = clean.substring(clean.lastIndexOf("/") + 1) + const dirPath = clean.substring(0, clean.lastIndexOf("/")) || "/" const catalogId = await this.resolveCatalogId(dirPath) const disk = await this.client.listFiles(catalogId) - for (const name of names) { - const folder = disk.folders.find((f) => f.catalogName === name) - if (folder) { - await this.client.deleteCatalog(folder.catalogID) - } else { - const file = disk.files.find((f) => f.contentName === name) - if (file && file.contentID) { - await this.client.deleteFile(file.contentID) - } + const folder = disk.folders.find((f) => f.catalogName === name) + if (folder) { + await this.client.deleteCatalog(folder.catalogID) + } else { + const file = disk.files.find((f) => f.contentName === name) + if (file && file.contentID) { + await this.client.deleteFile(file.contentID) } } } diff --git a/src/backend/drivers/alias/driver.ts b/src/backend/drivers/alias/driver.ts index 1dfc828e..1aa35068 100644 --- a/src/backend/drivers/alias/driver.ts +++ b/src/backend/drivers/alias/driver.ts @@ -237,14 +237,10 @@ export class AliasDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - // physicalPath 是目标项自身的路径,而 removeItems 需要「目录 + names」 - //(内部会再拼 name);直接把项路径当目录传入会得到 /, - // 目标不存在导致删除静默失败。仅当 names.length > 1 时按「目录 + 多个 - // name」展开(调用方恒传 1 个,此分支是防御性的)。 - const expand = names && names.length > 1 - const dirPath = expand - ? this.cleanPath(physicalPath) - : this.parentPath(physicalPath) + // physicalPath 是目标项自身的路径(op/storage.ts removeItems 逐项调用), + // 而 removeItems 需要「父目录 + names」(内部会再拼 name);直接把项路径 + // 当目录传入会得到 /,目标不存在导致删除静默失败。 + const dirPath = this.parentPath(physicalPath) const targets = this.getTargetsForPath(dirPath) if (targets.length === 0) return const { removeItems } = await import("../../internal/op/storage") @@ -258,16 +254,11 @@ export class AliasDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 是源/目标项自身的路径,moveItems 需要的是各自所在 - // 目录(内部会再拼 name);传项路径会得到 /,操作静默失败。 - // 仅当 names.length > 1 时按「目录 + 多个 name」展开(防御性分支)。 - const expand = names && names.length > 1 - const srcDirPath = expand - ? this.cleanPath(srcPhys) - : this.parentPath(srcPhys) - const dstDirPath = expand - ? this.cleanPath(dstPhys) - : this.parentPath(dstPhys) + // srcPhys/dstPhys 是源/目标项自身的路径(已含 name),moveItems 需要的 + // 是各自所在父目录(内部会再拼 name);传项路径会得到 /, + // 操作静默失败。 + const srcDirPath = this.parentPath(srcPhys) + const dstDirPath = this.parentPath(dstPhys) const srcTargets = this.getTargetsForPath(srcDirPath) const dstTargets = this.getTargetsForPath(dstDirPath) if (srcTargets.length === 0 || dstTargets.length === 0) { @@ -288,14 +279,9 @@ export class AliasDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // 同 move:srcPhys/dstPhys 是项自身路径,copyItems 需要所在目录。 - const expand = names && names.length > 1 - const srcDirPath = expand - ? this.cleanPath(srcPhys) - : this.parentPath(srcPhys) - const dstDirPath = expand - ? this.cleanPath(dstPhys) - : this.parentPath(dstPhys) + // 同 move:srcPhys/dstPhys 是项自身路径,copyItems 需要各自所在父目录。 + const srcDirPath = this.parentPath(srcPhys) + const dstDirPath = this.parentPath(dstPhys) const srcTargets = this.getTargetsForPath(srcDirPath) const dstTargets = this.getTargetsForPath(dstDirPath) if (srcTargets.length === 0 || dstTargets.length === 0) { diff --git a/src/backend/drivers/azure_blob/driver.ts b/src/backend/drivers/azure_blob/driver.ts index 97821a34..405a3938 100644 --- a/src/backend/drivers/azure_blob/driver.ts +++ b/src/backend/drivers/azure_blob/driver.ts @@ -303,23 +303,11 @@ export class AzureBlobDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 不得再拼 name,否则会指向 `/` 这种不存在的 blob; - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => - [ - joinPath(this.getKey(srcPhys), n), - joinPath(this.getKey(dstPhys), n), - ] as const, - ) - : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) - - for (const [srcKey, dstKey] of pairs) { - await this.copyBlob(srcKey, dstKey) - await this.deleteBlob(srcKey) - } + // 不得再拼 name,否则会指向 `/` 这种不存在的 blob。 + const srcKey = this.getKey(srcPhys) + const dstKey = this.getKey(dstPhys) + await this.copyBlob(srcKey, dstKey) + await this.deleteBlob(srcKey) } async copy( @@ -330,20 +318,9 @@ export class AzureBlobDriver implements StorageDriver { dstPhys: string, ): Promise { // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => - [ - joinPath(this.getKey(srcPhys), n), - joinPath(this.getKey(dstPhys), n), - ] as const, - ) - : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) - - for (const [srcKey, dstKey] of pairs) { - await this.copyBlob(srcKey, dstKey) - } + const srcKey = this.getKey(srcPhys) + const dstKey = this.getKey(dstPhys) + await this.copyBlob(srcKey, dstKey) } async put(_v: string, physicalPath: string, content: Buffer): Promise { diff --git a/src/backend/drivers/dropbox/driver.ts b/src/backend/drivers/dropbox/driver.ts index 89bb295f..4aefb63f 100644 --- a/src/backend/drivers/dropbox/driver.ts +++ b/src/backend/drivers/dropbox/driver.ts @@ -128,16 +128,9 @@ export class DropboxDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), - // 直接删除它即可;再拼 name 会指向 `/`,delete 404 报错。 - const expand = names && names.length > 1 - const base = this.cleanPath(physicalPath) - const targets = expand - ? names.map((n) => (base === "" ? `/${n}` : `${base}/${n}`)) - : [base] - - for (const target of targets) { - await this.client.delete(target) - } + // 直接删除它即可;不得再拼 name,否则指向 `/`,delete 404 报错。 + const target = this.cleanPath(physicalPath) + await this.client.delete(target) } async move( @@ -148,23 +141,10 @@ export class DropboxDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 直接作为 from/to 使用;再拼 name 会指向 `/`,源/目标错位。 - const expand = names && names.length > 1 - const srcBase = this.cleanPath(srcPhys) - const dstBase = this.cleanPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - srcBase === "" ? `/${n}` : `${srcBase}/${n}`, - dstBase === "" ? `/${n}` : `${dstBase}/${n}`, - ] as const, - ) - : ([[srcBase, dstBase]] as const) - - for (const [from, to] of pairs) { - await this.client.move(from, to) - } + // 直接作为 from/to 使用;不得再拼 name,否则指向 `/`,源/目标错位。 + const from = this.cleanPath(srcPhys) + const to = this.cleanPath(dstPhys) + await this.client.move(from, to) } async copy( @@ -175,23 +155,10 @@ export class DropboxDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), - // 直接作为 from/to 使用;再拼 name 会指向 `/`,源/目标错位。 - const expand = names && names.length > 1 - const srcBase = this.cleanPath(srcPhys) - const dstBase = this.cleanPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - srcBase === "" ? `/${n}` : `${srcBase}/${n}`, - dstBase === "" ? `/${n}` : `${dstBase}/${n}`, - ] as const, - ) - : ([[srcBase, dstBase]] as const) - - for (const [from, to] of pairs) { - await this.client.copy(from, to) - } + // 直接作为 from/to 使用;不得再拼 name,否则指向 `/`,源/目标错位。 + const from = this.cleanPath(srcPhys) + const to = this.cleanPath(dstPhys) + await this.client.copy(from, to) } async put( diff --git a/src/backend/drivers/ftp/driver.ts b/src/backend/drivers/ftp/driver.ts index fa68a2cf..71e078f6 100644 --- a/src/backend/drivers/ftp/driver.ts +++ b/src/backend/drivers/ftp/driver.ts @@ -141,15 +141,9 @@ export class FTPDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 已是目标项自身的物理路径(op/storage.ts 逐项解析后传入), - // 再拼一次 name 会指向不存在的 /,导致静默删除失败。 - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 - const expand = names && names.length > 1 - const targets = expand - ? names.map((n) => posixJoin(physicalPath, n)) - : [cleanPosixPath(physicalPath)] - for (const target of targets) { - await this.client.removeRecursive(target) - } + // 直接删除即可;不得再拼 name,否则指向不存在的 /,导致静默删除失败。 + const target = cleanPosixPath(physicalPath) + await this.client.removeRecursive(target) } async move( @@ -160,16 +154,10 @@ export class FTPDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 已是源/目标项自身的物理路径,直接重命名即可; - // 再拼 name 会让源/目标路径错位。多 name 时才按「目录 + name」展开。 - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => [posixJoin(srcPhys, n), posixJoin(dstPhys, n)] as const, - ) - : [[cleanPosixPath(srcPhys), cleanPosixPath(dstPhys)] as const] - for (const [src, dst] of pairs) { - await this.client.rename(src, dst) - } + // 不得再拼 name,否则源/目标路径错位。 + const src = cleanPosixPath(srcPhys) + const dst = cleanPosixPath(dstPhys) + await this.client.rename(src, dst) } async copy( diff --git a/src/backend/drivers/local.ts b/src/backend/drivers/local.ts index 9731a63f..2bffdb9b 100644 --- a/src/backend/drivers/local.ts +++ b/src/backend/drivers/local.ts @@ -102,16 +102,9 @@ export class LocalDriver implements StorageDriver { if (!fs || !path) throw new Error("LocalDriver is not supported in Edge Runtime") // physicalPath 是目标项自身的路径(op/storage.ts removeItems 逐项调用), - // 直接删除即可;再拼一次 name 会指向 /,配合 force:true 会 - // 静默"成功"但什么都没删。仅当 names.length > 1 时按「目录 + 多个 name」 - // 展开(防御性分支,当前调用链恒传 1 个)。 - const expand = names && names.length > 1 - const targets = expand - ? names.map((n) => path.join(physicalPath, n)) - : [physicalPath] - for (const itemPath of targets) { - await fs.rm(itemPath, { recursive: true, force: true }) - } + // 直接删除即可;不得再拼 name,否则指向 /,配合 force:true 会 + // 静默"成功"但什么都没删。 + await fs.rm(physicalPath, { recursive: true, force: true }) } async move( @@ -125,16 +118,9 @@ export class LocalDriver implements StorageDriver { if (!fs || !path) throw new Error("LocalDriver is not supported in Edge Runtime") // srcPhys/dstPhys 是源/目标项自身的路径(已含 name),直接移动即可; - // 再拼一次 name 会指向不存在的 / 而 ENOENT。 - // 仅当 names.length > 1 时按「目录 + 多个 name」展开(防御性分支)。 - const expand = names && names.length > 1 - const srcs = expand ? names.map((n) => path.join(srcPhys, n)) : [srcPhys] - const dsts = expand ? names.map((n) => path.join(dstPhys, n)) : [dstPhys] - for (let i = 0; i < srcs.length; i++) { - const dst = dsts[i] - await fs.mkdir(path.dirname(dst), { recursive: true }) - await fs.rename(srcs[i], dst) - } + // 不得再拼 name,否则指向不存在的 / 而 ENOENT。 + await fs.mkdir(path.dirname(dstPhys), { recursive: true }) + await fs.rename(srcPhys, dstPhys) } async copy( @@ -147,16 +133,9 @@ export class LocalDriver implements StorageDriver { await initNodeModules() if (!fs || !path) throw new Error("LocalDriver is not supported in Edge Runtime") - // 同 move:srcPhys/dstPhys 已是源/目标项自身路径,不再拼 name。 - // 仅当 names.length > 1 时按「目录 + 多个 name」展开(防御性分支)。 - const expand = names && names.length > 1 - const srcs = expand ? names.map((n) => path.join(srcPhys, n)) : [srcPhys] - const dsts = expand ? names.map((n) => path.join(dstPhys, n)) : [dstPhys] - for (let i = 0; i < srcs.length; i++) { - const dst = dsts[i] - await fs.mkdir(path.dirname(dst), { recursive: true }) - await fs.cp(srcs[i], dst, { recursive: true }) - } + // 同 move:srcPhys/dstPhys 已是源/目标项自身路径,不得再拼 name。 + await fs.mkdir(path.dirname(dstPhys), { recursive: true }) + await fs.cp(srcPhys, dstPhys, { recursive: true }) } async put( diff --git a/src/backend/drivers/mediatrack/driver.ts b/src/backend/drivers/mediatrack/driver.ts index f3fbcb6f..c1188086 100644 --- a/src/backend/drivers/mediatrack/driver.ts +++ b/src/backend/drivers/mediatrack/driver.ts @@ -202,30 +202,17 @@ export class MediatrackDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - // physicalPath 是目标项自身的物理路径,而 resolveParentId 解析的是路径 - // 自身的 id;拿它列子项再按 title 查找,命中的其实是 /, - // 找不到时静默跳过,对象仍然存在。 - const expand = names && names.length > 1 + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 目标名取参数末段、父目录取去掉末段后的部分;resolveParentId 解析的是 + // 传入路径自身的 id,把项路径当父目录会去 的子项里按 title 查找 + //(相当于找 /),找不到时静默跳过,对象仍然存在。 const clean = this.cleanPath(physicalPath) - const targets = expand - ? names.map((n) => (clean ? `${clean}/${n}` : `/${n}`)) - : [clean] - // 父目录:展开时 physicalPath 即公共父目录,否则取目标项的父目录 - const parentPath = expand ? clean : clean.split("/").slice(0, -1).join("/") + const name = clean.split("/").pop() || "" + const parentPath = clean.split("/").slice(0, -1).join("/") const parentId = await this.resolveParentId(parentPath) const files = await this.client.getFiles(parentId) - const ids: string[] = [] - - for (const target of targets) { - const match = files.find( - (f) => f.title === (target.split("/").pop() || ""), - ) - if (match) { - ids.push(match.id) - } - } - - if (ids.length === 0) return + const match = files.find((f) => f.title === name) + if (!match) return await this.client.request( "https://jayce.api.mediatrack.cn/v4/assets/batch/delete", @@ -233,14 +220,12 @@ export class MediatrackDriver implements StorageDriver { method: "DELETE", body: { origin_id: parentId, - ids, + ids: [match.id], }, }, ) - for (const target of targets) { - this.idCache.delete(target) - } + this.idCache.delete(clean) } async move( @@ -250,37 +235,21 @@ export class MediatrackDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name), - // 直接当父目录解析会指到项本身:源侧会去 的子项里找 title(永远落空), + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name),父目录取去掉 + // 末段后的部分:源侧在其父目录的子项里按 title 找,目标侧解析目标项所在 + // 目录的 id。把项路径当父目录解析会指到项本身:源侧永远落空, // 目标侧解析一个尚不存在的目标项必然报错。 - const expand = names && names.length > 1 const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) - const targets = expand - ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) - : [srcClean] - const srcParentPath = expand - ? srcClean - : srcClean.split("/").slice(0, -1).join("/") - const dstParentPath = expand - ? dstClean - : dstClean.split("/").slice(0, -1).join("/") + const srcName = srcClean.split("/").pop() || "" + const srcParentPath = srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = dstClean.split("/").slice(0, -1).join("/") const srcParentId = await this.resolveParentId(srcParentPath) const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.client.getFiles(srcParentId) - const ids: string[] = [] - - for (const target of targets) { - const match = srcFiles.find( - (f) => f.title === (target.split("/").pop() || ""), - ) - if (match) { - ids.push(match.id) - } - } - - if (ids.length === 0) return + const match = srcFiles.find((f) => f.title === srcName) + if (!match) return await this.client.request( "https://jayce.api.mediatrack.cn/v4/assets/batch/move", @@ -288,7 +257,7 @@ export class MediatrackDriver implements StorageDriver { method: "POST", body: { parent_id: dstParentId, - ids, + ids: [match.id], }, }, ) @@ -301,35 +270,18 @@ export class MediatrackDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // 与 move 同理:源/目标项自身路径先去掉末段得到父目录再解析 id。 - const expand = names && names.length > 1 + // 与 move 同理:源/目标项自身路径先去掉末段得到父目录,目标名取源路径末段。 const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) - const targets = expand - ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) - : [srcClean] - const srcParentPath = expand - ? srcClean - : srcClean.split("/").slice(0, -1).join("/") - const dstParentPath = expand - ? dstClean - : dstClean.split("/").slice(0, -1).join("/") + const srcName = srcClean.split("/").pop() || "" + const srcParentPath = srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = dstClean.split("/").slice(0, -1).join("/") const srcParentId = await this.resolveParentId(srcParentPath) const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.client.getFiles(srcParentId) - const ids: string[] = [] - - for (const target of targets) { - const match = srcFiles.find( - (f) => f.title === (target.split("/").pop() || ""), - ) - if (match) { - ids.push(match.id) - } - } - - if (ids.length === 0) return + const match = srcFiles.find((f) => f.title === srcName) + if (!match) return await this.client.request( "https://jayce.api.mediatrack.cn/v4/assets/batch/clone", @@ -337,7 +289,7 @@ export class MediatrackDriver implements StorageDriver { method: "POST", body: { parent_id: dstParentId, - ids, + ids: [match.id], }, }, ) diff --git a/src/backend/drivers/mega/driver.ts b/src/backend/drivers/mega/driver.ts index 1ff8e85b..3e232450 100644 --- a/src/backend/drivers/mega/driver.ts +++ b/src/backend/drivers/mega/driver.ts @@ -152,32 +152,18 @@ export class MegaDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 源直接用 srcPhys;目标父目录取 dstPhys 的父级,再拼 name 会指向 `/`。 - const expand = names && names.length > 1 - const srcBase = this.cleanPath(srcPhys) - const dstBase = this.cleanPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, - dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, - ] as const, - ) - : ([[srcBase, dstBase]] as const) - - for (const [srcPath, dstPath] of pairs) { - const dstParentPath = - dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" - const dstNode = this.resolveNodeByPath(dstParentPath) - if (!dstNode || !dstNode.is_dir) { - throw new Error("Destination folder not found") - } - const srcNode = this.resolveNodeByPath(srcPath) - if (srcNode) { - await this.client.moveNode(srcNode.id, dstNode.id) - } + // srcPhys/dstPhys 是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 参数即目标项路径,不得再拼 name,否则指向 `/` 导致静默失败。 + const srcPath = this.cleanPath(srcPhys) + const dstPath = this.cleanPath(dstPhys) + const dstParentPath = dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" + const dstNode = this.resolveNodeByPath(dstParentPath) + if (!dstNode || !dstNode.is_dir) { + throw new Error("Destination folder not found") + } + const srcNode = this.resolveNodeByPath(srcPath) + if (srcNode) { + await this.client.moveNode(srcNode.id, dstNode.id) } } @@ -199,19 +185,11 @@ export class MegaDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), - // 直接解析它即可;再拼 name 会指向 `/`,resolveNodeByPath 返回 - // null 后被静默跳过,接口成功但节点仍在。 - const expand = names && names.length > 1 - const base = this.cleanPath(physicalPath) - const targets = expand - ? names.map((n) => (base === "/" ? `/${n}` : `${base}/${n}`)) - : [base] - - for (const targetPath of targets) { - const node = this.resolveNodeByPath(targetPath) - if (node) { - await this.client.deleteNode(node.id) - } + // 参数即目标项路径,不得再拼 name,否则指向 `/`,resolveNodeByPath + // 返回 null 后被静默跳过,接口成功但节点仍在。 + const node = this.resolveNodeByPath(this.cleanPath(physicalPath)) + if (node) { + await this.client.deleteNode(node.id) } } diff --git a/src/backend/drivers/mopan/driver.ts b/src/backend/drivers/mopan/driver.ts index 5c735f5c..8b2b280d 100644 --- a/src/backend/drivers/mopan/driver.ts +++ b/src/backend/drivers/mopan/driver.ts @@ -275,23 +275,14 @@ export class MoPanDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 不得再拼 name,否则会指向 `/` 这种不存在的路径; - // 实际调用(单 name)时 dstPhys 是目标项,目标文件夹取其父目录。 - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 - const expand = names && names.length > 1 - const srcPaths = expand - ? names.map((n) => (srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`)) - : [srcPhys] - const dstFolderPath = expand - ? dstPhys - : dstPhys.split("/").slice(0, -1).join("/") + // srcPhys/dstPhys 是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 参数即目标项路径,不得再拼 name,否则指向 `/` 这种不存在的路径。 + // 目标文件夹取 dstPhys 去掉末段后的父目录。 + const srcItem = await this.get(srcDir, srcPhys) + const dstFolderPath = dstPhys.split("/").slice(0, -1).join("/") const dstFolderId = await this.resolveFolderId(dstFolderPath) - for (const srcItemPath of srcPaths) { - const srcItem = await this.get(srcDir, srcItemPath) - await this.performBatchTask(srcItem, dstFolderId, TaskTypeMove) - } + await this.performBatchTask(srcItem, dstFolderId, TaskTypeMove) } async copy( @@ -301,20 +292,12 @@ export class MoPanDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 - const expand = names && names.length > 1 - const srcPaths = expand - ? names.map((n) => (srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`)) - : [srcPhys] - const dstFolderPath = expand - ? dstPhys - : dstPhys.split("/").slice(0, -1).join("/") + // 同 move:srcPhys/dstPhys 已是目标项自身路径,参数即目标项路径,不得再拼 name。 + const srcItem = await this.get(srcDir, srcPhys) + const dstFolderPath = dstPhys.split("/").slice(0, -1).join("/") const dstFolderId = await this.resolveFolderId(dstFolderPath) - for (const srcItemPath of srcPaths) { - const srcItem = await this.get(srcDir, srcItemPath) - await this.performBatchTask(srcItem, dstFolderId, TaskTypeCopy) - } + await this.performBatchTask(srcItem, dstFolderId, TaskTypeCopy) } private async performBatchTask( diff --git a/src/backend/drivers/onedrive/driver.ts b/src/backend/drivers/onedrive/driver.ts index fe85ffcd..62898ba6 100644 --- a/src/backend/drivers/onedrive/driver.ts +++ b/src/backend/drivers/onedrive/driver.ts @@ -223,18 +223,9 @@ export class Onedrive implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts 逐项调用), - // 再拼一次 name 会指向 /,DELETE 404 导致对象仍然存在。 - const expand = names && names.length > 1 - const targets = expand - ? names.map((n) => - physicalPath === "/" ? `/${n}` : `${physicalPath}/${n}`, - ) - : [physicalPath] - - for (const targetPath of targets) { - const url = this.getMetaUrl(false, targetPath) - await requestApi(this, url, "DELETE") - } + // 参数即目标项路径,不得再拼 name,否则指向 /,DELETE 404 导致对象仍然存在。 + const url = this.getMetaUrl(false, physicalPath) + await requestApi(this, url, "DELETE") } async move( @@ -244,41 +235,25 @@ export class Onedrive implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name): - // 目标父目录需去掉末段(直接 GET dstPhys 拿到的是目标项本身,通常还不存在), + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name,参数即目标项路径, + // 不得再拼 name,否则指向 /): + // 目标父目录取 dstPhys 去掉末段(直接 GET dstPhys 拿到的是目标项本身,通常还不存在), // 目标名字以 dstPhys 末段为准,源项路径则直接使用 srcPhys。 - const expand = names && names.length > 1 - const dstParentPath = expand - ? dstPhys - : dstPhys.split("/").slice(0, -1).join("/") || "/" + const dstParentPath = dstPhys.split("/").slice(0, -1).join("/") || "/" const dstUrl = this.getMetaUrl(false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - const targets = expand - ? names.map((n) => ({ - srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, - name: n, - })) - : [ - { - srcPath: srcPhys, - name: dstPhys.split("/").filter(Boolean).pop() || "", - }, - ] - - for (const target of targets) { - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name: target.name, - } - const url = this.getMetaUrl(false, target.srcPath) - await requestApi(this, url, "PATCH", data) + const data = { + parentReference: { + id: dstId, + ...(driveId ? { driveId } : {}), + }, + name: dstPhys.split("/").filter(Boolean).pop() || "", } + const url = this.getMetaUrl(false, srcPhys) + await requestApi(this, url, "PATCH", data) } async copy( @@ -288,40 +263,23 @@ export class Onedrive implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // 与 move 同理:目标父目录 = dstPhys 去掉末段,目标名字取 dstPhys 末段, - // 源项路径直接使用 srcPhys,不再重复拼接 name。 - const expand = names && names.length > 1 - const dstParentPath = expand - ? dstPhys - : dstPhys.split("/").slice(0, -1).join("/") || "/" + // 与 move 同理:参数是目标项自身路径,不得再拼 name; + // 目标父目录 = dstPhys 去掉末段,目标名字取 dstPhys 末段,源项路径直接使用 srcPhys。 + const dstParentPath = dstPhys.split("/").slice(0, -1).join("/") || "/" const dstUrl = this.getMetaUrl(false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - const targets = expand - ? names.map((n) => ({ - srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, - name: n, - })) - : [ - { - srcPath: srcPhys, - name: dstPhys.split("/").filter(Boolean).pop() || "", - }, - ] - - for (const target of targets) { - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name: target.name, - } - const url = this.getMetaUrl(false, target.srcPath, "copy") - await requestApi(this, url, "POST", data) + const data = { + parentReference: { + id: dstId, + ...(driveId ? { driveId } : {}), + }, + name: dstPhys.split("/").filter(Boolean).pop() || "", } + const url = this.getMetaUrl(false, srcPhys, "copy") + await requestApi(this, url, "POST", data) } async put( diff --git a/src/backend/drivers/onedrive_app/driver.ts b/src/backend/drivers/onedrive_app/driver.ts index 06f086d4..db27e8d9 100644 --- a/src/backend/drivers/onedrive_app/driver.ts +++ b/src/backend/drivers/onedrive_app/driver.ts @@ -162,18 +162,9 @@ export class OnedriveAPP implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts 逐项调用), - // 再拼一次 name 会指向 /,DELETE 404 导致对象仍然存在。 - const expand = names && names.length > 1 - const targets = expand - ? names.map((n) => - physicalPath === "/" ? `/${n}` : `${physicalPath}/${n}`, - ) - : [physicalPath] - - for (const targetPath of targets) { - const url = getMetaUrl(this, false, targetPath) - await requestApi(this, url, "DELETE") - } + // 参数即目标项路径,不得再拼 name,否则指向 /,DELETE 404 导致对象仍然存在。 + const url = getMetaUrl(this, false, physicalPath) + await requestApi(this, url, "DELETE") } async move( @@ -183,41 +174,25 @@ export class OnedriveAPP implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name): - // 目标父目录需去掉末段(直接 GET dstPhys 拿到的是目标项本身,通常还不存在), + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name,参数即目标项路径, + // 不得再拼 name,否则指向 /): + // 目标父目录取 dstPhys 去掉末段(直接 GET dstPhys 拿到的是目标项本身,通常还不存在), // 目标名字以 dstPhys 末段为准,源项路径则直接使用 srcPhys。 - const expand = names && names.length > 1 - const dstParentPath = expand - ? dstPhys - : dstPhys.split("/").slice(0, -1).join("/") || "/" + const dstParentPath = dstPhys.split("/").slice(0, -1).join("/") || "/" const dstUrl = getMetaUrl(this, false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - const targets = expand - ? names.map((n) => ({ - srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, - name: n, - })) - : [ - { - srcPath: srcPhys, - name: dstPhys.split("/").filter(Boolean).pop() || "", - }, - ] - - for (const target of targets) { - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name: target.name, - } - const url = getMetaUrl(this, false, target.srcPath) - await requestApi(this, url, "PATCH", data) + const data = { + parentReference: { + id: dstId, + ...(driveId ? { driveId } : {}), + }, + name: dstPhys.split("/").filter(Boolean).pop() || "", } + const url = getMetaUrl(this, false, srcPhys) + await requestApi(this, url, "PATCH", data) } async copy( @@ -227,40 +202,23 @@ export class OnedriveAPP implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // 与 move 同理:目标父目录 = dstPhys 去掉末段,目标名字取 dstPhys 末段, - // 源项路径直接使用 srcPhys,不再重复拼接 name。 - const expand = names && names.length > 1 - const dstParentPath = expand - ? dstPhys - : dstPhys.split("/").slice(0, -1).join("/") || "/" + // 与 move 同理:参数是目标项自身路径,不得再拼 name; + // 目标父目录 = dstPhys 去掉末段,目标名字取 dstPhys 末段,源项路径直接使用 srcPhys。 + const dstParentPath = dstPhys.split("/").slice(0, -1).join("/") || "/" const dstUrl = getMetaUrl(this, false, dstParentPath) const dstRes = await requestApi(this, dstUrl, "GET") const dstId = dstRes.id const driveId = dstRes.parentReference?.driveId - const targets = expand - ? names.map((n) => ({ - srcPath: srcPhys === "/" ? `/${n}` : `${srcPhys}/${n}`, - name: n, - })) - : [ - { - srcPath: srcPhys, - name: dstPhys.split("/").filter(Boolean).pop() || "", - }, - ] - - for (const target of targets) { - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name: target.name, - } - const url = getMetaUrl(this, false, target.srcPath, "copy") - await requestApi(this, url, "POST", data) + const data = { + parentReference: { + id: dstId, + ...(driveId ? { driveId } : {}), + }, + name: dstPhys.split("/").filter(Boolean).pop() || "", } + const url = getMetaUrl(this, false, srcPhys, "copy") + await requestApi(this, url, "POST", data) } async put( diff --git a/src/backend/drivers/pikpak/driver.ts b/src/backend/drivers/pikpak/driver.ts index d49716c9..ddd26e8d 100644 --- a/src/backend/drivers/pikpak/driver.ts +++ b/src/backend/drivers/pikpak/driver.ts @@ -257,44 +257,28 @@ export class PikPakDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - // physicalPath 是目标项自身的物理路径,而 resolveParentId 解析的是路径 - // 自身的 id;拿它列子项再按 name 查找,命中的其实是 /, - // 找不到时静默跳过,对象仍然存在。 - const expand = names && names.length > 1 + // physicalPath 是目标项自身的物理路径(参数即目标项路径,不得再拼 name, + // 否则指向 /);父目录取 physicalPath 去掉末段后的部分, + // 目标名取其末段,在父目录子项中按名查找对应 id。 const clean = this.cleanPath(physicalPath) - const targets = expand - ? names.map((n) => (clean ? `${clean}/${n}` : `/${n}`)) - : [clean] - // 父目录:展开时 physicalPath 即公共父目录,否则取目标项的父目录 - const parentPath = expand ? clean : clean.split("/").slice(0, -1).join("/") + const targetName = clean.split("/").pop() || "" + const parentPath = clean.split("/").slice(0, -1).join("/") const parentId = await this.resolveParentId(parentPath) const files = await this.getFiles(parentId) - const ids: string[] = [] - - for (const target of targets) { - const match = files.find( - (f) => f.name === (target.split("/").pop() || ""), - ) - if (match) { - ids.push(match.id) - } - } - - if (ids.length === 0) return + const match = files.find((f) => f.name === targetName) + if (!match) return await this.client.request( "https://api-drive.mypikpak.net/drive/v1/files:batchTrash", { method: "POST", body: { - ids, + ids: [match.id], }, }, ) - for (const target of targets) { - this.idCache.delete(target) - } + this.idCache.delete(clean) } async move( @@ -304,44 +288,28 @@ export class PikPakDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name), - // 直接当父目录解析会指到项本身:源侧会去 的子项里找 name(永远落空), + // srcPhys/dstPhys 是源/目标项自身的物理路径(参数即目标项路径,不得再拼 name): + // 直接当父目录解析会指到项本身——源侧会去 的子项里找 name(永远落空), // 目标侧解析一个尚不存在的目标项必然报错。 - const expand = names && names.length > 1 + // 故两侧父目录均去掉末段,源名取 srcPhys 末段,在源父目录子项中按名查 id。 const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) - const targets = expand - ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) - : [srcClean] - const srcParentPath = expand - ? srcClean - : srcClean.split("/").slice(0, -1).join("/") - const dstParentPath = expand - ? dstClean - : dstClean.split("/").slice(0, -1).join("/") + const srcName = srcClean.split("/").pop() || "" + const srcParentPath = srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = dstClean.split("/").slice(0, -1).join("/") const srcParentId = await this.resolveParentId(srcParentPath) const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.getFiles(srcParentId) - const ids: string[] = [] - - for (const target of targets) { - const match = srcFiles.find( - (f) => f.name === (target.split("/").pop() || ""), - ) - if (match) { - ids.push(match.id) - } - } - - if (ids.length === 0) return + const match = srcFiles.find((f) => f.name === srcName) + if (!match) return await this.client.request( "https://api-drive.mypikpak.net/drive/v1/files:batchMove", { method: "POST", body: { - ids, + ids: [match.id], to: { parent_id: dstParentId, }, @@ -349,9 +317,7 @@ export class PikPakDriver implements StorageDriver { }, ) - for (const target of targets) { - this.idCache.delete(target) - } + this.idCache.delete(srcClean) } async copy( @@ -361,42 +327,26 @@ export class PikPakDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // 与 move 同理:源/目标项自身路径先去掉末段得到父目录再解析 id。 - const expand = names && names.length > 1 + // 与 move 同理:参数是源/目标项自身路径,不得再拼 name; + // 两侧父目录均去掉末段,源名取 srcPhys 末段,在源父目录子项中按名查 id。 const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) - const targets = expand - ? names.map((n) => (srcClean ? `${srcClean}/${n}` : `/${n}`)) - : [srcClean] - const srcParentPath = expand - ? srcClean - : srcClean.split("/").slice(0, -1).join("/") - const dstParentPath = expand - ? dstClean - : dstClean.split("/").slice(0, -1).join("/") + const srcName = srcClean.split("/").pop() || "" + const srcParentPath = srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = dstClean.split("/").slice(0, -1).join("/") const srcParentId = await this.resolveParentId(srcParentPath) const dstParentId = await this.resolveParentId(dstParentPath) const srcFiles = await this.getFiles(srcParentId) - const ids: string[] = [] - - for (const target of targets) { - const match = srcFiles.find( - (f) => f.name === (target.split("/").pop() || ""), - ) - if (match) { - ids.push(match.id) - } - } - - if (ids.length === 0) return + const match = srcFiles.find((f) => f.name === srcName) + if (!match) return await this.client.request( "https://api-drive.mypikpak.net/drive/v1/files:batchCopy", { method: "POST", body: { - ids, + ids: [match.id], to: { parent_id: dstParentId, }, diff --git a/src/backend/drivers/s3/driver.ts b/src/backend/drivers/s3/driver.ts index 1ef759b0..b6afeb40 100644 --- a/src/backend/drivers/s3/driver.ts +++ b/src/backend/drivers/s3/driver.ts @@ -237,22 +237,18 @@ export class S3Driver implements StorageDriver { dstPhys: string, ): Promise { await this.checkDogeToken() - const srcBase = this.getRemotePath(srcPhys) - const dstBase = this.getRemotePath(dstPhys) - const expand = names && names.length > 1 - const pairs = expand - ? names.map((n) => [joinPath(srcBase, n), joinPath(dstBase, n)] as const) - : [[srcBase, dstBase] as const] - - for (const [srcPath, dstPath] of pairs) { - const head = await this.client.headObject(srcPath) - if (head) { - await this.client.copyObject(srcPath, dstPath, head.size) - await this.client.deleteObject(srcPath) - } else { - await this.copyDirRecursive(srcPath, dstPath) - await this.removeDirRecursive(srcPath) - } + // srcPhys/dstPhys 是源/目标项自身的物理路径(参数即目标项路径,不得再拼 name, + // 否则指向 / 导致静默失败)。 + const srcPath = this.getRemotePath(srcPhys) + const dstPath = this.getRemotePath(dstPhys) + + const head = await this.client.headObject(srcPath) + if (head) { + await this.client.copyObject(srcPath, dstPath, head.size) + await this.client.deleteObject(srcPath) + } else { + await this.copyDirRecursive(srcPath, dstPath) + await this.removeDirRecursive(srcPath) } } @@ -264,20 +260,15 @@ export class S3Driver implements StorageDriver { dstPhys: string, ): Promise { await this.checkDogeToken() - const srcBase = this.getRemotePath(srcPhys) - const dstBase = this.getRemotePath(dstPhys) - const expand = names && names.length > 1 - const pairs = expand - ? names.map((n) => [joinPath(srcBase, n), joinPath(dstBase, n)] as const) - : [[srcBase, dstBase] as const] - - for (const [srcPath, dstPath] of pairs) { - const head = await this.client.headObject(srcPath) - if (head) { - await this.client.copyObject(srcPath, dstPath, head.size) - } else { - await this.copyDirRecursive(srcPath, dstPath) - } + // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 + const srcPath = this.getRemotePath(srcPhys) + const dstPath = this.getRemotePath(dstPhys) + + const head = await this.client.headObject(srcPath) + if (head) { + await this.client.copyObject(srcPath, dstPath, head.size) + } else { + await this.copyDirRecursive(srcPath, dstPath) } } @@ -302,20 +293,15 @@ export class S3Driver implements StorageDriver { ): Promise { await this.checkDogeToken() // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), - // 直接删除它即可;若再拼一次 name 会指向 `/`,HEAD 404 后退化成 - // 按目录递归删除,prefix 同样查不到对象,最终静默返回成功但对象仍在。 - const expand = names && names.length > 1 - const targets = expand - ? names.map((n) => joinPath(this.getRemotePath(physicalPath), n)) - : [this.getRemotePath(physicalPath)] - - for (const targetPath of targets) { - const head = await this.client.headObject(targetPath) - if (head) { - await this.client.deleteObject(targetPath) - } else { - await this.removeDirRecursive(targetPath) - } + // 参数即目标项路径,直接删除它即可;若再拼一次 name 会指向 `/`, + // HEAD 404 后退化成按目录递归删除,prefix 同样查不到对象,最终静默返回成功但对象仍在。 + const targetPath = this.getRemotePath(physicalPath) + + const head = await this.client.headObject(targetPath) + if (head) { + await this.client.deleteObject(targetPath) + } else { + await this.removeDirRecursive(targetPath) } } diff --git a/src/backend/drivers/seafile/driver.ts b/src/backend/drivers/seafile/driver.ts index 7e9d1c28..d17e73e4 100644 --- a/src/backend/drivers/seafile/driver.ts +++ b/src/backend/drivers/seafile/driver.ts @@ -265,23 +265,17 @@ export class SeafileDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), - // resolveRepoAndPath 已解析到目标项;再拼 name 会指向 `/`, - // DELETE 到不存在的路径,接口成功但文件仍在。 - const expand = names && names.length > 1 + // resolveRepoAndPath 已解析到目标项,参数即目标项路径,不得再拼 name,否则指向 + // `/`,DELETE 到不存在的路径,接口成功但文件仍在。 const { repoId, innerPath } = await this.resolveRepoAndPath(physicalPath) - const targets = expand - ? names.map((n) => (innerPath === "/" ? `/${n}` : `${innerPath}/${n}`)) - : [innerPath] - for (const targetPath of targets) { - await this.client.request( - `/api2/repos/${encodeURIComponent(repoId)}/file/`, - { - method: "DELETE", - params: { p: targetPath }, - }, - ) - } + await this.client.request( + `/api2/repos/${encodeURIComponent(repoId)}/file/`, + { + method: "DELETE", + params: { p: innerPath }, + }, + ) } async move( @@ -291,38 +285,27 @@ export class SeafileDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 源路径直接用 srcPath;dst_dir 需要目录,取 dstPath 的父级,再拼 name 会指向 - // `/`。 - const expand = names && names.length > 1 + // srcPhys/dstPhys 是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 参数即目标项路径,不得再拼 name,否则指向 `/`; + // 源路径直接用 src.innerPath,dst_dir 需要目录,取 dst.innerPath 的父级。 const src = await this.resolveRepoAndPath(srcPhys) const dst = await this.resolveRepoAndPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - src.innerPath === "/" ? `/${n}` : `${src.innerPath}/${n}`, - dst.innerPath === "/" ? `/${n}` : `${dst.innerPath}/${n}`, - ] as const, - ) - : ([[src.innerPath, dst.innerPath]] as const) - - for (const [srcPath, dstPath] of pairs) { - const dstDirPath = dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" - await this.client.request( - `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, - { - method: "POST", - isFormData: true, - params: { p: srcPath }, - body: { - operation: "move", - dst_repo: dst.repoId, - dst_dir: dstDirPath, - }, + const dstDirPath = + dst.innerPath.substring(0, dst.innerPath.lastIndexOf("/")) || "/" + + await this.client.request( + `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, + { + method: "POST", + isFormData: true, + params: { p: src.innerPath }, + body: { + operation: "move", + dst_repo: dst.repoId, + dst_dir: dstDirPath, }, - ) - } + }, + ) } async copy( @@ -332,38 +315,27 @@ export class SeafileDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), - // 源路径直接用 srcPath;dst_dir 需要目录,取 dstPath 的父级,再拼 name 会指向 - // `/`。 - const expand = names && names.length > 1 + // srcPhys/dstPhys 是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 参数即目标项路径,不得再拼 name,否则指向 `/`; + // 源路径直接用 src.innerPath,dst_dir 需要目录,取 dst.innerPath 的父级。 const src = await this.resolveRepoAndPath(srcPhys) const dst = await this.resolveRepoAndPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - src.innerPath === "/" ? `/${n}` : `${src.innerPath}/${n}`, - dst.innerPath === "/" ? `/${n}` : `${dst.innerPath}/${n}`, - ] as const, - ) - : ([[src.innerPath, dst.innerPath]] as const) - - for (const [srcPath, dstPath] of pairs) { - const dstDirPath = dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" - await this.client.request( - `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, - { - method: "POST", - isFormData: true, - params: { p: srcPath }, - body: { - operation: "copy", - dst_repo: dst.repoId, - dst_dir: dstDirPath, - }, + const dstDirPath = + dst.innerPath.substring(0, dst.innerPath.lastIndexOf("/")) || "/" + + await this.client.request( + `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, + { + method: "POST", + isFormData: true, + params: { p: src.innerPath }, + body: { + operation: "copy", + dst_repo: dst.repoId, + dst_dir: dstDirPath, }, - ) - } + }, + ) } async put( diff --git a/src/backend/drivers/sftp/driver.ts b/src/backend/drivers/sftp/driver.ts index 0d74ad92..acb840f8 100644 --- a/src/backend/drivers/sftp/driver.ts +++ b/src/backend/drivers/sftp/driver.ts @@ -193,15 +193,8 @@ export class SFTPDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 已是目标项自身的物理路径(op/storage.ts 逐项解析后传入), - // 再拼一次 name 会指向不存在的 /,导致静默删除失败。 - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 - const expand = names && names.length > 1 - const targets = expand - ? names.map((n) => posixJoin(physicalPath, n)) - : [cleanPosixPath(physicalPath)] - for (const target of targets) { - await this.client.removeRecursive(target) - } + // 参数即项路径,不得再拼 name,否则指向 / 导致静默删除失败。 + await this.client.removeRecursive(cleanPosixPath(physicalPath)) } async move( @@ -211,17 +204,9 @@ export class SFTPDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 已是源/目标项自身的物理路径,直接重命名即可; - // 再拼 name 会让源/目标路径错位。多 name 时才按「目录 + name」展开。 - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => [posixJoin(srcPhys, n), posixJoin(dstPhys, n)] as const, - ) - : [[cleanPosixPath(srcPhys), cleanPosixPath(dstPhys)] as const] - for (const [src, dst] of pairs) { - await this.client.rename(src, dst) - } + // srcPhys/dstPhys 已是源/目标项自身的物理路径,参数即项路径, + // 不得再拼 name,否则源/目标会错位到 /。 + await this.client.rename(cleanPosixPath(srcPhys), cleanPosixPath(dstPhys)) } async copy( diff --git a/src/backend/drivers/terabox/driver.ts b/src/backend/drivers/terabox/driver.ts index bb2d45c5..e6a4fe3c 100644 --- a/src/backend/drivers/terabox/driver.ts +++ b/src/backend/drivers/terabox/driver.ts @@ -201,13 +201,9 @@ export class TeraboxDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), - // 直接删除它即可;不得再拼 name,否则会指向 `/` 这种不存在的路径。 + // 参数即项路径,直接删除它即可;不得再拼 name,否则会指向 / 导致静默失败。 const clean = this.cleanPath(physicalPath) - const expand = names && names.length > 1 - const paths = expand - ? names.map((n) => (clean === "/" ? `/${n}` : `${clean}/${n}`)) - : [clean] - await this.client.manage("delete", paths) + await this.client.manage("delete", [clean]) } async move( @@ -220,24 +216,14 @@ export class TeraboxDriver implements StorageDriver { const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) // srcPhys/dstPhys 是目标项自身的路径:path 不得再拼 name; - // filemanager 的 dest 是目标目录,实际调用(单 name)时取 dstPhys 的父目录。 - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 - const expand = names && names.length > 1 - const fileList = expand - ? names.map((name) => ({ - path: srcClean === "/" ? `/${name}` : `${srcClean}/${name}`, - dest: dstClean, - newname: name, - })) - : [ - { - path: srcClean, - dest: dstClean.split("/").slice(0, -1).join("/") || "/", - newname: dstClean.split("/").pop() || "", - }, - ] - - await this.client.manage("move", fileList) + // filemanager 的 dest 是目标目录,故取 dstPhys 去掉末段后的父目录,newname 取末段。 + await this.client.manage("move", [ + { + path: srcClean, + dest: dstClean.split("/").slice(0, -1).join("/") || "/", + newname: dstClean.split("/").pop() || "", + }, + ]) } async copy( @@ -249,23 +235,15 @@ export class TeraboxDriver implements StorageDriver { ): Promise { const srcClean = this.cleanPath(srcPhys) const dstClean = this.cleanPath(dstPhys) - // 同 move:srcPhys/dstPhys 已是目标项自身路径,path 不得再拼 name。 - const expand = names && names.length > 1 - const fileList = expand - ? names.map((name) => ({ - path: srcClean === "/" ? `/${name}` : `${srcClean}/${name}`, - dest: dstClean, - newname: name, - })) - : [ - { - path: srcClean, - dest: dstClean.split("/").slice(0, -1).join("/") || "/", - newname: dstClean.split("/").pop() || "", - }, - ] - - await this.client.manage("copy", fileList) + // 同 move:srcPhys/dstPhys 已是目标项自身路径,path 不得再拼 name; + // dest 取 dstPhys 去掉末段后的父目录,newname 取末段。 + await this.client.manage("copy", [ + { + path: srcClean, + dest: dstClean.split("/").slice(0, -1).join("/") || "/", + newname: dstClean.split("/").pop() || "", + }, + ]) } async put( diff --git a/src/backend/drivers/uss/driver.ts b/src/backend/drivers/uss/driver.ts index 3b7af3c8..914ecf04 100644 --- a/src/backend/drivers/uss/driver.ts +++ b/src/backend/drivers/uss/driver.ts @@ -181,22 +181,8 @@ export class UssDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 不得再拼 name,否则会指向 `/` 这种不存在的路径; - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御性分支)。 - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => - [ - joinPath(this.getKey(srcPhys), n), - joinPath(this.getKey(dstPhys), n), - ] as const, - ) - : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) - - for (const [srcKey, dstKey] of pairs) { - await this.moveOrCopy(srcKey, dstKey, "move") - } + // 参数即项路径,不得再拼 name,否则会指向 / 导致移动失败。 + await this.moveOrCopy(this.getKey(srcPhys), this.getKey(dstPhys), "move") } async copy( @@ -206,21 +192,8 @@ export class UssDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => - [ - joinPath(this.getKey(srcPhys), n), - joinPath(this.getKey(dstPhys), n), - ] as const, - ) - : ([[this.getKey(srcPhys), this.getKey(dstPhys)]] as const) - - for (const [srcKey, dstKey] of pairs) { - await this.moveOrCopy(srcKey, dstKey, "copy") - } + // 同 move:srcPhys/dstPhys 已是目标项自身路径,参数即项路径,不得再拼 name。 + await this.moveOrCopy(this.getKey(srcPhys), this.getKey(dstPhys), "copy") } async put(_v: string, physicalPath: string, content: Buffer): Promise { diff --git a/src/backend/drivers/webdav/driver.ts b/src/backend/drivers/webdav/driver.ts index 2d29f85a..f4d98438 100644 --- a/src/backend/drivers/webdav/driver.ts +++ b/src/backend/drivers/webdav/driver.ts @@ -105,20 +105,13 @@ export class WebdavDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 已是源/目标项自身的路径,直接移动即可; + // srcPhys/dstPhys 已是源/目标项自身的路径,参数即项路径,直接移动即可; // 再拼一次 name 会指向不存在的 /,MOVE 源/目标错位。 - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 - const srcBasePath = this.getRemotePath(srcPhys) - const dstBasePath = this.getRemotePath(dstPhys) - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => [joinPath(srcBasePath, n), joinPath(dstBasePath, n)] as const, - ) - : [[srcBasePath, dstBasePath] as const] - for (const [srcPath, dstPath] of pairs) { - await this.client.move(srcPath, dstPath, true) - } + await this.client.move( + this.getRemotePath(srcPhys), + this.getRemotePath(dstPhys), + true, + ) } async copy( @@ -128,20 +121,13 @@ export class WebdavDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - // srcPhys/dstPhys 已是源/目标项自身的路径,直接复制即可; + // srcPhys/dstPhys 已是源/目标项自身的路径,参数即项路径,直接复制即可; // 再拼一次 name 会让 COPY 源/目标路径错位。 - // 仅当 names.length > 1 时才按「目录 + 多个 name」展开(防御分支)。 - const srcBasePath = this.getRemotePath(srcPhys) - const dstBasePath = this.getRemotePath(dstPhys) - const expand = names && names.length > 1 - const pairs = expand - ? names.map( - (n) => [joinPath(srcBasePath, n), joinPath(dstBasePath, n)] as const, - ) - : [[srcBasePath, dstBasePath] as const] - for (const [srcPath, dstPath] of pairs) { - await this.client.copy(srcPath, dstPath, true) - } + await this.client.copy( + this.getRemotePath(srcPhys), + this.getRemotePath(dstPhys), + true, + ) } async remove( @@ -150,15 +136,9 @@ export class WebdavDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 已是目标项自身的路径(op/storage.ts 逐项解析后传入), - // 再拼一次 name 会指向 /,WebDAV DELETE 对 404 视作成功, - // 结果接口静默返回成功但对象仍在。多 name 时才按「目录 + name」展开。 - const expand = names && names.length > 1 - const targets = expand - ? names.map((n) => joinPath(this.getRemotePath(physicalPath), n)) - : [this.getRemotePath(physicalPath)] - for (const targetPath of targets) { - await this.client.remove(targetPath) - } + // 参数即项路径,不得再拼 name,否则指向 /,WebDAV DELETE 对 404 视作成功, + // 结果接口静默返回成功但对象仍在。 + await this.client.remove(this.getRemotePath(physicalPath)) } async put( diff --git a/src/backend/drivers/wps/driver.ts b/src/backend/drivers/wps/driver.ts index 849b9ac6..0ad78367 100644 --- a/src/backend/drivers/wps/driver.ts +++ b/src/backend/drivers/wps/driver.ts @@ -218,36 +218,23 @@ export class WpsDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 源直接用 srcPath;目标文件夹是 dstPath 的父级,再拼 name 会指向 `/`。 - const expand = names && names.length > 1 - const srcBase = this.cleanPath(srcPhys) - const dstBase = this.cleanPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, - dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, - ] as const, - ) - : ([[srcBase, dstBase]] as const) - - for (const [srcPath, dstPath] of pairs) { - const dstParentPath = - dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" - const dstNode = await this.resolvePath(dstParentPath) - if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { - throw new Error("Target destination directory not found") - } - const srcNode = await this.resolvePath(srcPath) - if (srcNode) { - await this.client.move( - srcNode.groupId, - srcNode.fileId, - dstNode.groupId, - dstNode.fileId, - ) - } + // 参数即项路径:源直接用 srcPath,目标文件夹取 dstPath 去掉末段的父目录; + // 不得再拼 name,否则会指向 /。 + const srcPath = this.cleanPath(srcPhys) + const dstPath = this.cleanPath(dstPhys) + const dstParentPath = dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" + const dstNode = await this.resolvePath(dstParentPath) + if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { + throw new Error("Target destination directory not found") + } + const srcNode = await this.resolvePath(srcPath) + if (srcNode) { + await this.client.move( + srcNode.groupId, + srcNode.fileId, + dstNode.groupId, + dstNode.fileId, + ) } } @@ -272,36 +259,23 @@ export class WpsDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), - // 源直接用 srcPath;目标文件夹是 dstPath 的父级,再拼 name 会指向 `/`。 - const expand = names && names.length > 1 - const srcBase = this.cleanPath(srcPhys) - const dstBase = this.cleanPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, - dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, - ] as const, - ) - : ([[srcBase, dstBase]] as const) - - for (const [srcPath, dstPath] of pairs) { - const dstParentPath = - dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" - const dstNode = await this.resolvePath(dstParentPath) - if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { - throw new Error("Target destination directory not found") - } - const srcNode = await this.resolvePath(srcPath) - if (srcNode) { - await this.client.copy( - srcNode.groupId, - srcNode.fileId, - dstNode.groupId, - dstNode.fileId, - ) - } + // 参数即项路径:源直接用 srcPath,目标文件夹取 dstPath 去掉末段的父目录; + // 不得再拼 name,否则会指向 /。 + const srcPath = this.cleanPath(srcPhys) + const dstPath = this.cleanPath(dstPhys) + const dstParentPath = dstPath.substring(0, dstPath.lastIndexOf("/")) || "/" + const dstNode = await this.resolvePath(dstParentPath) + if (!dstNode || !dstNode.isDir || dstNode.kind === "root") { + throw new Error("Target destination directory not found") + } + const srcNode = await this.resolvePath(srcPath) + if (srcNode) { + await this.client.copy( + srcNode.groupId, + srcNode.fileId, + dstNode.groupId, + dstNode.fileId, + ) } } @@ -311,19 +285,11 @@ export class WpsDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), - // 直接解析它即可;再拼 name 会指向 `/`,resolvePath 返回 null + // 参数即项路径,直接解析它即可;再拼 name 会指向 /,resolvePath 返回 null // 后被静默跳过,接口成功但文件仍在。 - const expand = names && names.length > 1 - const base = this.cleanPath(physicalPath) - const targets = expand - ? names.map((n) => (base === "/" ? `/${n}` : `${base}/${n}`)) - : [base] - - for (const targetPath of targets) { - const node = await this.resolvePath(targetPath) - if (node && node.kind !== "root" && node.kind !== "group") { - await this.client.delete(node.groupId, node.fileId) - } + const node = await this.resolvePath(this.cleanPath(physicalPath)) + if (node && node.kind !== "root" && node.kind !== "group") { + await this.client.delete(node.groupId, node.fileId) } } diff --git a/src/backend/drivers/yandex/driver.ts b/src/backend/drivers/yandex/driver.ts index d5ad0a5f..74685710 100644 --- a/src/backend/drivers/yandex/driver.ts +++ b/src/backend/drivers/yandex/driver.ts @@ -137,19 +137,11 @@ export class YandexDriver implements StorageDriver { names: string[], ): Promise { // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), - // 直接删除它即可;再拼 name 会指向 `/`,DELETE 404 报错。 - const expand = names && names.length > 1 - const base = this.cleanPath(physicalPath) - const targets = expand - ? names.map((n) => (base === "/" ? `/${n}` : `${base}/${n}`)) - : [base] - - for (const targetPath of targets) { - await this.client.request("", { - method: "DELETE", - params: { path: targetPath }, - }) - } + // 参数即项路径,直接删除它即可;再拼 name 会指向 /,DELETE 404 报错。 + await this.client.request("", { + method: "DELETE", + params: { path: this.cleanPath(physicalPath) }, + }) } async move( @@ -160,30 +152,15 @@ export class YandexDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), - // 直接作为 from/path 使用;再拼 name 会指向 `/`,源/目标错位。 - const expand = names && names.length > 1 - const srcBase = this.cleanPath(srcPhys) - const dstBase = this.cleanPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, - dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, - ] as const, - ) - : ([[srcBase, dstBase]] as const) - - for (const [fromPath, toPath] of pairs) { - await this.client.request("/move", { - method: "POST", - params: { - from: fromPath, - path: toPath, - overwrite: "true", - }, - }) - } + // 参数即项路径,直接作为 from/path 使用;再拼 name 会指向 /,源/目标错位。 + await this.client.request("/move", { + method: "POST", + params: { + from: this.cleanPath(srcPhys), + path: this.cleanPath(dstPhys), + overwrite: "true", + }, + }) } async copy( @@ -194,30 +171,15 @@ export class YandexDriver implements StorageDriver { dstPhys: string, ): Promise { // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), - // 直接作为 from/path 使用;再拼 name 会指向 `/`,源/目标错位。 - const expand = names && names.length > 1 - const srcBase = this.cleanPath(srcPhys) - const dstBase = this.cleanPath(dstPhys) - const pairs = expand - ? names.map( - (n) => - [ - srcBase === "/" ? `/${n}` : `${srcBase}/${n}`, - dstBase === "/" ? `/${n}` : `${dstBase}/${n}`, - ] as const, - ) - : ([[srcBase, dstBase]] as const) - - for (const [fromPath, toPath] of pairs) { - await this.client.request("/copy", { - method: "POST", - params: { - from: fromPath, - path: toPath, - overwrite: "true", - }, - }) - } + // 参数即项路径,直接作为 from/path 使用;再拼 name 会指向 /,源/目标错位。 + await this.client.request("/copy", { + method: "POST", + params: { + from: this.cleanPath(srcPhys), + path: this.cleanPath(dstPhys), + overwrite: "true", + }, + }) } async put( From 9039f64cc7ea480c0e40c2a1cd02488ccb308f02 Mon Sep 17 00:00:00 2001 From: Astroptis <250784029+Astroptis@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:08:51 +0800 Subject: [PATCH 7/7] =?UTF-8?q?test(drivers):=20=E8=A1=A5=20dropbox/yandex?= =?UTF-8?q?/webdav/onedrive/sftp/ftp=20=E7=9A=84=20remove/move=20=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审指出 22 个改动驱动里只有 s3/local 有回归保护。按可 mock 性补齐 6 个: - dropbox/yandex/webdav/onedrive:mock globalThis.fetch,断言 DELETE/POST/MOVE 请求的路径参数为目标项自身路径(无 / 二次拼接) - sftp:注入 fake sftpClient(getSFTP 缓存点),断言 unlink/rename 收到项路径 - ftp:替换私有 client(dropbox 先例),断言 removeRecursive/rename 参数 ftp/sftp 无法 mock.module(Node 无实验标志),故用注入方式;6 文件共 13 例, 均验证通过且不发起真实网络连接。 Co-Authored-By: opencode --- .../drivers/dropbox/dropbox_remove.test.ts | 123 ++++++++++++ src/backend/drivers/ftp/ftp_remove.test.ts | 65 +++++++ .../drivers/onedrive/onedrive_remove.test.ts | 134 +++++++++++++ src/backend/drivers/sftp/sftp_remove.test.ts | 81 ++++++++ .../drivers/webdav/webdav_remove.test.ts | 183 ++++++++++++++++++ .../drivers/yandex/yandex_remove.test.ts | 132 +++++++++++++ 6 files changed, 718 insertions(+) create mode 100644 src/backend/drivers/dropbox/dropbox_remove.test.ts create mode 100644 src/backend/drivers/ftp/ftp_remove.test.ts create mode 100644 src/backend/drivers/onedrive/onedrive_remove.test.ts create mode 100644 src/backend/drivers/sftp/sftp_remove.test.ts create mode 100644 src/backend/drivers/webdav/webdav_remove.test.ts create mode 100644 src/backend/drivers/yandex/yandex_remove.test.ts diff --git a/src/backend/drivers/dropbox/dropbox_remove.test.ts b/src/backend/drivers/dropbox/dropbox_remove.test.ts new file mode 100644 index 00000000..26bc6298 --- /dev/null +++ b/src/backend/drivers/dropbox/dropbox_remove.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { DropboxDriver } from "./driver" + +/** + * 调用约定(src/backend/internal/op/storage.ts 的 removeItems/moveItems): + * + * const resolved = await resolvePath(`${dir}/${name}`) + * await driver.remove(virtualPath, resolved.physical, [name]) + * + * 即 physicalPath/srcPhys/dstPhys 是【目标项自身】的物理路径(已含文件名), + * 调用方恒传单元素 names。driver 不得再把 name 拼到该路径后面,否则 body.path + * 变成 `/`:delete_v2/move_v2 找不到该路径 → 404 报错, + * 目标项既没被删除也没被移动。 + */ + +type Call = { method: string; url: string; body: string } + +/** 记录请求,恒返回 200 + application/json(util.request 走 res.json() 分支) */ +function mockDropbox(calls: Call[]) { + return async (input: any, init: any = {}) => { + const url = String(typeof input === "string" ? input : input.url) + calls.push({ + method: String(init.method || "GET").toUpperCase(), + url, + body: typeof init.body === "string" ? init.body : "", + }) + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }) + } +} + +function withMock(calls: Call[], fn: () => Promise): Promise { + const original = globalThis.fetch + globalThis.fetch = mockDropbox(calls) as any + return fn().finally(() => { + globalThis.fetch = original + }) +} + +function makeDriver() { + // access_token 必填:util.request 发现无 token 会先联网 refreshToken(), + // 测试不调用 init(),也不允许出现 oauth2/token 请求。 + return new DropboxDriver({ + access_token: "tok", + refresh_token: "r", + client_id: "id", + client_secret: "sec", + }) +} + +test("Dropbox remove() 删除项自身路径,不再拼一次 name", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + makeDriver().remove("/d/a.txt", "/d/a.txt", ["a.txt"]), + ) + + const deletes = calls.filter( + (c) => c.url === "https://api.dropboxapi.com/2/files/delete_v2", + ) + assert.equal( + deletes.length, + 1, + `应恰好发出 1 次 files/delete_v2 请求,实际 ${deletes.length}`, + ) + const body = JSON.parse(deletes[0].body) + assert.equal( + body.path, + "/d/a.txt", + `delete 的 path 应为项自身路径 /d/a.txt,实际 ${body.path}`, + ) + assert.ok( + !String(body.path).endsWith("/a.txt/a.txt"), + "path 不得出现二次拼接的 /", + ) + assert.ok( + calls.every((c) => !c.url.includes("/oauth2/token")), + "已带 access_token,不应触发 token 刷新请求", + ) +}) + +test("Dropbox move() 的 from_path/to_path 分别为源/目标项自身路径", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + makeDriver().move("/d", "/d/dst", ["a.txt"], "/d/a.txt", "/d/dst/a.txt"), + ) + + const moves = calls.filter( + (c) => c.url === "https://api.dropboxapi.com/2/files/move_v2", + ) + assert.equal( + moves.length, + 1, + `应恰好发出 1 次 files/move_v2 请求,实际 ${moves.length}`, + ) + const body = JSON.parse(moves[0].body) + assert.equal( + body.from_path, + "/d/a.txt", + `from_path 应为源项自身路径 /d/a.txt,实际 ${body.from_path}`, + ) + assert.equal( + body.to_path, + "/d/dst/a.txt", + `to_path 应为目标项自身路径 /d/dst/a.txt,实际 ${body.to_path}`, + ) + assert.ok( + !String(body.from_path).endsWith("/a.txt/a.txt"), + "from_path 不得出现二次拼接的 /", + ) + assert.ok( + !String(body.to_path).endsWith("/a.txt/a.txt"), + "to_path 不得出现二次拼接的 /", + ) + assert.ok( + calls.every((c) => !c.url.includes("/oauth2/token")), + "已带 access_token,不应触发 token 刷新请求", + ) +}) diff --git a/src/backend/drivers/ftp/ftp_remove.test.ts b/src/backend/drivers/ftp/ftp_remove.test.ts new file mode 100644 index 00000000..6d08a43a --- /dev/null +++ b/src/backend/drivers/ftp/ftp_remove.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { FTPDriver } from "./driver" + +/** + * 调用约定(src/backend/internal/op/storage.ts 的 removeItems/moveItems): + * + * await driver.remove(virtualPath, resolved.physical, [name]) + * await driver.move(srcDir, dstDir, [name], srcPhys, dstPhys) + * + * 即 physicalPath / srcPhys / dstPhys 都是【目标项自身】的物理路径(已含文件名), + * driver 不得再把 name 拼接到路径后面,否则会指向 `/`, + * removeRecursive/rename 打到不存在的路径 → 静默失败。 + * + * Node v24 下 mock.module 不可用(需实验标志),且不调 init()(否则真连 TCP): + * 用注入私有 client 的方式(同 dropbox/driver.test.ts 先例),替换 driver.client + * 为 fake 对象,全程不建立网络连接。只影响本用例新建的 driver 实例,不污染全局。 + */ + +function makeDriverWithFakeClient(calls: string[]): FTPDriver { + const driver = new FTPDriver({ + address: "127.0.0.1:21", + username: "u", + password: "p", + }) + ;(driver as any).client = { + removeRecursive: async (p: string) => { + calls.push(`rm ${p}`) + }, + rename: async (s: string, d: string) => { + calls.push(`mv ${s} -> ${d}`) + }, + } + return driver +} + +test("FTP remove() 删除项自身路径,不拼接 name", async () => { + const calls: string[] = [] + const driver = makeDriverWithFakeClient(calls) + + await driver.remove("/d/a.txt", "/d/a.txt", ["a.txt"]) + + assert.deepEqual( + calls, + ["rm /d/a.txt"], + "removeRecursive 应收到项自身路径(fake 必须被调用,否则注入未生效)", + ) + assert.ok( + !calls.some((c) => c.includes("a.txt/a.txt")), + `路径不得出现二次拼接的 a.txt/a.txt,实际:${calls.join(", ")}`, + ) +}) + +test("FTP move() 把源/目标项自身路径传给 rename", async () => { + const calls: string[] = [] + const driver = makeDriverWithFakeClient(calls) + + await driver.move("/d", "/d/bak", ["a.txt"], "/d/a.txt", "/d/bak/a.txt") + + assert.deepEqual( + calls, + ["mv /d/a.txt -> /d/bak/a.txt"], + "rename 的两个参数必须是源/目标项自身路径(fake 必须被调用,否则注入未生效)", + ) +}) diff --git a/src/backend/drivers/onedrive/onedrive_remove.test.ts b/src/backend/drivers/onedrive/onedrive_remove.test.ts new file mode 100644 index 00000000..2d6efa05 --- /dev/null +++ b/src/backend/drivers/onedrive/onedrive_remove.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Onedrive } from "./driver" + +/** + * 调用约定(src/backend/internal/op/storage.ts 的 removeItems/moveItems): + * + * await driver.remove(virtualPath, resolved.physical, [name]) + * await driver.move(srcDir, dstDir, [name], srcPhys, dstPhys) + * + * 即 physicalPath / srcPhys / dstPhys 都是【目标项自身】的物理路径(已含文件名), + * driver 不得再把 name 拼接到路径后面,否则会指向 `/`: + * DELETE 404 → 静默失败,对象仍然存在。 + * + * 构造 `new Onedrive({})` 不联网,不调 init()(fixture 没有 refresh_token); + * 401 会触发 refreshToken 真实联网,所以 mock fetch 一律返回 2xx。 + */ + +type Call = { method: string; url: string; body?: string } + +/** 按 (method, url) 分派的 Graph API mock,记录所有请求以便断言目标路径 */ +function mockGraph(calls: Call[]) { + return async (input: any, init: any = {}) => { + const url = new URL(String(typeof input === "string" ? input : input.url)) + const method = String(init.method || "GET").toUpperCase() + calls.push({ + method, + url: url.toString(), + body: typeof init.body === "string" ? init.body : undefined, + }) + + // DELETE → 204 无 body;GET → 目标父目录解析;PATCH → 更新项 + if (method === "DELETE") return new Response(null, { status: 204 }) + const json = JSON.stringify( + method === "GET" + ? { id: "parent-id", parentReference: { driveId: "d1" } } + : {}, + ) + return new Response(json, { + status: 200, + headers: { "content-type": "application/json" }, + }) + } +} + +function withMock(calls: Call[], fn: () => Promise): Promise { + const original = globalThis.fetch + globalThis.fetch = mockGraph(calls) as any + return fn().finally(() => { + globalThis.fetch = original + }) +} + +test("OneDrive remove() 删除目标项自身路径,不拼接 name", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + new Onedrive({}).remove("/d/a.txt", "/d/a.txt", ["a.txt"]), + ) + + const deletes = calls.filter((c) => c.method === "DELETE") + assert.equal( + deletes.length, + 1, + `应恰好发出 1 次 DELETE,实际请求:${calls.map((c) => c.method).join(", ") || "(无)"}`, + ) + assert.equal( + deletes[0].url, + "https://graph.microsoft.com/v1.0/me/drive/root:/d/a.txt:", + "DELETE 必须指向项自身路径(结尾裸冒号),即 physicalPath 本身", + ) + const pathname = decodeURIComponent(new URL(deletes[0].url).pathname) + assert.ok( + !pathname.includes("a.txt/a.txt"), + `路径不得出现二次拼接的 a.txt/a.txt,实际:${pathname}`, + ) +}) + +test("OneDrive move() 先 GET 目标父目录,再 PATCH 源项自身路径", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + new Onedrive({}).move( + "/d", + "/d/bak", + ["a.txt"], + "/d/a.txt", + "/d/bak/a.txt", + ), + ) + + const getIdx = calls.findIndex((c) => c.method === "GET") + const patchIdx = calls.findIndex((c) => c.method === "PATCH") + assert.ok( + getIdx >= 0 && patchIdx >= 0, + `move 应先 GET 目标父目录再 PATCH 源项,实际请求:${calls.map((c) => c.method).join(", ") || "(无)"}`, + ) + assert.ok( + getIdx < patchIdx, + "GET(解析目标父目录)必须先于 PATCH(更新源项)", + ) + + const getPathname = decodeURIComponent(new URL(calls[getIdx].url).pathname) + assert.equal( + getPathname, + "/v1.0/me/drive/root:/d/bak:", + "GET 应指向 dstPhys 去掉末段后的目标父目录", + ) + + const patchPathname = decodeURIComponent( + new URL(calls[patchIdx].url).pathname, + ) + assert.equal( + patchPathname, + "/v1.0/me/drive/root:/d/a.txt:", + "PATCH 必须指向源项自身路径(srcPhys),即结尾裸冒号", + ) + assert.ok( + !patchPathname.includes("a.txt/a.txt"), + `PATCH 路径不得出现二次拼接的 a.txt/a.txt,实际:${patchPathname}`, + ) + + const body = JSON.parse(calls[patchIdx].body || "{}") + assert.equal( + body.name, + "a.txt", + `PATCH body 的 name 应取 dstPhys 末段,实际:${body.name}`, + ) + assert.equal( + body.parentReference?.id, + "parent-id", + "PATCH body 应携带 GET 解析出的目标父目录 id", + ) +}) diff --git a/src/backend/drivers/sftp/sftp_remove.test.ts b/src/backend/drivers/sftp/sftp_remove.test.ts new file mode 100644 index 00000000..7777356c --- /dev/null +++ b/src/backend/drivers/sftp/sftp_remove.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { SFTPDriver } from "./driver" + +/** + * 调用约定(src/backend/internal/op/storage.ts 的 removeItems/moveItems): + * + * await driver.remove(virtualPath, resolved.physical, [name]) + * await driver.move(srcDir, dstDir, [name], srcPhys, dstPhys) + * + * 即 physicalPath / srcPhys / dstPhys 都是【目标项自身】的物理路径(已含文件名), + * driver 不得再把 name 拼接到路径后面,否则会指向 `/`, + * lstat 查不到 → removeRecursive 静默返回,接口报成功但文件仍在。 + * + * Node v24 下 mock.module 不可用(需实验标志),且不调 init()(避免真连 SSH): + * 注入点是 SFTPClientWrapper.getSFTP() 首行的 sftpClient 缓存(见 sftp/util.ts), + * 把 fake sftp 对象挂上去,removeRecursive/rename 走的回调式方法全部命中 fake,不触网。 + * 只影响本用例新建的 driver 实例,不污染全局。 + */ + +function makeDriverWithFakeSFTP(fake: any): SFTPDriver { + const driver = new SFTPDriver({ + address: "127.0.0.1:22", + username: "root", + password: "x", + }) + ;(driver as any).client["sftpClient"] = fake + return driver +} + +test("SFTP remove() 对项自身路径调用 lstat/unlink,不拼接 name", async () => { + const calls: string[] = [] + const driver = makeDriverWithFakeSFTP({ + lstat: (p: string, cb: any) => { + calls.push(`lstat ${p}`) + cb(null, { isDirectory: () => false }) + }, + readdir: (p: string, cb: any) => { + calls.push(`readdir ${p}`) + cb(null, []) + }, + unlink: (p: string, cb: any) => { + calls.push(`unlink ${p}`) + cb(null) + }, + rmdir: (p: string, cb: any) => { + calls.push(`rmdir ${p}`) + cb(null) + }, + }) + + await driver.remove("/d/a.txt", "/d/a.txt", ["a.txt"]) + + assert.deepEqual( + calls, + ["lstat /d/a.txt", "unlink /d/a.txt"], + "remove 应直接作用于项自身路径(fake 必须被调用,否则注入未生效)", + ) + assert.ok( + !calls.some((c) => c.includes("a.txt/a.txt")), + `路径不得出现二次拼接的 a.txt/a.txt,实际:${calls.join(", ")}`, + ) +}) + +test("SFTP move() 把源/目标项自身路径传给 rename", async () => { + const calls: string[] = [] + const driver = makeDriverWithFakeSFTP({ + rename: (a: string, b: string, cb: any) => { + calls.push(`rename ${a} -> ${b}`) + cb(null) + }, + }) + + await driver.move("/d", "/d/bak", ["a.txt"], "/d/a.txt", "/d/bak/a.txt") + + assert.deepEqual( + calls, + ["rename /d/a.txt -> /d/bak/a.txt"], + "rename 的两个参数必须是源/目标项自身路径(fake 必须被调用,否则注入未生效)", + ) +}) diff --git a/src/backend/drivers/webdav/webdav_remove.test.ts b/src/backend/drivers/webdav/webdav_remove.test.ts new file mode 100644 index 00000000..6e4876d5 --- /dev/null +++ b/src/backend/drivers/webdav/webdav_remove.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { WebdavDriver } from "./driver" + +/** + * 调用约定(src/backend/internal/op/storage.ts 的 removeItems/moveItems): + * + * const resolved = await resolvePath(`${dir}/${name}`) + * await driver.remove(virtualPath, resolved.physical, [name]) + * + * physicalPath/srcPhys/dstPhys 是【目标项自身】的物理路径(已含文件名), + * 调用方恒传单元素 names。driver 不得再把 name 拼到该路径后面,否则 + * DELETE 打到 `/`(WebDAV 对 404 视作成功 → 接口报成功但对象仍在), + * MOVE 的请求源与 Destination 头源/目标也会错位。 + * + * 状态码坑:DELETE 返回 204(实现接受 200/204/404);MOVE 返回 201(绝不能 409, + * 409 会触发 mkdirAll 重试链,产生额外请求干扰断言)。 + */ + +type Call = { method: string; url: string; headers: Headers } + +/** 记录请求;DELETE 回 204、MOVE 回 201,其余回 200 */ +function mockWebdav(calls: Call[]) { + return async (input: any, init: any = {}) => { + const url = String(typeof input === "string" ? input : input.url) + const method = String(init.method || "GET").toUpperCase() + calls.push({ method, url, headers: new Headers(init.headers || {}) }) + if (method === "DELETE") { + return new Response(null, { status: 204 }) + } + if (method === "MOVE") { + return new Response(null, { status: 201 }) + } + return new Response(null, { status: 200 }) + } +} + +function withMock(calls: Call[], fn: () => Promise): Promise { + const original = globalThis.fetch + globalThis.fetch = mockWebdav(calls) as any + return fn().finally(() => { + globalThis.fetch = original + }) +} + +function makeDriver(rootFolderPath?: string) { + // 构造不联网,测试不调用 init();root_folder_path 缺省为 "/" + return new WebdavDriver({ + address: "https://dav.example.com", + username: "u", + password: "p", + ...(rootFolderPath ? { root_folder_path: rootFolderPath } : {}), + }) +} + +test("Webdav remove() DELETE 目标为项自身路径,不再拼一次 name", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + makeDriver().remove("/d/a.txt", "/d/a.txt", ["a.txt"]), + ) + + const deletes = calls.filter((c) => c.method === "DELETE") + assert.equal( + deletes.length, + 1, + `应恰好发出 1 次 DELETE 请求,实际 ${deletes.length}`, + ) + const pathname = decodeURIComponent(new URL(deletes[0].url).pathname) + assert.equal( + pathname, + "/d/a.txt", + `DELETE 目标应为项自身路径 /d/a.txt,实际 ${pathname}`, + ) + assert.ok( + !pathname.includes("a.txt/a.txt"), + "DELETE 目标不得出现二次拼接的 /", + ) +}) + +test("Webdav move() 请求源与 Destination 头分别为源/目标项自身路径", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + makeDriver().move("/d", "/d/dst", ["a.txt"], "/d/a.txt", "/d/dst/a.txt"), + ) + + const moves = calls.filter((c) => c.method === "MOVE") + assert.equal( + moves.length, + 1, + `应恰好发出 1 次 MOVE 请求,实际 ${moves.length}`, + ) + const pathname = decodeURIComponent(new URL(moves[0].url).pathname) + assert.equal( + pathname, + "/d/a.txt", + `MOVE 请求 URL 应为源项自身路径 /d/a.txt,实际 ${pathname}`, + ) + assert.ok( + !pathname.includes("a.txt/a.txt"), + "MOVE 请求 URL 不得出现二次拼接的 /", + ) + // Headers 不区分大小写,get("destination") 与写入时的 Destination 等价 + const destination = moves[0].headers.get("destination") + assert.ok(destination, "MOVE 必须携带 Destination 头") + const destPathname = decodeURIComponent(new URL(destination).pathname) + assert.equal( + destPathname, + "/d/dst/a.txt", + `Destination 应指向目标项自身路径 /d/dst/a.txt,实际 ${destination}`, + ) + assert.ok( + !destPathname.includes("a.txt/a.txt"), + "Destination 不得出现二次拼接的 /", + ) + assert.equal( + calls.filter((c) => c.method === "MKCOL").length, + 0, + "MOVE 返回 201 不应触发 mkdirAll 重试链", + ) +}) + +test("Webdav root_folder_path 前缀只拼一次(remove 与 move)", async () => { + const removeCalls: Call[] = [] + + await withMock(removeCalls, () => + makeDriver("/root").remove("/d/a.txt", "/d/a.txt", ["a.txt"]), + ) + const removeDeletes = removeCalls.filter((c) => c.method === "DELETE") + assert.equal( + removeDeletes.length, + 1, + `应恰好发出 1 次 DELETE 请求,实际 ${removeDeletes.length}`, + ) + const removePath = decodeURIComponent(new URL(removeDeletes[0].url).pathname) + assert.equal( + removePath, + "/root/d/a.txt", + `DELETE 目标应为 /<项路径> = /root/d/a.txt,实际 ${removePath}`, + ) + assert.ok(!removePath.includes("/root/root/"), "root 前缀不得被拼两次") + assert.ok( + !removePath.includes("a.txt/a.txt"), + "DELETE 目标不得出现二次拼接的 /", + ) + + const moveCalls: Call[] = [] + + await withMock(moveCalls, () => + makeDriver("/root").move( + "/d", + "/d/dst", + ["a.txt"], + "/d/a.txt", + "/d/dst/a.txt", + ), + ) + const rootMoves = moveCalls.filter((c) => c.method === "MOVE") + assert.equal( + rootMoves.length, + 1, + `应恰好发出 1 次 MOVE 请求,实际 ${rootMoves.length}`, + ) + const srcPath = decodeURIComponent(new URL(rootMoves[0].url).pathname) + assert.equal( + srcPath, + "/root/d/a.txt", + `MOVE 请求 URL 应为 /<源项路径> = /root/d/a.txt,实际 ${srcPath}`, + ) + const destination = rootMoves[0].headers.get("destination") + assert.ok(destination, "MOVE 必须携带 Destination 头") + const destPath = decodeURIComponent(new URL(destination).pathname) + assert.equal( + destPath, + "/root/d/dst/a.txt", + `Destination 应为 /<目标项路径> = /root/d/dst/a.txt,实际 ${destPath}`, + ) + assert.ok( + !srcPath.includes("/root/root/") && !destPath.includes("/root/root/"), + "root 前缀不得被拼两次", + ) +}) diff --git a/src/backend/drivers/yandex/yandex_remove.test.ts b/src/backend/drivers/yandex/yandex_remove.test.ts new file mode 100644 index 00000000..12d2ce1c --- /dev/null +++ b/src/backend/drivers/yandex/yandex_remove.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { YandexDriver } from "./driver" + +/** + * 调用约定(src/backend/internal/op/storage.ts 的 removeItems/moveItems): + * + * const resolved = await resolvePath(`${dir}/${name}`) + * await driver.remove(virtualPath, resolved.physical, [name]) + * + * physicalPath/srcPhys/dstPhys 是【目标项自身】的物理路径(已含文件名), + * 调用方恒传单元素 names。driver 不得再把 name 拼到该路径后面,否则 + * `?path=/` 指向不存在的对象 → DELETE 404 报错, + * move 的 from/path 源/目标错位。 + * + * 注意:路径在 query 参数里(不在 pathname),断言必须读 searchParams。 + */ + +type Call = { method: string; url: string } + +/** 记录请求,恒返回 2xx + application/json: + * 401 会触发 refreshToken() 联网,content-type 决定 util 的解析分支 */ +function mockYandex(calls: Call[]) { + return async (input: any, init: any = {}) => { + const url = String(typeof input === "string" ? input : input.url) + calls.push({ + method: String(init.method || "GET").toUpperCase(), + url, + }) + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }) + } +} + +function withMock(calls: Call[], fn: () => Promise): Promise { + const original = globalThis.fetch + globalThis.fetch = mockYandex(calls) as any + return fn().finally(() => { + globalThis.fetch = original + }) +} + +function makeDriver() { + // 测试不调用 init(),client 的 accessToken 为空;只要 mock 返回 2xx 就不会 + // 触发 401 → refreshToken() 联网。 + return new YandexDriver({ + refresh_token: "r", + use_online_api: true, + }) +} + +test("Yandex remove() 的 path query 为项自身路径,不再拼一次 name", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + makeDriver().remove("/d/a.txt", "/d/a.txt", ["a.txt"]), + ) + + const deletes = calls.filter((c) => c.method === "DELETE") + assert.equal( + deletes.length, + 1, + `应恰好发出 1 次 DELETE 请求,实际 ${deletes.length}`, + ) + const url = new URL(deletes[0].url) + assert.equal( + url.origin + url.pathname, + "https://cloud-api.yandex.net/v1/disk/resources", + `DELETE 应打到 /v1/disk/resources,实际 ${url.origin}${url.pathname}`, + ) + const path = url.searchParams.get("path") + assert.equal( + path, + "/d/a.txt", + `path query 应为项自身路径 /d/a.txt,实际 ${path}`, + ) + assert.ok( + !String(path).endsWith("/a.txt/a.txt"), + "path query 不得出现二次拼接的 /", + ) + assert.ok( + calls.every((c) => !c.url.includes("api.oplist.org")), + "不应触发在线 renewapi 的 token 刷新请求", + ) +}) + +test("Yandex move() 的 from/path query 分别为源/目标项自身路径", async () => { + const calls: Call[] = [] + + await withMock(calls, () => + makeDriver().move("/d", "/d/dst", ["a.txt"], "/d/a.txt", "/d/dst/a.txt"), + ) + + const moves = calls.filter((c) => c.method === "POST") + assert.equal( + moves.length, + 1, + `应恰好发出 1 次 POST move 请求,实际 ${moves.length}`, + ) + const url = new URL(moves[0].url) + assert.equal( + url.origin + url.pathname, + "https://cloud-api.yandex.net/v1/disk/resources/move", + `move 应打到 /v1/disk/resources/move,实际 ${url.origin}${url.pathname}`, + ) + const from = url.searchParams.get("from") + const path = url.searchParams.get("path") + assert.equal( + from, + "/d/a.txt", + `from query 应为源项自身路径 /d/a.txt,实际 ${from}`, + ) + assert.equal( + path, + "/d/dst/a.txt", + `path query 应为目标项自身路径 /d/dst/a.txt,实际 ${path}`, + ) + assert.ok( + !String(from).endsWith("/a.txt/a.txt"), + "from query 不得出现二次拼接的 /", + ) + assert.ok( + !String(path).endsWith("/a.txt/a.txt"), + "path query 不得出现二次拼接的 /", + ) + assert.ok( + calls.every((c) => !c.url.includes("api.oplist.org")), + "不应触发在线 renewapi 的 token 刷新请求", + ) +})