From 20b544b90e6d34faedf7627ed08838977be4665a Mon Sep 17 00:00:00 2001 From: lenmei233 <1663996167@qq.com> Date: Mon, 5 Oct 2026 20:14:19 +0800 Subject: [PATCH] feat(drivers): add GitHub Releases and CNB Releases drivers - Normalize driver names to support both "GitHub Releases" and "CNB Releases". - Add driver configurations for GitHub Releases and CNB Releases, including required fields and settings. - Add unit tests for both drivers and integration coverage for admin registration and storage creation. - Preserve compatibility with existing driver info retrieval, supporting exact and Go-style driver names. Co-Authored-By: DeepSeek --- .../drivers/cnb_releases/driver.test.ts | 161 +++++++ src/backend/drivers/cnb_releases/driver.ts | 57 ++- src/backend/drivers/cnb_releases/util.ts | 43 +- .../drivers/github_releases/driver.test.ts | 353 +++++++++++++++ src/backend/drivers/github_releases/driver.ts | 25 +- src/backend/drivers/github_releases/types.ts | 8 +- src/backend/drivers/github_releases/util.ts | 413 +++++++++++------- src/backend/server/admin.ts | 148 ++++++- src/backend/server/driver_list.test.ts | 230 ++++++++++ 9 files changed, 1237 insertions(+), 201 deletions(-) create mode 100644 src/backend/drivers/cnb_releases/driver.test.ts create mode 100644 src/backend/drivers/github_releases/driver.test.ts create mode 100644 src/backend/server/driver_list.test.ts diff --git a/src/backend/drivers/cnb_releases/driver.test.ts b/src/backend/drivers/cnb_releases/driver.test.ts new file mode 100644 index 00000000..1118750e --- /dev/null +++ b/src/backend/drivers/cnb_releases/driver.test.ts @@ -0,0 +1,161 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { DriverCnbReleases } from "./driver" + +/** + * CNB Releases 驱动回归测试:API 按 release/asset ID 操作,而 TS 链路传路径名, + * 必须先解析出 ID(旧实现直接把路径段当 ID,进入 release 目录会 404)。 + */ + +const RELEASE = { + id: "rel-1", + name: "v1.0.0", + tag_name: "v1.0.0", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + assets: [ + { + id: "asset-1", + name: "file.zip", + size: 5, + path: "/org/repo/-/releases/download/file.zip", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }, + ], +} + +const ADDITION = { repo: "org/repo", token: "tkn" } + +function installFetch(): { calls: string[]; restore: () => void } { + const original = globalThis.fetch + const calls: string[] = [] + globalThis.fetch = (async (input: any, init?: any) => { + const url = String(input) + calls.push(`${init?.method || "GET"} ${url}`) + if (url.includes("/org/repo/-/releases") && url.includes("/assets/")) { + return new Response(null, { status: 204 }) + } + if (url.endsWith("/org/repo/-/releases")) { + return new Response(JSON.stringify([RELEASE]), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (url.includes("/org/repo/-/releases/")) { + return new Response(JSON.stringify(RELEASE), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response("", { status: 404 }) + }) as typeof fetch + return { + calls, + restore: () => { + globalThis.fetch = original + }, + } +} + +test("CNB Releases 驱动 init 校验 repo/token", async () => { + await assert.rejects( + () => new DriverCnbReleases({ repo: "", token: "" }).init(), + /repo is required/, + ) + await assert.rejects( + () => new DriverCnbReleases({ repo: "org/repo", token: "" }).init(), + /token is required/, + ) +}) + +test("根目录列出 releases(目录 sign 为 release ID)", async () => { + const { restore } = installFetch() + try { + const driver = new DriverCnbReleases(ADDITION) + const items = await driver.list("", "/") + assert.equal(items.length, 1) + assert.equal(items[0].name, "v1.0.0") + assert.equal(items[0].is_dir, true) + assert.equal(items[0].sign, "rel-1") + assert.equal(items[0].size, 5) + } finally { + restore() + } +}) + +test("按 release 名称进入目录并列出资产", async () => { + const { restore } = installFetch() + try { + const driver = new DriverCnbReleases(ADDITION) + const items = await driver.list("/v1.0.0", "/v1.0.0") + assert.equal(items.length, 1) + assert.equal(items[0].name, "file.zip") + assert.equal(items[0].is_dir, false) + assert.equal( + items[0].raw_url, + "https://cnb.cool/org/repo/-/releases/download/file.zip", + ) + } finally { + restore() + } +}) + +test("get:release 目录与资产文件都能按名称解析", async () => { + const { restore } = installFetch() + try { + const driver = new DriverCnbReleases(ADDITION) + + const dir = await driver.get("/v1.0.0", "/v1.0.0") + assert.equal(dir.is_dir, true) + assert.equal(dir.name, "v1.0.0") + + const file = await driver.get("/v1.0.0/file.zip", "/v1.0.0/file.zip") + assert.equal(file.is_dir, false) + assert.equal(file.name, "file.zip") + assert.equal( + file.raw_url, + "https://cnb.cool/org/repo/-/releases/download/file.zip", + ) + } finally { + restore() + } +}) + +test("remove:按名称解析出 release/asset ID 后再调用删除接口", async () => { + const { calls, restore } = installFetch() + try { + const driver = new DriverCnbReleases(ADDITION) + await driver.remove("", "/v1.0.0/file.zip", ["file.zip"]) + assert.ok( + calls.some( + (c) => + c.startsWith("DELETE") && + c.includes("/org/repo/-/releases/rel-1/assets/asset-1"), + ), + `未按 ID 调用删除接口: ${JSON.stringify(calls)}`, + ) + } finally { + restore() + } +}) + +test("use_tag_name 时目录名使用 tag 且不允许重命名", async () => { + const { restore } = installFetch() + try { + const driver = new DriverCnbReleases({ ...ADDITION, use_tag_name: true }) + const items = await driver.list("", "/") + assert.equal(items[0].name, "v1.0.0") + await assert.rejects( + () => driver.rename("", "/v1.0.0", "new"), + /only release name can be renamed/, + ) + } finally { + restore() + } +}) + +test("无状态环境下 put 直接报错", async () => { + const driver = new DriverCnbReleases(ADDITION) + await assert.rejects(() => driver.put(), /asset upload not supported/) +}) diff --git a/src/backend/drivers/cnb_releases/driver.ts b/src/backend/drivers/cnb_releases/driver.ts index 50c79511..77a4a97c 100644 --- a/src/backend/drivers/cnb_releases/driver.ts +++ b/src/backend/drivers/cnb_releases/driver.ts @@ -34,9 +34,9 @@ export class DriverCnbReleases implements StorageDriver { raw_url: "", })) } - // release 目录:assets - const releaseId = clean.split("/")[0] - const release = await this.client.getRelease(releaseId) + // release 目录:先按名称解析出 ID + const release = await this.client.findRelease(clean.split("/")[0]) + if (!release) return [] return release.assets.map((a) => ({ name: a.name, size: a.size, @@ -55,29 +55,33 @@ export class DriverCnbReleases implements StorageDriver { // release 目录 if (parts.length === 1) { - try { - const release = await this.client.getRelease(parts[0]) + const release = await this.client.findRelease(parts[0]) + if (release) { return { name: this.client.releaseName(release), size: release.assets.reduce((s, a) => s + a.size, 0), is_dir: true, - modified: release.updated_at || release.created_at || new Date().toISOString(), + modified: + release.updated_at || + release.created_at || + new Date().toISOString(), sign: release.id, type: 1, raw_url: "", } - } catch {} + } } // asset 文件 if (parts.length === 2) { - const release = await this.client.getRelease(parts[0]) - const asset = release.assets.find((a) => a.name === parts[1]) - if (asset) { + const release = await this.client.findRelease(parts[0]) + const asset = release ? this.client.findAsset(release, parts[1]) : null + if (release && asset) { return { name: asset.name, size: asset.size, is_dir: false, - modified: asset.updated_at || asset.created_at || new Date().toISOString(), + modified: + asset.updated_at || asset.created_at || new Date().toISOString(), sign: asset.id, type: calcFileType(asset.name, false), raw_url: this.client.assetDownloadUrl(asset), @@ -98,7 +102,10 @@ export class DriverCnbReleases implements StorageDriver { async mkdir(_virtualPath: string, physicalPath: string): Promise { const parts = physicalPath.split("/").filter(Boolean) if (parts.length === 1) { - await this.client.createRelease(parts[0], this.addition.default_branch || "main") + await this.client.createRelease( + parts[0], + this.addition.default_branch || "main", + ) return } throw new Error("[CNB Releases] only supports creating releases at root") @@ -111,8 +118,11 @@ export class DriverCnbReleases implements StorageDriver { ): Promise { const parts = physicalPath.split("/").filter(Boolean) if (parts.length === 1 && !this.addition.use_tag_name) { - await this.client.renameRelease(parts[0], newName) - return + const release = await this.client.findRelease(parts[0]) + if (release) { + await this.client.renameRelease(release.id, newName) + return + } } throw new Error("[CNB Releases] only release name can be renamed") } @@ -124,12 +134,19 @@ export class DriverCnbReleases implements StorageDriver { ): Promise { const parts = physicalPath.split("/").filter(Boolean) if (parts.length === 1) { - await this.client.deleteRelease(parts[0]) - return + const release = await this.client.findRelease(parts[0]) + if (release) { + await this.client.deleteRelease(release.id) + return + } } if (parts.length === 2) { - await this.client.deleteAsset(parts[0], parts[1]) - return + const release = await this.client.findRelease(parts[0]) + const asset = release ? this.client.findAsset(release, parts[1]) : null + if (release && asset) { + await this.client.deleteAsset(release.id, asset.id) + return + } } throw new Error("[CNB Releases] invalid path") } @@ -143,6 +160,8 @@ export class DriverCnbReleases implements StorageDriver { } async put(): Promise { - throw new Error("[CNB Releases] asset upload not supported in stateless environment") + throw new Error( + "[CNB Releases] asset upload not supported in stateless environment", + ) } } diff --git a/src/backend/drivers/cnb_releases/util.ts b/src/backend/drivers/cnb_releases/util.ts index dd549424..b6bb63f5 100644 --- a/src/backend/drivers/cnb_releases/util.ts +++ b/src/backend/drivers/cnb_releases/util.ts @@ -41,12 +41,10 @@ export class ClientCnbReleases { } } const resp = await fetch(`${API_BASE}${path}`, options) - if ( - resp.status !== 200 && - resp.status !== 201 && - resp.status !== 204 - ) { - throw new Error(`[CNB Releases] ${resp.status} ${await resp.text().catch(() => "")}`) + if (resp.status !== 200 && resp.status !== 201 && resp.status !== 204) { + throw new Error( + `[CNB Releases] ${resp.status} ${await resp.text().catch(() => "")}`, + ) } if (resp.status === 204) return {} as T return (await resp.json().catch(() => ({}))) as T @@ -61,6 +59,27 @@ export class ClientCnbReleases { return this.request("GET", `/${this.repo}/-/releases/${id}`) } + /** 路径里是 release 名称(TS 链路不传 Go 对象 ID),需先解析出 ID 再调 API */ + async findRelease(idOrName: string): Promise { + const releases = await this.listReleases().catch(() => [] as CNBRelease[]) + if (!Array.isArray(releases)) return null + return ( + releases.find((r) => r.id === idOrName) || + releases.find((r) => this.releaseName(r) === idOrName) || + releases.find((r) => r.tag_name === idOrName) || + null + ) + } + + /** 兼容按资产名称或 ID 查找 */ + findAsset(release: CNBRelease, idOrName: string): CNBAsset | null { + return ( + release.assets.find((a) => a.id === idOrName) || + release.assets.find((a) => a.name === idOrName) || + null + ) + } + releaseName(r: CNBRelease): string { return this.useTagName ? r.tag_name : r.name } @@ -74,7 +93,12 @@ export class ClientCnbReleases { } renameRelease(id: string, newName: string): Promise { - return this.request("PATCH", `/${this.repo}/-/releases/${id}`, { name: newName }, true) + return this.request( + "PATCH", + `/${this.repo}/-/releases/${id}`, + { name: newName }, + true, + ) } deleteRelease(id: string): Promise { @@ -82,7 +106,10 @@ export class ClientCnbReleases { } deleteAsset(releaseId: string, assetId: string): Promise { - return this.request("DELETE", `/${this.repo}/-/releases/${releaseId}/assets/${assetId}`) + return this.request( + "DELETE", + `/${this.repo}/-/releases/${releaseId}/assets/${assetId}`, + ) } assetDownloadUrl(asset: CNBAsset): string { diff --git a/src/backend/drivers/github_releases/driver.test.ts b/src/backend/drivers/github_releases/driver.test.ts new file mode 100644 index 00000000..96acf03e --- /dev/null +++ b/src/backend/drivers/github_releases/driver.test.ts @@ -0,0 +1,353 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { ClientGithubReleases } from "./util" +import { DriverGithubReleases } from "./driver" + +/** + * GitHub Releases 驱动回归测试(Issue #107):repo_structure 解析、根/嵌套挂载、 + * gh_proxy、show_all_version 目录与文件判定、per_page/max_page 分页。 + */ + +const release = (tag: string, assetName: string) => ({ + tag_name: tag, + created_at: "2026-01-01T00:00:00Z", + published_at: "2026-01-02T00:00:00Z", + html_url: `https://github.com/OpenListTeam/OpenList/releases/tag/${tag}`, + zipball_url: `https://github.com/OpenListTeam/OpenList/archive/refs/tags/${tag}.zip`, + tarball_url: `https://github.com/OpenListTeam/OpenList/archive/refs/tags/${tag}.tar.gz`, + assets: [ + { + name: assetName, + size: 1024, + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + browser_download_url: `https://github.com/OpenListTeam/OpenList/releases/download/${tag}/${assetName}`, + }, + ], +}) + +const LATEST = release("v1.0.0", "openlist-linux-amd64.tar.gz") +const ALL = [LATEST, release("v0.9.0", "openlist-v0.9.0.tar.gz")] + +const CONTENTS = [ + { + name: "README.md", + size: 10, + download_url: + "https://raw.githubusercontent.com/OpenListTeam/OpenList/main/README.md", + }, + { + name: "LICENSE", + size: 20, + download_url: + "https://raw.githubusercontent.com/OpenListTeam/OpenList/main/LICENSE", + }, + { + name: "src", + size: 0, + download_url: + "https://raw.githubusercontent.com/OpenListTeam/OpenList/main/src", + }, +] + +type Routes = Array<[string, any]> + +/** 按 URL 包含关系匹配的 fetch mock;返回恢复函数 */ +function installFetch(routes: Routes): () => void { + const original = globalThis.fetch + globalThis.fetch = (async (input: any) => { + const url = String(input) + const match = routes.find(([key]) => url.includes(key)) + if (!match) return new Response("", { status: 404 }) + return new Response(JSON.stringify(match[1]), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch + return () => { + globalThis.fetch = original + } +} + +const defaultRoutes: Routes = [ + ["/releases/latest", LATEST], + ["/releases?", ALL], + ["/contents", CONTENTS], +] + +test("repo_structure 必填,缺失时 init 报错", async () => { + const client = new ClientGithubReleases({}) + await assert.rejects(() => client.init(), /repo_structure is required/) +}) + +test("repo_structure 解析:单行 / 多行 / 分号", async () => { + const restore = installFetch(defaultRoutes) + try { + const root = new ClientGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + }) + await root.init() + + // 挂载点根目录直接展示 release 资产(对齐 Go) + const files = await root.list("/") + assert.ok(files.some((f) => f.name === "openlist-linux-amd64.tar.gz")) + + // 多行(Go 写法)+ 分号(历史写法)都能解析出嵌套挂载点 + const multi = new ClientGithubReleases({ + repo_structure: "repos:OpenListTeam/OpenList\nother:OpenListTeam/Other", + }) + await multi.init() + const atRoot = await multi.list("/") + assert.deepEqual(atRoot.map((f) => f.name).sort(), ["other", "repos"]) + + // 分号分隔:根挂载点直接在 "/" 展示内容,嵌套挂载点合成为目录 "a" + const semicolon = new ClientGithubReleases({ + repo_structure: "a:OpenListTeam/OpenList;OpenListTeam/Other", + }) + await semicolon.init() + const second = await semicolon.list("/") + const secondNames = second.map((f) => f.name) + assert.ok(secondNames.includes("a")) + assert.ok(secondNames.includes("openlist-linux-amd64.tar.gz")) + } finally { + restore() + } +}) + +test("非法 repo_structure(多个冒号)直接报错", () => { + assert.throws( + () => new ClientGithubReleases({ repo_structure: "x:y:z" }), + /invalid format/, + ) +}) + +test("挂载点根目录列出最新版本资产 + README/LICENSE + 源码包", async () => { + const restore = installFetch(defaultRoutes) + try { + const client = new ClientGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + show_readme: true, + show_source_code: true, + }) + await client.init() + + const files = await client.list("/") + const names = files.map((f) => f.name).sort() + assert.deepEqual(names, [ + "LICENSE", + "README.md", + "Source code (tar.gz)", + "Source code (zip)", + "openlist-linux-amd64.tar.gz", + ]) + + const asset = files.find((f) => f.name === "openlist-linux-amd64.tar.gz")! + assert.equal( + asset.url, + "https://github.com/OpenListTeam/OpenList/releases/download/v1.0.0/openlist-linux-amd64.tar.gz", + ) + // raw.githubusercontent.com 不是 github.com 前缀,不应被改写 + const readme = files.find((f) => f.name === "README.md")! + assert.equal( + readme.url, + "https://raw.githubusercontent.com/OpenListTeam/OpenList/main/README.md", + ) + assert.equal(await client.isDirectory("/"), true) + assert.equal(await client.isDirectory("/README.md"), false) + } finally { + restore() + } +}) + +test("gh_proxy 只替换 github.com 前缀(对齐 Go Link)", async () => { + const restore = installFetch(defaultRoutes) + try { + const client = new ClientGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + show_readme: true, + show_source_code: true, + gh_proxy: "https://ghproxy.net/https://github.com", + }) + await client.init() + + const files = await client.list("/") + const asset = files.find((f) => f.name === "openlist-linux-amd64.tar.gz")! + assert.equal( + asset.url, + "https://ghproxy.net/https://github.com/OpenListTeam/OpenList/releases/download/v1.0.0/openlist-linux-amd64.tar.gz", + ) + const zip = files.find((f) => f.name === "Source code (zip)")! + assert.equal( + zip.url, + "https://ghproxy.net/https://github.com/OpenListTeam/OpenList/archive/refs/tags/v1.0.0.zip", + ) + const readme = files.find((f) => f.name === "README.md")! + assert.equal( + readme.url, + "https://raw.githubusercontent.com/OpenListTeam/OpenList/main/README.md", + ) + } finally { + restore() + } +}) + +test("嵌套挂载点:逐级合成目录并进入挂载点根目录", async () => { + const restore = installFetch(defaultRoutes) + try { + const client = new ClientGithubReleases({ + repo_structure: "repos:OpenListTeam/OpenList", + }) + await client.init() + + const root = await client.list("/") + assert.deepEqual( + root.map((f) => f.name), + ["repos"], + ) + assert.equal(root[0].isDir, true) + + assert.equal(await client.isDirectory("/repos"), true) + const mountRoot = await client.list("/repos") + assert.ok(mountRoot.some((f) => f.name === "openlist-linux-amd64.tar.gz")) + + const url = await client.getDownloadUrl( + "/repos/openlist-linux-amd64.tar.gz", + ) + assert.equal( + url, + "https://github.com/OpenListTeam/OpenList/releases/download/v1.0.0/openlist-linux-amd64.tar.gz", + ) + } finally { + restore() + } +}) + +test("show_all_version:版本目录、目录判定与版本内文件下载", async () => { + const restore = installFetch(defaultRoutes) + try { + const client = new ClientGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + show_all_version: true, + show_readme: true, + show_source_code: true, + }) + await client.init() + + const root = await client.list("/") + const dirs = root + .filter((f) => f.isDir) + .map((f) => f.name) + .sort() + assert.deepEqual(dirs, ["v0.9.0", "v1.0.0"]) + assert.equal(await client.isDirectory("/v1.0.0"), true) + assert.equal(await client.isDirectory("/not-a-tag"), false) + + const tag = await client.list("/v1.0.0") + const names = tag.map((f) => f.name).sort() + assert.deepEqual(names, [ + "Source code (tar.gz)", + "Source code (zip)", + "openlist-linux-amd64.tar.gz", + ]) + + // 版本目录内的文件不能被误判为目录 + const fileUrl = await client.getDownloadUrl( + "/v1.0.0/openlist-linux-amd64.tar.gz", + ) + assert.equal( + fileUrl, + "https://github.com/OpenListTeam/OpenList/releases/download/v1.0.0/openlist-linux-amd64.tar.gz", + ) + const driver = new DriverGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + show_all_version: true, + }) + const item = await driver.get( + "/v1.0.0/openlist-linux-amd64.tar.gz", + "/v1.0.0/openlist-linux-amd64.tar.gz", + ) + assert.equal(item.is_dir, false) + assert.match(item.raw_url || "", /releases\/download\/v1\.0\.0/) + } finally { + restore() + } +}) + +test("per_page / max_page 控制分页拉取(对齐 Go getAllReleases)", async () => { + const calls: string[] = [] + const original = globalThis.fetch + const all = [release("v3", "a"), release("v2", "b"), release("v1", "c")] + globalThis.fetch = (async (input: any) => { + const url = String(input) + calls.push(url) + if (url.includes("/releases?")) { + const parsed = new URL(url) + const page = Number(parsed.searchParams.get("page") || "1") + const perPage = Number(parsed.searchParams.get("per_page") || "30") + const slice = all.slice((page - 1) * perPage, page * perPage) + return new Response(JSON.stringify(slice), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (url.includes("/releases/latest")) { + return new Response(JSON.stringify(LATEST), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as typeof fetch + try { + const client = new ClientGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + show_all_version: true, + per_page: 2, + max_page: 2, + show_readme: false, + }) + await client.init() + const files = await client.list("/") + assert.deepEqual(files.map((f) => f.name).sort(), ["v1", "v2", "v3"]) + assert.ok( + calls.some((u) => u.includes("per_page=2") && u.includes("page=1")), + ) + assert.ok( + calls.some((u) => u.includes("per_page=2") && u.includes("page=2")), + ) + assert.ok(!calls.some((u) => u.includes("page=3"))) + + calls.length = 0 + const limited = new ClientGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + show_all_version: true, + per_page: 1, + max_page: 1, + show_readme: false, + }) + await limited.init() + const limitedFiles = await limited.list("/") + assert.deepEqual( + limitedFiles.map((f) => f.name), + ["v3"], + ) + assert.ok(!calls.some((u) => u.includes("page=2"))) + } finally { + globalThis.fetch = original + } +}) + +test("驱动为只读:写操作全部抛错", async () => { + const driver = new DriverGithubReleases({ + repo_structure: "OpenListTeam/OpenList", + }) + await assert.rejects(() => driver.mkdir(), /read-only/) + await assert.rejects(() => driver.put(), /read-only/) + await assert.rejects(() => driver.remove(), /read-only/) + await assert.rejects(() => driver.move(), /read-only/) + await assert.rejects(() => driver.copy(), /read-only/) + await assert.rejects(() => driver.rename(), /read-only/) +}) diff --git a/src/backend/drivers/github_releases/driver.ts b/src/backend/drivers/github_releases/driver.ts index 8c966a5e..2cf7ad81 100644 --- a/src/backend/drivers/github_releases/driver.ts +++ b/src/backend/drivers/github_releases/driver.ts @@ -34,13 +34,9 @@ export class DriverGithubReleases implements StorageDriver { async get(_virtualPath: string, physicalPath: string): Promise { const parts = physicalPath.split("/").filter(Boolean) const name = parts[parts.length - 1] || "root" - const files = await this.client.list(physicalPath) - // 目录:list 成功返回非空或目录项 - if (files.length > 0 || physicalPath === "/") { - const dir = files.find((f) => f.isDir && f.name === name) - if (!dir && files.some((f) => !f.isDir)) { - // 可能是文件 - } + + // 目录判定按路径结构;不能用 list() 非空判断(版本目录内的文件会被误判) + if (await this.client.isDirectory(physicalPath)) { return { name, size: 0, @@ -51,19 +47,8 @@ export class DriverGithubReleases implements StorageDriver { raw_url: "", } } - // 文件:获取下载链接 + const url = await this.client.getDownloadUrl(physicalPath) - if (url) { - return { - name, - size: 0, - is_dir: false, - modified: new Date().toISOString(), - sign: "", - type: calcFileType(name, false), - raw_url: url, - } - } return { name, size: 0, @@ -71,7 +56,7 @@ export class DriverGithubReleases implements StorageDriver { modified: new Date().toISOString(), sign: "", type: calcFileType(name, false), - raw_url: "", + raw_url: url, } } diff --git a/src/backend/drivers/github_releases/types.ts b/src/backend/drivers/github_releases/types.ts index 0d8955fd..be3df51b 100644 --- a/src/backend/drivers/github_releases/types.ts +++ b/src/backend/drivers/github_releases/types.ts @@ -2,7 +2,9 @@ // API reference: Go drivers/github_releases export interface DriverGithubReleasesAddition { - /** 仓库结构:[path:]org/repo,多个用分号分隔 */ + /** 根文件夹路径(由 resolvePath 通用处理,默认 "/") */ + root_folder_path?: string + /** 仓库结构:[path:]org/repo,每行一个(兼容分号分隔) */ repo_structure?: string /** 显示 README/LICENSE */ show_readme?: boolean @@ -12,6 +14,10 @@ export interface DriverGithubReleasesAddition { show_source_code?: boolean /** 显示所有版本 */ show_all_version?: boolean + /** show_all_version 时每页拉取的 releases 数(默认 30,最大 100) */ + per_page?: number | string + /** show_all_version 时最多拉取的页数(0 = 不限制) */ + max_page?: number | string /** GitHub 代理前缀 */ gh_proxy?: string } diff --git a/src/backend/drivers/github_releases/util.ts b/src/backend/drivers/github_releases/util.ts index d3c9ade6..4f218f51 100644 --- a/src/backend/drivers/github_releases/util.ts +++ b/src/backend/drivers/github_releases/util.ts @@ -1,4 +1,4 @@ -// GitHub Releases API 客户端 +// GitHub Releases API 客户端(行为对齐 Go drivers/github_releases) import { DriverGithubReleasesAddition, GHRMountPoint, @@ -8,40 +8,78 @@ import { } from "./types" const API_BASE = "https://api.github.com" +const GITHUB_PREFIX = "https://github.com" + +function normalizePath(p: string): string { + return ( + "/" + + String(p ?? "") + .split("/") + .filter(Boolean) + .join("/") + ) +} + +function joinPath(...parts: string[]): string { + return normalizePath(parts.filter(Boolean).join("/")) +} + +function clampNumber( + value: unknown, + fallback: number, + min: number, + max: number, +): number { + const num = Math.floor(Number(value)) + if (!Number.isFinite(num) || num <= 0) return fallback + return Math.min(max, Math.max(min, num)) +} + +function releaseSize(release: GHRRelease): number { + return release.assets.reduce((sum, asset) => sum + (asset.size || 0), 0) +} export class ClientGithubReleases { private addition: DriverGithubReleasesAddition private mounts: GHRMountPoint[] constructor(addition: DriverGithubReleasesAddition) { - this.addition = addition - this.mounts = this.parseMounts(addition.repo_structure || "") + this.addition = addition || ({} as DriverGithubReleasesAddition) + this.mounts = this.parseMounts(this.addition.repo_structure || "") } + /** + * 解析 repo_structure:每行一个 [path:]org/repo(兼容分号分隔), + * 省略路径时挂载到根目录;出现第二个冒号视为非法(对齐 Go ParseRepos)。 + */ private parseMounts(structure: string): GHRMountPoint[] { const out: GHRMountPoint[] = [] - for (const raw of structure.split(";")) { + for (const raw of String(structure || "").split(/[\r\n;]+/)) { const item = raw.trim() if (!item) continue const idx = item.indexOf(":") - if (idx > 0 && item.slice(0, idx).includes("/")) { - // 可能是 [path:]org/repo 或 org/repo(无冒号) + if (idx < 0) { + out.push({ point: "/", repo: item }) + continue } - let point = "/" - let repo = item - if (idx > 0 && !item.slice(idx + 1).startsWith("/") && !item.slice(0, idx).includes(".")) { - // 形如 mymount:org/repo - point = "/" + item.slice(0, idx).replace(/^\/+|\/+$/g, "") - repo = item.slice(idx + 1) + if (item.indexOf(":", idx + 1) >= 0) { + throw new Error(`[GitHub Releases] invalid format: ${item}`) } - out.push({ point: point === "/" ? "/" : point, repo }) + const path = item.slice(0, idx).trim() + const repo = item.slice(idx + 1).trim() + if (!repo) { + throw new Error(`[GitHub Releases] invalid format: ${item}`) + } + out.push({ point: normalizePath(path), repo }) } return out } async init(): Promise { if (this.mounts.length === 0) { - throw new Error("[GitHub Releases] repo_structure is required (format: [path:]org/repo)") + throw new Error( + "[GitHub Releases] repo_structure is required (format: [path:]org/repo)", + ) } } @@ -50,7 +88,9 @@ export class ClientGithubReleases { Accept: "application/vnd.github+json", "User-Agent": "OpenList-TSWorker", } - if (this.addition.token) headers["Authorization"] = `Bearer ${this.addition.token}` + if (this.addition.token) { + headers["Authorization"] = `Bearer ${this.addition.token}` + } const resp = await fetch(`${API_BASE}${path}`, { headers }) if (resp.status === 404) return null as any if (resp.status >= 400) { @@ -59,35 +99,56 @@ export class ClientGithubReleases { return (await resp.json().catch(() => null)) as T } + /** gh_proxy 只替换 https://github.com 前缀(对齐 Go Link),其它域名原样返回 */ private proxy(url: string): string { - const p = this.addition.gh_proxy || "" - if (!p || url.startsWith("http")) return url - return p + url + const prefix = String(this.addition.gh_proxy || "").trim() + if (!prefix || !url || !url.startsWith(GITHUB_PREFIX)) return url + return prefix + url.slice(GITHUB_PREFIX.length) } - /** 解析挂载点对应的 org/repo */ + /** 取路径所属的挂载点:最长匹配,根挂载点 "/" 匹配任意路径 */ private mountForPath(virtualPath: string): GHRMountPoint | null { - const clean = virtualPath.startsWith("/") ? virtualPath : "/" + virtualPath - // 找最长的匹配挂载点 + const clean = normalizePath(virtualPath) let best: GHRMountPoint | null = null - for (const m of this.mounts) { - const mp = m.point === "/" ? "/" : m.point - if (clean === mp || clean.startsWith(mp + "/")) { - if (!best || mp.length > (best.point === "/" ? 0 : best.point.length)) { - best = m - } + let bestLen = -1 + for (const mount of this.mounts) { + const point = mount.point === "/" ? "" : mount.point + if (point !== "" && clean !== point && !clean.startsWith(point + "/")) { + continue + } + if (point.length > bestLen) { + best = mount + bestLen = point.length } } return best } + /** 相对挂载点的路径;"" 表示挂载点根目录 */ + private relPath(mount: GHRMountPoint, virtualPath: string): string { + const clean = normalizePath(virtualPath) + const point = mount.point === "/" ? "" : mount.point + return clean.slice(point.length).replace(/^\/+/, "") + } + private async latestRelease(repo: string): Promise { return this.get(`/repos/${repo}/releases/latest`) } + /** 对齐 Go getAllReleases:per_page 默认 30(上限 100),max_page 为 0 时不限页数 */ private async allReleases(repo: string): Promise { - const r = await this.get(`/repos/${repo}/releases`) - return r || [] + const perPage = clampNumber(this.addition.per_page, 30, 1, 100) + const maxPage = Math.max(0, Math.floor(Number(this.addition.max_page) || 0)) + const out: GHRRelease[] = [] + for (let page = 1; maxPage === 0 || page <= maxPage; page++) { + const releases = await this.get( + `/repos/${repo}/releases?per_page=${perPage}&page=${page}`, + ) + if (!releases || releases.length === 0) break + out.push(...releases) + if (releases.length < perPage) break + } + return out } private async repoFiles(repo: string): Promise { @@ -95,166 +156,216 @@ export class ClientGithubReleases { return r || [] } - async list(virtualPath: string): Promise { - const mount = this.mountForPath(virtualPath) - if (!mount) return [] - const rel = this.relPath(mount, virtualPath) + private assetFiles(prefix: string, release: GHRRelease): GHRFile[] { + return release.assets.map((asset) => ({ + path: joinPath(prefix, asset.name), + name: asset.name, + size: asset.size, + isDir: false, + modified: + asset.updated_at || asset.created_at || new Date().toISOString(), + url: this.proxy(asset.browser_download_url), + })) + } + + private sourceCodeFiles(prefix: string, release: GHRRelease): GHRFile[] { + if (!this.addition.show_source_code) return [] + return [ + { + path: joinPath(prefix, "Source code (zip)"), + name: "Source code (zip)", + size: 1, + isDir: false, + modified: release.created_at, + url: this.proxy(release.zipball_url), + }, + { + path: joinPath(prefix, "Source code (tar.gz)"), + name: "Source code (tar.gz)", + size: 1, + isDir: false, + modified: release.created_at, + url: this.proxy(release.tarball_url), + }, + ] + } + + private readmeFiles(prefix: string, contents: GHRFileInfo[]): GHRFile[] { + const out: GHRFile[] = [] + for (const f of contents) { + if ( + f.name.toLowerCase().endsWith(".md") || + f.name.toUpperCase().startsWith("LICENSE") + ) { + out.push({ + path: joinPath(prefix, f.name), + name: f.name, + size: f.size, + isDir: false, + modified: "1970-01-01T00:00:00Z", + url: this.proxy(f.download_url), + }) + } + } + return out + } + /** 挂载点根目录:最新版本资产(或全部版本目录)+ README + 源码包 */ + private async mountContent(point: string, repo: string): Promise { const files: GHRFile[] = [] - // 根目录:挂载点列表 - if (rel === "" && virtualPath === "/") { - for (const m of this.mounts) { + if (this.addition.show_all_version) { + for (const release of await this.allReleases(repo)) { files.push({ - path: m.point, - name: m.point === "/" ? m.repo : m.point.split("/").filter(Boolean).pop() || m.repo, - size: 0, + path: joinPath(point, release.tag_name), + name: release.tag_name, + size: releaseSize(release), isDir: true, - modified: new Date().toISOString(), + modified: release.published_at || release.created_at, url: "", }) } - return files + } else { + const release = await this.latestRelease(repo) + if (release) { + files.push(...this.assetFiles(point, release)) + files.push(...this.sourceCodeFiles(point, release)) + } } - const isAllVersion = this.addition.show_all_version + if (this.addition.show_readme !== false) { + const contents = await this.repoFiles(repo).catch(() => []) + files.push(...this.readmeFiles(point, contents)) + } + return files + } - if (rel === "") { - // 挂载点根:最新版本文件 + 可选源码 + README/LICENSE - const release = await this.latestRelease(mount.repo) - if (release) { - for (const asset of release.assets) { - files.push({ - path: join(mount.point, asset.name), - name: asset.name, - size: asset.size, - isDir: false, - modified: asset.updated_at || asset.created_at || new Date().toISOString(), - url: this.proxy(asset.browser_download_url), - }) - } - if (this.addition.show_source_code) { - files.push({ - path: join(mount.point, "Source code (zip)"), - name: "Source code (zip)", - size: 1, - isDir: false, - modified: release.created_at, - url: this.proxy(release.zipball_url), - }) - files.push({ - path: join(mount.point, "Source code (tar.gz)"), - name: "Source code (tar.gz)", - size: 1, - isDir: false, - modified: release.created_at, - url: this.proxy(release.tarball_url), - }) + async list(virtualPath: string): Promise { + const clean = normalizePath(virtualPath) + const files: GHRFile[] = [] + + for (const mount of this.mounts) { + const point = normalizePath(mount.point) + + // 挂载点根目录 + if (point === clean) { + try { + files.push(...(await this.mountContent(point, mount.repo))) + } catch (e) { + // 单个仓库失败只告警,不影响其它挂载点(对齐 Go) + console.warn(`[GitHub Releases] failed to list ${mount.repo}:`, e) } + continue } - if (isAllVersion) { - const releases = await this.allReleases(mount.repo) - for (const r of releases) { + + // 祖先目录:合成下一级目录 + if ( + point !== "/" && + point.startsWith(clean === "/" ? "/" : clean + "/") + ) { + const prefix = clean === "/" ? "/" : clean + "/" + const name = point.slice(prefix.length).split("/")[0] + if (name && !files.some((f) => f.name === name)) { files.push({ - path: join(mount.point, r.tag_name), - name: r.tag_name, + path: joinPath(clean, name), + name, size: 0, isDir: true, - modified: r.published_at || r.created_at, - url: r.html_url, + modified: new Date().toISOString(), + url: "", }) } + continue } - if (this.addition.show_readme !== false) { - const contents = await this.repoFiles(mount.repo) - for (const f of contents) { - if (f.name.toLowerCase().endsWith(".md") || f.name.toUpperCase().startsWith("LICENSE")) { - files.push({ - path: join(mount.point, f.name), - name: f.name, - size: f.size, - isDir: false, - modified: "1970-01-01T00:00:00Z", - url: this.proxy(f.download_url), - }) + + // 版本目录(仅 show_all_version 时存在) + if ( + this.addition.show_all_version && + (point === "/" || clean.startsWith(point + "/")) + ) { + const rel = + point === "/" ? clean.slice(1) : clean.slice(point.length + 1) + if (rel && !rel.includes("/")) { + try { + const releases = await this.allReleases(mount.repo) + const release = releases.find((r) => r.tag_name === rel) + if (release) { + files.push(...this.assetFiles(clean, release)) + files.push(...this.sourceCodeFiles(clean, release)) + } + } catch (e) { + console.warn(`[GitHub Releases] failed to list version ${rel}:`, e) } } } - return files } - // 版本目录内:该 tag 的 assets + 源码 - const releases = await this.allReleases(mount.repo) - const release = releases.find((r) => r.tag_name === rel.split("/")[0]) - if (release) { - for (const asset of release.assets) { - files.push({ - path: join(mount.point, release.tag_name, asset.name), - name: asset.name, - size: asset.size, - isDir: false, - modified: asset.updated_at || asset.created_at || new Date().toISOString(), - url: this.proxy(asset.browser_download_url), - }) - } - if (this.addition.show_source_code) { - files.push({ - path: join(mount.point, "Source code (zip)"), - name: "Source code (zip)", - size: 1, - isDir: false, - modified: release.created_at, - url: this.proxy(release.zipball_url), - }) - files.push({ - path: join(mount.point, "Source code (tar.gz)"), - name: "Source code (tar.gz)", - size: 1, - isDir: false, - modified: release.created_at, - url: this.proxy(release.tarball_url), - }) - } - } return files } - /** 获取单文件下载链接 */ + /** 仅挂载点、合成的祖先目录与 show_all_version 下的版本目录是目录 */ + async isDirectory(virtualPath: string): Promise { + const clean = normalizePath(virtualPath) + if (clean === "/") return true + + for (const mount of this.mounts) { + const point = normalizePath(mount.point) + if (clean === point) return true + if (point !== "/" && point.startsWith(clean + "/")) return true + } + + if (!this.addition.show_all_version) return false + + const mount = this.mountForPath(clean) + if (!mount) return false + const rel = this.relPath(mount, clean) + if (!rel || rel.includes("/")) return false + const releases = await this.allReleases(mount.repo).catch(() => []) + return releases.some((r) => r.tag_name === rel) + } + + /** 获取单文件下载链接:版本目录 → 最新版本 → 其它版本 → README/LICENSE */ async getDownloadUrl(virtualPath: string): Promise { const mount = this.mountForPath(virtualPath) if (!mount) return "" const rel = this.relPath(mount, virtualPath) - const name = rel.split("/").pop() || "" + const segs = rel.split("/").filter(Boolean) + const name = segs[segs.length - 1] || "" + if (!name) return "" - // 最新版本资产 - const release = await this.latestRelease(mount.repo) - if (release) { - const asset = release.assets.find((a) => a.name === name) + const releases = await this.allReleases(mount.repo).catch(() => []) + if (segs.length >= 2) { + const tagged = releases.find((r) => r.tag_name === segs[0]) + if (tagged) { + const asset = tagged.assets.find((a) => a.name === name) + if (asset) return this.proxy(asset.browser_download_url) + const source = this.sourceCodeUrl(name, tagged) + if (source) return this.proxy(source) + } + } + + const latest = await this.latestRelease(mount.repo).catch(() => null) + if (latest) { + const asset = latest.assets.find((a) => a.name === name) if (asset) return this.proxy(asset.browser_download_url) + const source = this.sourceCodeUrl(name, latest) + if (source) return this.proxy(source) } - // 所有版本资产 - const releases = await this.allReleases(mount.repo) - for (const r of releases) { - const asset = r.assets.find((a) => a.name === name) + + for (const release of releases) { + const asset = release.assets.find((a) => a.name === name) if (asset) return this.proxy(asset.browser_download_url) } - // README/LICENSE - const contents = await this.repoFiles(mount.repo) - const f = contents.find((c) => c.name === name) - if (f) return this.proxy(f.download_url) + + const contents = await this.repoFiles(mount.repo).catch(() => []) + const match = contents.find((c) => c.name === name) + if (match) return this.proxy(match.download_url) return "" } - private relPath(mount: GHRMountPoint, virtualPath: string): string { - const mp = mount.point === "/" ? "/" : mount.point - const clean = virtualPath.startsWith("/") ? virtualPath : "/" + virtualPath - if (mp === "/") return clean.replace(/^\/+/, "") - const rel = clean.slice(mp.length).replace(/^\/+/, "") - return rel + private sourceCodeUrl(name: string, release: GHRRelease): string { + if (name === "Source code (zip)") return release.zipball_url + if (name === "Source code (tar.gz)") return release.tarball_url + return "" } } - -function join(...parts: string[]): string { - const clean = parts.filter(Boolean).map((p) => p.replace(/^\/+|\/+$/g, "")).filter(Boolean) - return "/" + clean.join("/") -} diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index a23583a5..1b7eb451 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -169,7 +169,10 @@ export const normalizeDriver = (driverName: string): string => { ].includes(norm) ) return "S3" - if (norm.startsWith("github")) return "Github" + // releases 别名必须在通用 github 别名之前,否则会被归一到 GitHub API + if (norm.startsWith("githubrelease")) return "GitHub Releases" + if (norm.startsWith("cnbrelease")) return "CNB Releases" + if (norm.startsWith("github")) return "GitHub API" if (norm === "local") return "Local" if (norm.includes("pikpak")) return "PikPak" if (norm.includes("seafile")) return "Seafile" @@ -537,6 +540,8 @@ adminRouter.get("/driver/names", (c) => { "GuangYaPan", "AutoIndex", "ProtonDrive", + "GitHub Releases", + "CNB Releases", ], }) }) @@ -1497,6 +1502,141 @@ const driverConfigs: Record = { default_root: "/", }, }, + // Issue #107:驱动已实现(internal/op/storage.ts)但此处漏配,前端存储列表 + // 因此看不到「GitHub Releases」;字段对齐 Go meta 与前端 i18n。 + "GitHub Releases": { + name: "GitHub Releases", + default_mount_path: "/github_releases", + common: COMMON_FIELDS, + additional: [ + { + name: "root_folder_path", + type: "string", + default: "/", + required: false, + }, + { + name: "repo_structure", + type: "text", + default: "OpenListTeam/OpenList", + required: true, + help: "structure:[path:]org/repo", + }, + { + name: "show_readme", + type: "bool", + default: "true", + required: false, + help: "show README、LICENSE file", + }, + { + name: "token", + type: "string", + default: "", + required: false, + help: "GitHub token, if you want to access private repositories or increase the rate limit", + }, + { + name: "show_source_code", + type: "bool", + default: "false", + required: false, + help: "show Source code (zip/tar.gz)", + }, + { + name: "show_all_version", + type: "bool", + default: "false", + required: false, + help: "show all versions", + }, + { + name: "per_page", + type: "number", + default: "30", + required: false, + help: "releases per page (max 100), only works when show all versions", + }, + { + name: "max_page", + type: "number", + default: "0", + required: false, + help: "max pages to fetch (0 = unlimited), only works when show all versions", + }, + { + name: "gh_proxy", + type: "string", + default: "", + required: false, + help: "GitHub proxy, e.g. https://ghproxy.net/https://github.com", + }, + ], + config: { + name: "GitHub Releases", + // 对齐 Go:只读发布源(NoUpload) + local_sort: false, + only_local: false, + only_proxy: false, + no_cache: false, + no_upload: true, + need_ms: false, + default_root: "", + }, + }, + // 同一处遗漏:CNB Releases 驱动同样未登记。 + "CNB Releases": { + name: "CNB Releases", + default_mount_path: "/cnb_releases", + common: COMMON_FIELDS, + additional: [ + { + name: "root_folder_id", + type: "string", + default: "", + required: false, + }, + { + name: "repo", + type: "string", + default: "", + required: true, + help: "CNB 仓库,形如 org/repo", + }, + { + name: "token", + type: "string", + default: "", + required: true, + help: "CNB 访问令牌", + }, + { + name: "use_tag_name", + type: "bool", + default: "false", + required: false, + help: "Use tag name instead of release name", + }, + { + name: "default_branch", + type: "string", + default: "main", + required: false, + help: "Default branch for new releases", + }, + ], + config: { + name: "CNB Releases", + local_sort: true, + only_local: false, + only_proxy: false, + no_cache: false, + // Go 版支持上传;TS 无状态实现 put 直接报错,隐藏上传入口 + no_upload: true, + need_ms: false, + default_root: "", + }, + }, Thunder: { name: "Thunder", default_mount_path: "/thunder", @@ -4263,7 +4403,11 @@ adminRouter.get("/driver/list", (c) => { adminRouter.get("/driver/info", (c) => { const driverName = c.req.query("driver") || "" - const info = driverConfigs[driverName] || driverConfigs["AliyundriveOpen"] + // 兼容 Go 导入名(如 GithubReleases):精确未命中时按别名归一化再回退 + const info = + driverConfigs[driverName] || + driverConfigs[normalizeDriver(driverName)] || + driverConfigs["AliyundriveOpen"] return c.json({ code: 200, message: "success", diff --git a/src/backend/server/driver_list.test.ts b/src/backend/server/driver_list.test.ts new file mode 100644 index 00000000..3050e7cd --- /dev/null +++ b/src/backend/server/driver_list.test.ts @@ -0,0 +1,230 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { adminRouter } from "./admin" +import { listItems } from "../internal/op/storage" +import { saveDb } from "../internal/model/db" + +/** + * Issue #107 回归:GitHub Releases / CNB Releases 必须出现在后台驱动列表中; + * 覆盖 /driver/list、/driver/names、/driver/info 与创建存储后的端到端列目录。 + */ + +const authHeaders = { Authorization: "Bearer admin-token" } + +async function setupEnv(): Promise { + const env: any = {} + await saveDb( + { + settings: [{ key: "token", value: "admin-token" }], + users: [], + storages: [], + shares: [], + metas: [], + }, + env, + ) + return env +} + +function makeApp(): Hono { + const app = new Hono() + app.route("/api/admin", adminRouter) + return app +} + +test("driver/list 包含 GitHub Releases 与 CNB Releases", async () => { + const env = await setupEnv() + const res = await makeApp().request( + "/api/admin/driver/list", + { method: "GET", headers: authHeaders }, + env, + ) + assert.equal(res.status, 200) + const body: any = await res.json() + assert.equal(body.code, 200) + + const ghr = body.data["GitHub Releases"] + assert.ok(ghr, "GitHub Releases 必须出现在驱动列表中") + assert.equal(ghr.name, "GitHub Releases") + assert.equal(ghr.default_mount_path, "/github_releases") + assert.equal(ghr.config.no_upload, true) + assert.equal(ghr.config.local_sort, false) + const ghrFields = ghr.additional.map((f: any) => f.name) + for (const field of [ + "root_folder_path", + "repo_structure", + "show_readme", + "token", + "show_source_code", + "show_all_version", + "per_page", + "max_page", + "gh_proxy", + ]) { + assert.ok(ghrFields.includes(field), `GitHub Releases 缺少字段 ${field}`) + } + const repoStructure = ghr.additional.find( + (f: any) => f.name === "repo_structure", + ) + assert.equal(repoStructure.required, true) + assert.equal(repoStructure.default, "OpenListTeam/OpenList") + + const cnb = body.data["CNB Releases"] + assert.ok(cnb, "CNB Releases 必须出现在驱动列表中") + assert.equal(cnb.name, "CNB Releases") + assert.equal(cnb.default_mount_path, "/cnb_releases") + const cnbFields = cnb.additional.map((f: any) => f.name) + for (const field of [ + "root_folder_id", + "repo", + "token", + "use_tag_name", + "default_branch", + ]) { + assert.ok(cnbFields.includes(field), `CNB Releases 缺少字段 ${field}`) + } +}) + +test("driver/names 包含 GitHub Releases 与 CNB Releases", async () => { + const env = await setupEnv() + const res = await makeApp().request( + "/api/admin/driver/names", + { method: "GET", headers: authHeaders }, + env, + ) + assert.equal(res.status, 200) + const body: any = await res.json() + assert.ok(body.data.includes("GitHub Releases")) + assert.ok(body.data.includes("CNB Releases")) +}) + +test("driver/info 支持精确名与 Go 风格名(GithubReleases/大小写)", async () => { + const env = await setupEnv() + const app = makeApp() + + const exact = await app.request( + `/api/admin/driver/info?driver=${encodeURIComponent("GitHub Releases")}`, + { method: "GET", headers: authHeaders }, + env, + ) + const exactBody: any = await exact.json() + assert.equal(exactBody.data.name, "GitHub Releases") + + // Go 导入名(无空格)也要命中,编辑页依赖同一份配置 + const goStyle = await app.request( + "/api/admin/driver/info?driver=GithubReleases", + { method: "GET", headers: authHeaders }, + env, + ) + const goStyleBody: any = await goStyle.json() + assert.equal(goStyleBody.data.name, "GitHub Releases") + + const cnb = await app.request( + "/api/admin/driver/info?driver=cnb_releases", + { method: "GET", headers: authHeaders }, + env, + ) + const cnbBody: any = await cnb.json() + assert.equal(cnbBody.data.name, "CNB Releases") +}) + +test("通过 API 创建 GitHub Releases 存储并可列出资源(端到端)", async () => { + const env = await setupEnv() + const app = makeApp() + + const create = await app.request( + "/api/admin/storage/create", + { + method: "POST", + headers: { ...authHeaders, "Content-Type": "application/json" }, + body: JSON.stringify({ + driver: "GitHub Releases", + mount_path: "/ghr", + addition: JSON.stringify({ repo_structure: "OpenListTeam/OpenList" }), + }), + }, + env, + ) + assert.equal(create.status, 200) + const created: any = await create.json() + assert.equal(created.code, 200) + // 必须按配置键原样落库,getDriver 才能命中 githubreleases 分支 + assert.equal(created.data.driver, "GitHub Releases") + + const originalFetch = globalThis.fetch + globalThis.fetch = (async (input: any) => { + const url = String(input) + if (url.includes("/repos/OpenListTeam/OpenList/releases/latest")) { + return new Response( + JSON.stringify({ + tag_name: "v1.0.0", + created_at: "2026-01-01T00:00:00Z", + published_at: "2026-01-02T00:00:00Z", + html_url: + "https://github.com/OpenListTeam/OpenList/releases/tag/v1.0.0", + zipball_url: + "https://github.com/OpenListTeam/OpenList/archive/refs/tags/v1.0.0.zip", + tarball_url: + "https://github.com/OpenListTeam/OpenList/archive/refs/tags/v1.0.0.tar.gz", + assets: [ + { + name: "openlist-linux-amd64.tar.gz", + size: 1024, + created_at: "2026-01-02T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + browser_download_url: + "https://github.com/OpenListTeam/OpenList/releases/download/v1.0.0/openlist-linux-amd64.tar.gz", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + if (url.includes("/repos/OpenListTeam/OpenList/contents")) { + return new Response(JSON.stringify([]), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response("", { status: 404 }) + }) as typeof fetch + + try { + const { content, provider } = await listItems("/ghr", { env }) + assert.equal(provider, "GitHub Releases") + const asset = content.find((i) => i.name === "openlist-linux-amd64.tar.gz") + assert.ok(asset, `未列出 release 资产: ${JSON.stringify(content)}`) + assert.equal(asset!.is_dir, false) + assert.equal(asset!.raw_url?.includes("releases/download/v1.0.0"), true) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("存储列表中的驱动配置可被 driver/info 复用(前端编辑页)", async () => { + const env = await setupEnv() + const app = makeApp() + const list = await app.request( + "/api/admin/driver/list", + { method: "GET", headers: authHeaders }, + env, + ) + const listBody: any = await list.json() + const keys = Object.keys(listBody.data) + + for (const key of keys) { + // 与前端一致:按 key 请求 driver/info 必须精确命中 + const info = await app.request( + `/api/admin/driver/info?driver=${encodeURIComponent(key)}`, + { method: "GET", headers: authHeaders }, + env, + ) + const infoBody: any = await info.json() + assert.equal( + infoBody.data.name, + key, + `driver/info 未返回 ${key} 自身的配置`, + ) + } +})