diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index 1a26fb5a..f5b24c47 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -204,18 +204,22 @@ export class Yun139Driver implements StorageDriver { names: string[], ): Promise { const clean = this.cleanPath(physicalPath) - const catalogId = await this.resolveCatalogId(clean) + // physicalPath 是目标项自身的路径(op/storage.ts removeItems 逐项调用), + // 目标名取参数末段、父目录取去掉末段后的部分;把项路径当目录解析会走进 + // 目标项内部(目录项尤其明显),相当于找 /,永远落空并静默 + // 返回成功。 + 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 20e1c9fc..1aa35068 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,11 @@ export class AliasDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const targets = this.getTargetsForPath(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") await removeItems(targets[0].targetFullPath, names) @@ -244,8 +254,13 @@ export class AliasDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcTargets = this.getTargetsForPath(srcPhys) - const dstTargets = this.getTargetsForPath(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) { throw new Error("[Alias] cannot resolve source or destination path") } @@ -264,8 +279,11 @@ 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 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) { 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..405a3938 100644 --- a/src/backend/drivers/azure_blob/driver.ts +++ b/src/backend/drivers/azure_blob/driver.ts @@ -302,12 +302,12 @@ 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) - await this.copyBlob(srcKey, dstKey) - await this.deleteBlob(srcKey) - } + // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 不得再拼 name,否则会指向 `/` 这种不存在的 blob。 + const srcKey = this.getKey(srcPhys) + const dstKey = this.getKey(dstPhys) + await this.copyBlob(srcKey, dstKey) + await this.deleteBlob(srcKey) } async copy( @@ -317,11 +317,10 @@ 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) - await this.copyBlob(srcKey, dstKey) - } + // 同 move:srcPhys/dstPhys 已是目标项自身路径,不得再拼 name。 + 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/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..4aefb63f 100644 --- a/src/backend/drivers/dropbox/driver.ts +++ b/src/backend/drivers/dropbox/driver.ts @@ -127,11 +127,10 @@ 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}` - await this.client.delete(target) - } + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 直接删除它即可;不得再拼 name,否则指向 `/`,delete 404 报错。 + const target = this.cleanPath(physicalPath) + await this.client.delete(target) } async move( @@ -141,13 +140,11 @@ 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}` - await this.client.move(from, to) - } + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 直接作为 from/to 使用;不得再拼 name,否则指向 `/`,源/目标错位。 + const from = this.cleanPath(srcPhys) + const to = this.cleanPath(dstPhys) + await this.client.move(from, to) } async copy( @@ -157,13 +154,11 @@ 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}` - await this.client.copy(from, to) - } + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 直接作为 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/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/driver.ts b/src/backend/drivers/ftp/driver.ts index 373d6dec..71e078f6 100644 --- a/src/backend/drivers/ftp/driver.ts +++ b/src/backend/drivers/ftp/driver.ts @@ -140,34 +140,24 @@ 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,否则指向不存在的 /,导致静默删除失败。 + const target = cleanPosixPath(physicalPath) + 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,否则源/目标路径错位。 + const src = cleanPosixPath(srcPhys) + const dst = cleanPosixPath(dstPhys) + await this.client.rename(src, dst) } async copy( 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/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/local.ts b/src/backend/drivers/local.ts index e6bf8cb9..2bffdb9b 100644 --- a/src/backend/drivers/local.ts +++ b/src/backend/drivers/local.ts @@ -101,10 +101,10 @@ 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) - await fs.rm(itemPath, { recursive: true, force: true }) - } + // physicalPath 是目标项自身的路径(op/storage.ts removeItems 逐项调用), + // 直接删除即可;不得再拼 name,否则指向 /,配合 force:true 会 + // 静默"成功"但什么都没删。 + await fs.rm(physicalPath, { recursive: true, force: true }) } async move( @@ -117,12 +117,10 @@ 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) - await fs.mkdir(path.dirname(dst), { recursive: true }) - await fs.rename(src, dst) - } + // srcPhys/dstPhys 是源/目标项自身的路径(已含 name),直接移动即可; + // 不得再拼 name,否则指向不存在的 / 而 ENOENT。 + await fs.mkdir(path.dirname(dstPhys), { recursive: true }) + await fs.rename(srcPhys, dstPhys) } async copy( @@ -135,12 +133,9 @@ 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) - await fs.mkdir(path.dirname(dst), { recursive: true }) - await fs.cp(src, 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/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", + "目标位置应有副本", + ) +}) diff --git a/src/backend/drivers/mediatrack/driver.ts b/src/backend/drivers/mediatrack/driver.ts index 398a35c3..c1188086 100644 --- a/src/backend/drivers/mediatrack/driver.ts +++ b/src/backend/drivers/mediatrack/driver.ts @@ -202,18 +202,17 @@ export class MediatrackDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const parentId = await this.resolveParentId(physicalPath) + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 目标名取参数末段、父目录取去掉末段后的部分;resolveParentId 解析的是 + // 传入路径自身的 id,把项路径当父目录会去 的子项里按 title 查找 + //(相当于找 /),找不到时静默跳过,对象仍然存在。 + const clean = this.cleanPath(physicalPath) + 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 name of names) { - const match = files.find((f) => f.title === name) - 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", @@ -221,15 +220,12 @@ export class MediatrackDriver implements StorageDriver { method: "DELETE", body: { origin_id: parentId, - ids, + ids: [match.id], }, }, ) - const clean = this.cleanPath(physicalPath) - for (const name of names) { - this.idCache.delete(clean ? `${clean}/${name}` : `/${name}`) - } + this.idCache.delete(clean) } async move( @@ -239,19 +235,21 @@ 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 找,目标侧解析目标项所在 + // 目录的 id。把项路径当父目录解析会指到项本身:源侧永远落空, + // 目标侧解析一个尚不存在的目标项必然报错。 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + 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 name of names) { - const match = srcFiles.find((f) => f.title === name) - 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", @@ -259,7 +257,7 @@ export class MediatrackDriver implements StorageDriver { method: "POST", body: { parent_id: dstParentId, - ids, + ids: [match.id], }, }, ) @@ -272,19 +270,18 @@ export class MediatrackDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcParentId = await this.resolveParentId(srcPhys) - const dstParentId = await this.resolveParentId(dstPhys) + // 与 move 同理:源/目标项自身路径先去掉末段得到父目录,目标名取源路径末段。 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + 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 name of names) { - const match = srcFiles.find((f) => f.title === name) - 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", @@ -292,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 7d6b2fa1..3e232450 100644 --- a/src/backend/drivers/mega/driver.ts +++ b/src/backend/drivers/mega/driver.ts @@ -152,20 +152,18 @@ export class MegaDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstNode = this.resolveNodeByPath(this.cleanPath(dstPhys)) + // 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") } - - for (const name of names) { - const srcItemPath = - this.cleanPath(srcPhys) === "/" - ? `/${name}` - : `${this.cleanPath(srcPhys)}/${name}` - const srcNode = this.resolveNodeByPath(srcItemPath) - if (srcNode) { - await this.client.moveNode(srcNode.id, dstNode.id) - } + const srcNode = this.resolveNodeByPath(srcPath) + if (srcNode) { + await this.client.moveNode(srcNode.id, dstNode.id) } } @@ -186,15 +184,12 @@ 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) - if (node) { - await this.client.deleteNode(node.id) - } + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 参数即目标项路径,不得再拼 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 6871f656..8b2b280d 100644 --- a/src/backend/drivers/mopan/driver.ts +++ b/src/backend/drivers/mopan/driver.ts @@ -275,12 +275,14 @@ 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}` - const srcItem = await this.get(srcDir, srcItemPath) - await this.performBatchTask(srcItem, dstFolderId, TaskTypeMove) - } + // 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) + + await this.performBatchTask(srcItem, dstFolderId, TaskTypeMove) } async copy( @@ -290,12 +292,12 @@ 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}` - const srcItem = await this.get(srcDir, srcItemPath) - await this.performBatchTask(srcItem, dstFolderId, TaskTypeCopy) - } + // 同 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) + + 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 1f648844..62898ba6 100644 --- a/src/backend/drivers/onedrive/driver.ts +++ b/src/backend/drivers/onedrive/driver.ts @@ -222,12 +222,10 @@ 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) - await requestApi(this, url, "DELETE") - } + // physicalPath 是目标项自身的物理路径(op/storage.ts 逐项调用), + // 参数即目标项路径,不得再拼 name,否则指向 /,DELETE 404 导致对象仍然存在。 + const url = this.getMetaUrl(false, physicalPath) + await requestApi(this, url, "DELETE") } async move( @@ -237,25 +235,25 @@ 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,参数即目标项路径, + // 不得再拼 name,否则指向 /): + // 目标父目录取 dstPhys 去掉末段(直接 GET 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 - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name, - } - const url = this.getMetaUrl(false, srcItemPath) - 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( @@ -265,23 +263,23 @@ export class Onedrive implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstUrl = this.getMetaUrl(false, dstPhys) + // 与 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 - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name, - } - const url = this.getMetaUrl(false, srcItemPath, "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/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/onedrive_app/driver.ts b/src/backend/drivers/onedrive_app/driver.ts index 9b9b37f0..db27e8d9 100644 --- a/src/backend/drivers/onedrive_app/driver.ts +++ b/src/backend/drivers/onedrive_app/driver.ts @@ -161,12 +161,10 @@ 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) - await requestApi(this, url, "DELETE") - } + // physicalPath 是目标项自身的物理路径(op/storage.ts 逐项调用), + // 参数即目标项路径,不得再拼 name,否则指向 /,DELETE 404 导致对象仍然存在。 + const url = getMetaUrl(this, false, physicalPath) + await requestApi(this, url, "DELETE") } async move( @@ -176,23 +174,25 @@ export class OnedriveAPP implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstUrl = getMetaUrl(this, false, dstPhys) + // srcPhys/dstPhys 是源/目标项自身的物理路径(已含 name,参数即目标项路径, + // 不得再拼 name,否则指向 /): + // 目标父目录取 dstPhys 去掉末段(直接 GET 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 - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name, - } - const url = getMetaUrl(this, false, srcItemPath) - 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( @@ -202,23 +202,23 @@ export class OnedriveAPP implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstUrl = getMetaUrl(this, false, dstPhys) + // 与 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 - for (const name of names) { - const srcItemPath = srcPhys === "/" ? `/${name}` : `${srcPhys}/${name}` - const data = { - parentReference: { - id: dstId, - ...(driveId ? { driveId } : {}), - }, - name, - } - const url = getMetaUrl(this, false, srcItemPath, "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 40e09df4..ddd26e8d 100644 --- a/src/backend/drivers/pikpak/driver.ts +++ b/src/backend/drivers/pikpak/driver.ts @@ -257,33 +257,28 @@ export class PikPakDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { - const parentId = await this.resolveParentId(physicalPath) + // physicalPath 是目标项自身的物理路径(参数即目标项路径,不得再拼 name, + // 否则指向 /);父目录取 physicalPath 去掉末段后的部分, + // 目标名取其末段,在父目录子项中按名查找对应 id。 + const clean = this.cleanPath(physicalPath) + 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 name of names) { - const match = files.find((f) => f.name === name) - 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], }, }, ) - const clean = this.cleanPath(physicalPath) - for (const name of names) { - this.idCache.delete(clean ? `${clean}/${name}` : `/${name}`) - } + this.idCache.delete(clean) } async move( @@ -293,26 +288,28 @@ export class PikPakDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcParentId = await this.resolveParentId(srcPhys) - const dstParentId = await this.resolveParentId(dstPhys) - const srcFiles = await this.getFiles(srcParentId) - const ids: string[] = [] - - for (const name of names) { - const match = srcFiles.find((f) => f.name === name) - if (match) { - ids.push(match.id) - } - } + // srcPhys/dstPhys 是源/目标项自身的物理路径(参数即目标项路径,不得再拼 name): + // 直接当父目录解析会指到项本身——源侧会去 的子项里找 name(永远落空), + // 目标侧解析一个尚不存在的目标项必然报错。 + // 故两侧父目录均去掉末段,源名取 srcPhys 末段,在源父目录子项中按名查 id。 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + const srcName = srcClean.split("/").pop() || "" + const srcParentPath = srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = dstClean.split("/").slice(0, -1).join("/") - if (ids.length === 0) return + const srcParentId = await this.resolveParentId(srcParentPath) + const dstParentId = await this.resolveParentId(dstParentPath) + const srcFiles = await this.getFiles(srcParentId) + 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, }, @@ -320,10 +317,7 @@ export class PikPakDriver implements StorageDriver { }, ) - const srcClean = this.cleanPath(srcPhys) - for (const name of names) { - this.idCache.delete(srcClean ? `${srcClean}/${name}` : `/${name}`) - } + this.idCache.delete(srcClean) } async copy( @@ -333,26 +327,26 @@ export class PikPakDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const srcParentId = await this.resolveParentId(srcPhys) - const dstParentId = await this.resolveParentId(dstPhys) - const srcFiles = await this.getFiles(srcParentId) - const ids: string[] = [] - - for (const name of names) { - const match = srcFiles.find((f) => f.name === name) - if (match) { - ids.push(match.id) - } - } + // 与 move 同理:参数是源/目标项自身路径,不得再拼 name; + // 两侧父目录均去掉末段,源名取 srcPhys 末段,在源父目录子项中按名查 id。 + const srcClean = this.cleanPath(srcPhys) + const dstClean = this.cleanPath(dstPhys) + const srcName = srcClean.split("/").pop() || "" + const srcParentPath = srcClean.split("/").slice(0, -1).join("/") + const dstParentPath = dstClean.split("/").slice(0, -1).join("/") - if (ids.length === 0) return + const srcParentId = await this.resolveParentId(srcParentPath) + const dstParentId = await this.resolveParentId(dstParentPath) + const srcFiles = await this.getFiles(srcParentId) + 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 b2eb6304..b6afeb40 100644 --- a/src/backend/drivers/s3/driver.ts +++ b/src/backend/drivers/s3/driver.ts @@ -237,20 +237,18 @@ export class S3Driver implements StorageDriver { dstPhys: string, ): Promise { await this.checkDogeToken() - const srcBase = this.getRemotePath(srcPhys) - const dstBase = this.getRemotePath(dstPhys) - - for (const name of names) { - const srcPath = joinPath(srcBase, name) - const dstPath = joinPath(dstBase, name) - 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) } } @@ -262,18 +260,15 @@ export class S3Driver implements StorageDriver { dstPhys: string, ): Promise { await this.checkDogeToken() - const srcBase = this.getRemotePath(srcPhys) - const dstBase = this.getRemotePath(dstPhys) - - for (const name of names) { - const srcPath = joinPath(srcBase, name) - const dstPath = joinPath(dstBase, name) - 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) } } @@ -297,25 +292,16 @@ 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) - } - } + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 参数即目标项路径,直接删除它即可;若再拼一次 name 会指向 `/`, + // HEAD 404 后退化成按目录递归删除,prefix 同样查不到对象,最终静默返回成功但对象仍在。 + const targetPath = this.getRemotePath(physicalPath) + + const head = await this.client.headObject(targetPath) + if (head) { + await this.client.deleteObject(targetPath) } else { - const head = await this.client.headObject(basePath) - if (head) { - await this.client.deleteObject(basePath) - } 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/drivers/seafile/driver.ts b/src/backend/drivers/seafile/driver.ts index 7a432991..d17e73e4 100644 --- a/src/backend/drivers/seafile/driver.ts +++ b/src/backend/drivers/seafile/driver.ts @@ -264,17 +264,18 @@ export class SeafileDriver implements StorageDriver { physicalPath: string, names: string[], ): Promise { + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // resolveRepoAndPath 已解析到目标项,参数即目标项路径,不得再拼 name,否则指向 + // `/`,DELETE 到不存在的路径,接口成功但文件仍在。 const { repoId, innerPath } = await this.resolveRepoAndPath(physicalPath) - for (const name of names) { - const targetPath = innerPath === "/" ? `/${name}` : `${innerPath}/${name}` - 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( @@ -284,26 +285,27 @@ export class SeafileDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { + // 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 dstDirPath = + dst.innerPath.substring(0, dst.innerPath.lastIndexOf("/")) || "/" - for (const name of names) { - const targetPath = - src.innerPath === "/" ? `/${name}` : `${src.innerPath}/${name}` - await this.client.request( - `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, - { - method: "POST", - isFormData: true, - params: { p: targetPath }, - body: { - operation: "move", - dst_repo: dst.repoId, - dst_dir: dst.innerPath, - }, + 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( @@ -313,26 +315,27 @@ export class SeafileDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { + // 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 dstDirPath = + dst.innerPath.substring(0, dst.innerPath.lastIndexOf("/")) || "/" - for (const name of names) { - const targetPath = - src.innerPath === "/" ? `/${name}` : `${src.innerPath}/${name}` - await this.client.request( - `/api2/repos/${encodeURIComponent(src.repoId)}/file/`, - { - method: "POST", - isFormData: true, - params: { p: targetPath }, - body: { - operation: "copy", - dst_repo: dst.repoId, - dst_dir: dst.innerPath, - }, + 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 7eefe8db..acb840f8 100644 --- a/src/backend/drivers/sftp/driver.ts +++ b/src/backend/drivers/sftp/driver.ts @@ -192,34 +192,21 @@ 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,否则指向 / 导致静默删除失败。 + await this.client.removeRecursive(cleanPosixPath(physicalPath)) } 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,否则源/目标会错位到 /。 + await this.client.rename(cleanPosixPath(srcPhys), cleanPosixPath(dstPhys)) } async copy( 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/terabox/driver.ts b/src/backend/drivers/terabox/driver.ts index 0f1cef51..e6a4fe3c 100644 --- a/src/backend/drivers/terabox/driver.ts +++ b/src/backend/drivers/terabox/driver.ts @@ -200,9 +200,10 @@ 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}`)) - await this.client.manage("delete", paths) + await this.client.manage("delete", [clean]) } async move( @@ -214,14 +215,15 @@ 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, - })) - - await this.client.manage("move", fileList) + // srcPhys/dstPhys 是目标项自身的路径:path 不得再拼 name; + // filemanager 的 dest 是目标目录,故取 dstPhys 去掉末段后的父目录,newname 取末段。 + await this.client.manage("move", [ + { + path: srcClean, + dest: dstClean.split("/").slice(0, -1).join("/") || "/", + newname: dstClean.split("/").pop() || "", + }, + ]) } async copy( @@ -233,14 +235,15 @@ 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, - })) - - 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 c12f4f9c..914ecf04 100644 --- a/src/backend/drivers/uss/driver.ts +++ b/src/backend/drivers/uss/driver.ts @@ -180,11 +180,9 @@ 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) - await this.moveOrCopy(srcKey, dstKey, "move") - } + // srcPhys/dstPhys 是目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 参数即项路径,不得再拼 name,否则会指向 / 导致移动失败。 + await this.moveOrCopy(this.getKey(srcPhys), this.getKey(dstPhys), "move") } async copy( @@ -194,11 +192,8 @@ 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) - 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 3add883f..f4d98438 100644 --- a/src/backend/drivers/webdav/driver.ts +++ b/src/backend/drivers/webdav/driver.ts @@ -105,13 +105,13 @@ export class WebdavDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - 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) - await this.client.move(srcPath, dstPath, true) - } + // srcPhys/dstPhys 已是源/目标项自身的路径,参数即项路径,直接移动即可; + // 再拼一次 name 会指向不存在的 /,MOVE 源/目标错位。 + await this.client.move( + this.getRemotePath(srcPhys), + this.getRemotePath(dstPhys), + true, + ) } async copy( @@ -121,13 +121,13 @@ export class WebdavDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - 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) - await this.client.copy(srcPath, dstPath, true) - } + // srcPhys/dstPhys 已是源/目标项自身的路径,参数即项路径,直接复制即可; + // 再拼一次 name 会让 COPY 源/目标路径错位。 + await this.client.copy( + this.getRemotePath(srcPhys), + this.getRemotePath(dstPhys), + true, + ) } async remove( @@ -135,15 +135,10 @@ 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 视作成功, + // 结果接口静默返回成功但对象仍在。 + await this.client.remove(this.getRemotePath(physicalPath)) } async put( 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/wps/driver.ts b/src/backend/drivers/wps/driver.ts index 29dd1619..0ad78367 100644 --- a/src/backend/drivers/wps/driver.ts +++ b/src/backend/drivers/wps/driver.ts @@ -217,25 +217,24 @@ export class WpsDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstNode = await this.resolvePath(this.cleanPath(dstPhys)) + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 参数即项路径:源直接用 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") } - - for (const name of names) { - const srcItemPath = - this.cleanPath(srcPhys) === "/" - ? `/${name}` - : `${this.cleanPath(srcPhys)}/${name}` - const srcNode = await this.resolvePath(srcItemPath) - if (srcNode) { - await this.client.move( - srcNode.groupId, - srcNode.fileId, - dstNode.groupId, - dstNode.fileId, - ) - } + const srcNode = await this.resolvePath(srcPath) + if (srcNode) { + await this.client.move( + srcNode.groupId, + srcNode.fileId, + dstNode.groupId, + dstNode.fileId, + ) } } @@ -259,25 +258,24 @@ export class WpsDriver implements StorageDriver { srcPhys: string, dstPhys: string, ): Promise { - const dstNode = await this.resolvePath(this.cleanPath(dstPhys)) + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 参数即项路径:源直接用 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") } - - for (const name of names) { - const srcItemPath = - this.cleanPath(srcPhys) === "/" - ? `/${name}` - : `${this.cleanPath(srcPhys)}/${name}` - const srcNode = await this.resolvePath(srcItemPath) - if (srcNode) { - await this.client.copy( - srcNode.groupId, - srcNode.fileId, - dstNode.groupId, - dstNode.fileId, - ) - } + const srcNode = await this.resolvePath(srcPath) + if (srcNode) { + await this.client.copy( + srcNode.groupId, + srcNode.fileId, + dstNode.groupId, + dstNode.fileId, + ) } } @@ -286,15 +284,12 @@ 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) - if (node && node.kind !== "root" && node.kind !== "group") { - await this.client.delete(node.groupId, node.fileId) - } + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 参数即项路径,直接解析它即可;再拼 name 会指向 /,resolvePath 返回 null + // 后被静默跳过,接口成功但文件仍在。 + 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 37f5abcd..74685710 100644 --- a/src/backend/drivers/yandex/driver.ts +++ b/src/backend/drivers/yandex/driver.ts @@ -136,14 +136,12 @@ 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}` - await this.client.request("", { - method: "DELETE", - params: { path: targetPath }, - }) - } + // physicalPath 是目标项自身的物理路径(op/storage.ts removeItems 逐项调用), + // 参数即项路径,直接删除它即可;再拼 name 会指向 /,DELETE 404 报错。 + await this.client.request("", { + method: "DELETE", + params: { path: this.cleanPath(physicalPath) }, + }) } async move( @@ -153,21 +151,16 @@ 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}` - await this.client.request("/move", { - method: "POST", - params: { - from: fromPath, - path: toPath, - overwrite: "true", - }, - }) - } + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts moveItems 逐项调用), + // 参数即项路径,直接作为 from/path 使用;再拼 name 会指向 /,源/目标错位。 + await this.client.request("/move", { + method: "POST", + params: { + from: this.cleanPath(srcPhys), + path: this.cleanPath(dstPhys), + overwrite: "true", + }, + }) } async copy( @@ -177,21 +170,16 @@ 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}` - await this.client.request("/copy", { - method: "POST", - params: { - from: fromPath, - path: toPath, - overwrite: "true", - }, - }) - } + // srcPhys/dstPhys 已是源/目标项自身的物理路径(op/storage.ts copyItems 逐项调用), + // 参数即项路径,直接作为 from/path 使用;再拼 name 会指向 /,源/目标错位。 + await this.client.request("/copy", { + method: "POST", + params: { + from: this.cleanPath(srcPhys), + path: this.cleanPath(dstPhys), + overwrite: "true", + }, + }) } async put( 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 刷新请求", + ) +}) diff --git a/src/backend/internal/model/db.ts b/src/backend/internal/model/db.ts index a1814a6f..4c3ca665 100644 --- a/src/backend/internal/model/db.ts +++ b/src/backend/internal/model/db.ts @@ -41,6 +41,23 @@ 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" +/** + * 分享摘要/「复制链接」默认模板。 + * + * 前端「复制链接」= 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 = "{{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 = { settings: [ @@ -509,7 +526,7 @@ export const defaultDb = { }, { key: "share_summary_content", - value: "", + value: DEFAULT_SHARE_SUMMARY_CONTENT, type: "text", help: "Share Summary Content", group: 4, @@ -944,6 +961,12 @@ const LEGACY_SETTING_MIGRATIONS: Record = { from: [LEGACY_TEXT_TYPES], to: DEFAULT_TEXT_TYPES, }, + // 「复制链接」:早期 seed 为空串(复制出空白),上一版一度采用上游 Go 的 + // 整段分享文案(复制出一堆非链接内容)。两种已写入的值都迁移到纯分享 URL。 + share_summary_content: { + from: ["", LEGACY_SHARE_SUMMARY_GO], + 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..0b41523f 100644 --- a/src/backend/server/public.ts +++ b/src/backend/server/public.ts @@ -398,6 +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", + // 「复制链接」渲染的模板。缺失或为空时会渲染成空串, + // `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 aee244a0..3b28aa63 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,68 @@ 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.equal( + tpl, + "{{base_url}}/@s/{{id}}", + 'the "copy link" button must render a bare share URL, not a share blurb', + ) +} + +/** + * 上一版曾采用的上游 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", + JWT_SECRET: "test-secret-for-share-summary", + ENCRYPTION_SECRET: "test-secret-for-share-summary", + } + await saveDb( + { + 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) + return String( + db.settings.find((s: any) => s.key === "share_summary_content")?.value || + "", + ) +} + +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 () => { + await seed([]) + const json = await fetchSettings() + assertShareTemplateUsable(String(json.data.share_summary_content || "")) +})