From 4ed1b4b480db40e1858e9b9211e9a8cc074e4137 Mon Sep 17 00:00:00 2001 From: wumingzhinu Date: Sat, 10 Oct 2026 22:58:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(drivers):=20=E4=B8=8B=E8=BD=BD=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E9=9A=8F=E9=99=84=20UA=20/=20Referer=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E8=AE=A9=20123=20=E9=BB=98=E8=AE=A4=E8=B5=B0=E7=9B=B4?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 动机 原先是「服务端替客户端搬字节」的形态:Worker 用 `raw_url_headers` 取回 CDN 内容再转发。在 Cloudflare Workers 上这条路有三个固定代价: 1. **客户端只能单线程下** —— Worker 是唯一出口,多线程分片无从谈起; 2. Worker 吃满 CPU / 子请求预算(代码里可见 SUBREQUEST_LIMIT 之类常量); 3. 受平台响应体上限约束,超限还得降级回 302。 参考 YunX:同一批直链在客户端可开 32~512 线程(迅雷锁 8)。 要吃到这个能力,就必须把直链连带 UA/Referer 交给客户端。 ## 改动一:四个驱动的取链函数返回请求头 `Promise` → `Promise<{ url, headers }>`,驱动赋给 `raw_url_headers`。 | 驱动 | 新增头 | 依据 | | --- | --- | --- | | 115 | User-Agent | 同族 115open 同一约定(UA 绑进取链请求) | | quark_uc_tv | Referer + UA | 同族 quark 已返回 {url, headers} | | 123pan | Referer + UA | YunX Pan123Api 注明 CDN 直链需 Referer | | 123_open | Referer + UA | 同上,同一 CDN 体系 | ## 改动二:`123pan` / `123open` 移出 `DRIVER_PREFER_PROXY` 这张表决定**新建存储时 `web_proxy` 的默认值**:命中即默认勾选,即默认由 Worker 代理字节 —— 客户端拿不到直链,多线程无从发生。 移出后默认改为 302 直链。依据是仓库自己的判定: `server/proxy_request.ts` 的 `PRIVATE_HEADER_NAMES` 只有 authorization / cookie,其注释明确写着 UA / Referer / Origin「浏览器可以自己提供」, **不构成必须服务端代理的理由**。 **只影响新建存储的默认值**:已有存储有显式持久化的 `web_proxy`, `effectiveWebProxy` 优先读显式值,行为不变,管理员可随时勾回。 ## 改动三:`/api/fs/link` 接通 `link()` 并返回 headers 排查时发现 **`link()` 是死代码**:10 个驱动实现了它 (115_share、123_share、139、189pc、aliyundrive_share、dropbox、mega、 onedrive_sharelink、pikpak_share、wps),但下载链路里没有任何地方调用。 而 `fs.ts` 的 `/link` 端点只返回 `{url}`,既不调 `link()` 也不回 headers。 后果:多线程下载器**没有任何途径**拿到驱动指定的精确头 —— 302 只能交给 浏览器自带的 UA/Referer,取不到驱动指定的值。这正是「无法让客户端多线程」的 根因之一。 改造 `/api/fs/link`: - 优先调用驱动 `link()`,成功则直接返回 `{url, headers}`; - `link()` 不可用或失败时回退 `driver.get()` 的 `raw_url` + `raw_url_headers`; - `linkOk` 时**跳过** `driver.get()`,省一次上游往返(仓库在意子请求预算)。 安全性:`link()` 返回的头可能含 Cookie / Authorization。该端点本就是 admin-only(上方有 `isAdmin` 判定),受众正是「正在配置下载管理器的管理员」, 原样返回是恰当的。已加测试守住权限门,防止回归成任意用户可取。 ## 为什么 `baidu_netdisk` 没一起改 三条 `download_api` 路径都只带 UA(`official` 要求恰为 `pan.baidu.com`)。 百度是否校验 UA 具体值未知,若校验则 302 后新用户直接下载失败, 风险与 123(YunX 只强制 Referer)不对等。宜单独 PR,并需先确认。 ## 已确认无需改动 `115`、`115_share`、`115open`、`123_share`、`quark`、`quark_uc_tv`、 `baidu_photo`、`thunder`、`139`、`lanzou`、`uc` 均不在两张代理表内, 默认即 302。 `quark_open` 带的头是 `Cookie`(私有头),`DRIVER_FORCE_PROXY` 对它是 **正确配置**,客户端多线程对其结构性不可行,故不动。 ## 未验证 无真实凭据,未实机比对 302 前后结果。**不保证修复任何具体故障。** Co-Authored-By: Claude Opus 4.8 (1M context) --- src/backend/drivers/115/driver.ts | 6 +- src/backend/drivers/115/util.ts | 16 +++- src/backend/drivers/123_open/driver.ts | 5 +- src/backend/drivers/123_open/util.ts | 33 ++++++++- src/backend/drivers/123pan/driver.ts | 5 +- src/backend/drivers/123pan/util.ts | 40 ++++++++-- src/backend/drivers/quark_uc_tv/driver.ts | 5 +- src/backend/drivers/quark_uc_tv/util.ts | 21 +++++- src/backend/internal/driver/proxy.test.ts | 19 +++-- src/backend/internal/driver/proxy.ts | 18 ++++- src/backend/server/fs.ts | 48 +++++++++++- src/backend/server/fs_link.test.ts | 89 +++++++++++++++++++++++ 12 files changed, 275 insertions(+), 30 deletions(-) create mode 100644 src/backend/server/fs_link.test.ts diff --git a/src/backend/drivers/115/driver.ts b/src/backend/drivers/115/driver.ts index c4e77143..70aabfae 100644 --- a/src/backend/drivers/115/driver.ts +++ b/src/backend/drivers/115/driver.ts @@ -60,8 +60,10 @@ export class Driver115 implements StorageDriver { const item = fileToItem(file) if (!item.is_dir && file.Pc) { try { - const url = await this.client.getDownloadUrl(file.Pc) - item.raw_url = url + const link = await this.client.getDownloadUrl(file.Pc) + item.raw_url = link.url + // 115 的下载链接按 UA 签发,必须随链接一起交给客户端 + item.raw_url_headers = link.headers } catch (e: any) { item.raw_url_error = e.message } diff --git a/src/backend/drivers/115/util.ts b/src/backend/drivers/115/util.ts index 33f88b1d..602b5304 100644 --- a/src/backend/drivers/115/util.ts +++ b/src/backend/drivers/115/util.ts @@ -180,12 +180,24 @@ export class Client115 { await this.request("/open/ufile/delete", { fid: fids, ignore_warn: 1 }) } - async getDownloadUrl(pickCode: string): Promise { + /** + * 取下载链接。 + * + * 随链接一并返回 User-Agent:115 的 downurl 是按请求方 UA 签发的,同族 + * 115open 使用同一约定(其注释:「115 防盗链校验通过率高」,并把 UA 绑进 + * 取链请求),115_share 的 link() 同样带 UA + Referer。 + * + * 说明:本次是从 YunX 移植行为并与同族驱动对齐,**并未实机验证**原先的裸 + * URL 是否真的会失败;改动的目的是让三个 115 驱动行为一致。 + */ + async getDownloadUrl( + pickCode: string, + ): Promise<{ url: string; headers: Record }> { const resp = await this.request("/open/ufile/downurl", { pickcode: pickCode, }) const url = resp?.url || resp?.data?.url || "" if (!url) throw new Error("[115] empty download url") - return url + return { url, headers: { "User-Agent": UA } } } } diff --git a/src/backend/drivers/123_open/driver.ts b/src/backend/drivers/123_open/driver.ts index 66425d02..940c8a7a 100644 --- a/src/backend/drivers/123_open/driver.ts +++ b/src/backend/drivers/123_open/driver.ts @@ -58,7 +58,10 @@ export class Driver123Open implements StorageDriver { const item = this.fileToItem(file) if (!item.is_dir) { try { - item.raw_url = await this.client.getDownloadUrl(file.fileId) + const link = await this.client.getDownloadUrl(file.fileId) + item.raw_url = link.url + // CDN 直链需带 Referer + UA + item.raw_url_headers = link.headers } catch (e: any) { item.raw_url_error = e.message } diff --git a/src/backend/drivers/123_open/util.ts b/src/backend/drivers/123_open/util.ts index 51e75e89..f1015b88 100644 --- a/src/backend/drivers/123_open/util.ts +++ b/src/backend/drivers/123_open/util.ts @@ -10,6 +10,32 @@ import { const API = "https://open-api.123pan.com" +/** + * 123 网盘下载请求头。 + * + * 依据(参考项目 YunX,https://github.com/CYQawa/YunX): + * - Pan123Api 对解码后的 CDN 直链明确注明「下载需带 Referer: https://yun.123pan.cn/」; + * - Pan123Constants.WEB_UA 给出配套浏览器 UA。 + * + * 本仓库此前只把 Referer 用在了**内部**跟随 302 那一步,拿到最终直链后直接 + * 交给客户端。 + * + * 说明:本次行为移植自 YunX,未实机验证缺少该头是否真的导致失败。 + */ + +export const PAN123_DOWNLOAD_REFERER = "https://yun.123pan.cn/" + +export const PAN123_DOWNLOAD_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" + +/** 取链时可复用的下载头 */ +export function pan123DownloadHeaders(): Record { + return { + Referer: PAN123_DOWNLOAD_REFERER, + "User-Agent": PAN123_DOWNLOAD_UA, + } +} + export class Client123Open { private addition: Driver123OpenAddition private accessToken = "" @@ -111,13 +137,16 @@ export class Client123Open { return all } - async getDownloadUrl(fileId: number): Promise { + /** 取下载链接;CDN 直链需带 Referer + UA(见文件顶部说明) */ + async getDownloadUrl( + fileId: number, + ): Promise<{ url: string; headers: Record }> { const qs = new URLSearchParams({ fileId: String(fileId) }) const resp = await this.request(`${API}/api/v1/file/download_info?${qs.toString()}`, "GET") if (resp.code !== 0) throw new Error(`[123Open] ${resp.message}`) const url = resp.data?.download_url || "" if (!url) throw new Error("[123Open] empty download url") - return url + return { url, headers: pan123DownloadHeaders() } } async mkdir(parentId: string, name: string): Promise { diff --git a/src/backend/drivers/123pan/driver.ts b/src/backend/drivers/123pan/driver.ts index 6b36c7c7..d5aeede3 100644 --- a/src/backend/drivers/123pan/driver.ts +++ b/src/backend/drivers/123pan/driver.ts @@ -314,7 +314,10 @@ export class Pan123Driver implements StorageDriver { const item = pan123FileToFileItem(file) if (file.Type !== 1) { try { - item.raw_url = await this.client.getDownloadLink(file) + const link = await this.client.getDownloadLink(file) + item.raw_url = link.url + // 123 的 CDN 直链要求 Referer + UA,必须随链接交给客户端 + item.raw_url_headers = link.headers if (!item.raw_url) { item.raw_url_error = "123 网盘未返回下载链接(DownloadUrl 为空)。常见原因:access_token/cookie 失效,或该文件已删除/被限制下载。请到管理后台更新 access_token 后重试。" diff --git a/src/backend/drivers/123pan/util.ts b/src/backend/drivers/123pan/util.ts index dd39a018..e128749b 100644 --- a/src/backend/drivers/123pan/util.ts +++ b/src/backend/drivers/123pan/util.ts @@ -160,6 +160,32 @@ function getApi(rawUrl: string): string { // --- Client --- +/** + * 123 网盘下载请求头。 + * + * 依据(参考项目 YunX,https://github.com/CYQawa/YunX): + * - Pan123Api 对解码后的 CDN 直链明确注明「下载需带 Referer: https://yun.123pan.cn/」; + * - Pan123Constants.WEB_UA 给出配套浏览器 UA。 + * + * 本仓库此前只把 Referer 用在了**内部**跟随 302 那一步,拿到最终直链后直接 + * 交给客户端。 + * + * 说明:本次行为移植自 YunX,未实机验证缺少该头是否真的导致失败。 + */ + +export const PAN123_DOWNLOAD_REFERER = "https://yun.123pan.cn/" + +export const PAN123_DOWNLOAD_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" + +/** 取链时可复用的下载头 */ +export function pan123DownloadHeaders(): Record { + return { + Referer: PAN123_DOWNLOAD_REFERER, + "User-Agent": PAN123_DOWNLOAD_UA, + } +} + export class Pan123Client { private addition: Pan123Addition private accessToken = "" @@ -421,7 +447,9 @@ export class Pan123Client { // ---- Download ---- - public async getDownloadLink(file: Pan123File): Promise { + public async getDownloadLink( + file: Pan123File, + ): Promise<{ url: string; headers: Record }> { const body = { driveId: 0, etag: file.Etag, @@ -459,14 +487,14 @@ export class Pan123Client { redirect: "manual", headers: { Referer: "https://yun.123pan.com/" }, }) + let finalUrl = downloadUrl if (res.status === 302) { - return res.headers.get("location") || downloadUrl - } - if (res.status < 300) { + finalUrl = res.headers.get("location") || downloadUrl + } else if (res.status < 300) { const body = await res.json().catch(() => ({})) - return body.data?.redirect_url || downloadUrl + finalUrl = body.data?.redirect_url || downloadUrl } - return downloadUrl + return { url: finalUrl, headers: pan123DownloadHeaders() } } // ---- File operations ---- diff --git a/src/backend/drivers/quark_uc_tv/driver.ts b/src/backend/drivers/quark_uc_tv/driver.ts index 57c06acc..cb487887 100644 --- a/src/backend/drivers/quark_uc_tv/driver.ts +++ b/src/backend/drivers/quark_uc_tv/driver.ts @@ -55,7 +55,10 @@ export class DriverQuarkUcTv implements StorageDriver { const item = this.fileToItem(file) if (!item.is_dir) { try { - item.raw_url = await this.client.getDownloadUrl(file.fid) + const link = await this.client.getDownloadUrl(file.fid) + item.raw_url = link.url + // 夸克系校验来源,下载头必须随链接一起交给客户端 + item.raw_url_headers = link.headers } catch (e: any) { item.raw_url_error = e.message } diff --git a/src/backend/drivers/quark_uc_tv/util.ts b/src/backend/drivers/quark_uc_tv/util.ts index 0717fa3a..a2786c10 100644 --- a/src/backend/drivers/quark_uc_tv/util.ts +++ b/src/backend/drivers/quark_uc_tv/util.ts @@ -194,7 +194,17 @@ export class ClientQuarkUcTv { return all } - async getDownloadUrl(fid: string): Promise { + /** + * 取下载/播放链接。 + * + * 同族的 quark 驱动(quark/util.ts 的 getDownloadUrl)已确立此形状:链接 + * 连同 Referer 与 User-Agent 一起返回。本次据此把 quark_uc_tv 对齐到同一约定。 + * + * 说明:属行为移植与统一,未实机验证此前是否真的失败。 + */ + async getDownloadUrl( + fid: string, + ): Promise<{ url: string; headers: Record }> { if (this.addition.link_method === "streaming") { const resp = await this.request("/file", "GET", { method: "streaming", @@ -206,7 +216,7 @@ export class ClientQuarkUcTv { const data = resp.data as QuarkTvStreamingData | undefined const url = data?.video_info?.find((v) => v.url)?.url || "" if (!url) throw new Error("[QuarkTV] no streaming link found") - return url + return { url, headers: quarkTvDownloadHeaders() } } const resp = await this.request("/file", "GET", { method: "download", @@ -217,6 +227,11 @@ export class ClientQuarkUcTv { }) const data = resp.data as QuarkTvDownloadData | undefined if (!data?.download_url) throw new Error("[QuarkTV] empty download url") - return data.download_url + return { url: data.download_url, headers: quarkTvDownloadHeaders() } } } + +/** 夸克系下载请求头:与 quark 驱动保持一致 */ +function quarkTvDownloadHeaders(): Record { + return { Referer: "https://pan.quark.cn/", "User-Agent": UA } +} diff --git a/src/backend/internal/driver/proxy.test.ts b/src/backend/internal/driver/proxy.test.ts index bd9be557..93f3b009 100644 --- a/src/backend/internal/driver/proxy.test.ts +++ b/src/backend/internal/driver/proxy.test.ts @@ -61,15 +61,20 @@ test("MustProxy:只包含 Go 标了 OnlyProxy / NoLinkURL 的驱动", () => { }) test("PreferProxy:与 Go 的 DefaultProxy() 一致", () => { - for (const driver of [ - "WebDav", - "BaiduNetdisk", - "123Pan", - "123PanShare", - "123Open", - ]) { + for (const driver of ["WebDav", "BaiduNetdisk", "123PanShare"]) { assert.equal(driverPreferProxy(driver), true, `${driver} 应为 PreferProxy`) } + + // 123pan / 123open 已移出 PreferProxy:直链只需 Referer + UA + // (proxy_request.ts 判定这两头「浏览器/下载器可自供」), + // 留在表内会让 Worker 替客户端搬字节、且客户端无法多线程分片。 + for (const driver of ["123Pan", "123Open"]) { + assert.equal( + driverPreferProxy(driver), + false, + `${driver} 应默认为直链(交给客户端多线程),不再走服务端代理`, + ) + } for (const driver of ["Onedrive", "GoogleDrive", "S3", "Terabox"]) { assert.equal( driverPreferProxy(driver), diff --git a/src/backend/internal/driver/proxy.ts b/src/backend/internal/driver/proxy.ts index 3f05539c..46b28642 100644 --- a/src/backend/internal/driver/proxy.ts +++ b/src/backend/internal/driver/proxy.ts @@ -128,12 +128,24 @@ const DRIVER_FORCE_PROXY = new Set([ "local", ]) -/** 默认代理(Go: Config.DefaultProxy() = PreferProxy) */ +/** + * 默认代理(Go: Config.DefaultProxy() = PreferProxy)。 + * + * 表单默认值含义:这里是 true 时,新建存储的 `web_proxy` 默认勾选, + * 即由 Worker 转发字节流;客户端因此只能单线程下,Worker 也会吃满 + * CPU / 子请求预算,且受平台响应体上限约束(见 proxy_request.ts)。 + * + * `123pan` / `123open` 已移出本表:它们的直链只要求 Referer + User-Agent, + * 而 proxy_request.ts 的 PRIVATE_HEADER_NAMES 明确判定这两个头 + * 「浏览器/下载器可以自己提供」,不构成必须服务端代理的理由。 + * 保留在表内只会让 Cloudflare Workers 替客户端搬字节,白白放弃 + * 客户端多线程分片的能力(参考 YunX:同一批直链可开 32~512 线程)。 + * + * 需要管理员仍可用 `web_proxy` 显式打开代理,行为不受影响。 + */ const DRIVER_PREFER_PROXY = new Set([ "webdav", "baidunetdisk", - "123pan", - "123open", "123panshare", ]) diff --git a/src/backend/server/fs.ts b/src/backend/server/fs.ts index ac90cef6..8aa488c6 100644 --- a/src/backend/server/fs.ts +++ b/src/backend/server/fs.ts @@ -1295,12 +1295,56 @@ fsRouter.post("/link", async (c) => { } const driver = await getDriver(resolved.storage.driver, resolved.storage) try { + // 优先用驱动自己的 link():它返回 { url, headers },即「直链 + 精确请求头」。 + // 这是多线程下载器唯一能拿到正确 UA / Referer / Cookie 的途径 —— + // 302 只能交给浏览器自带的那一套头,拿不到驱动指定的值。 + // + // 注意:link() 返回的头可能含 Cookie / Authorization 等私有鉴权信息。 + // 本端点已是 admin-only(见上方 isAdmin 判定),受众就是「正在配置下载 + // 管理器的管理员」,因此这里原样返回是恰当的。 + let linkHeaders: Record | undefined + let linkUrl: string | undefined + let linkOk = false + const linkFn = (driver as any).link + if (typeof linkFn === "function") { + try { + const linked = await linkFn.call( + driver, + reqPath, + resolved.physical ?? "/", + ) + if (linked?.url) { + linkUrl = linked.url + linkHeaders = linked.headers + linkOk = true + } + } catch { + // link() 失败不阻断:回退到 driver.get() 的 raw_url + } + } + + if (linkOk) { + return c.json({ + code: 200, + message: "success", + data: + linkHeaders && Object.keys(linkHeaders).length + ? { url: linkUrl, headers: linkHeaders } + : { url: linkUrl }, + }) + } + + // link() 不可用或未返回 url 时才走 driver.get(),省一次上游往返 const item = await driver.get(reqPath, resolved.physical ?? "/") - if (item && item.raw_url) { + if (item?.raw_url) { + const headers = + item.raw_url_headers && Object.keys(item.raw_url_headers).length + ? item.raw_url_headers + : undefined return c.json({ code: 200, message: "success", - data: { url: item.raw_url }, + data: headers ? { url: item.raw_url, headers } : { url: item.raw_url }, }) } } finally { diff --git a/src/backend/server/fs_link.test.ts b/src/backend/server/fs_link.test.ts new file mode 100644 index 00000000..b2316af7 --- /dev/null +++ b/src/backend/server/fs_link.test.ts @@ -0,0 +1,89 @@ +// `/api/fs/link` 行为测试。 +// +// 关注点:该端点返回**真实直链**,改造后又额外返回精确请求头(headers), +// 因此权限门必须保持 admin-only —— 否则任意用户都能取到带 Cookie / +// Authorization 的直链。权限回归比取链本身更值得守。 + +import assert from "node:assert/strict" +import { test } from "node:test" +import { Hono } from "hono" +import { saveDb } from "../internal/model/db" +import { fsRouter } from "./fs" + +const env: any = {} +const ADMIN_TOKEN = "ADMIN_STATIC_TOKEN" + +const seed = () => + saveDb( + { + settings: [{ key: "token", value: ADMIN_TOKEN }], + users: [ + { + id: 1, + username: "admin", + password: "xxx", + role: 2, + permission: 0, + base_path: "/", + disabled: false, + }, + { + id: 2, + username: "guest", + password: "xxx", + role: 1, + permission: 0, + base_path: "/", + disabled: false, + }, + ], + storages: [ + { + id: 1, + mount_path: "/x", + driver: "local", + disabled: false, + addition: JSON.stringify({ root_folder_path: "/data" }), + }, + ], + shares: [], + }, + env, + ) + +const callLink = (headers: Record = {}) => { + const app = new Hono() + app.route("/api/fs", fsRouter) + return app.request("/api/fs/link", { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ path: "/x" }), + }) +} + +test("/api/fs/link 保持 admin-only(直链可能带私有鉴权头)", async () => { + await seed() + const guest = await callLink() + assert.equal( + guest.status, + 403, + "guest 不得取得真实直链 —— 直链可能携带 Cookie / Authorization", + ) + + // 管理员至少不应被权限门拦下(可能因驱动无直链而落回代理地址) + const admin = await callLink({ Authorization: ADMIN_TOKEN }) + assert.notEqual(admin.status, 403, "admin 不应被权限门拒绝") +}) + +test("/api/fs/link 响应体始终带 url 字段", async () => { + await seed() + const res = await callLink({ Authorization: ADMIN_TOKEN }) + const body = (await res.json()) as any + assert.equal(body.code, 200) + assert.equal(typeof body.data.url, "string", "响应必须包含可用的 url") + // 无 headers 时不得凭空多出该字段,保持向后兼容 + assert.ok( + body.data.headers === undefined || typeof body.data.headers === "object", + "headers 缺省时不应出现", + ) +}) \ No newline at end of file