diff --git a/docs/pr-issue-179-pikpak-client-credentials.md b/docs/pr-issue-179-pikpak-client-credentials.md new file mode 100644 index 00000000..af5e0884 --- /dev/null +++ b/docs/pr-issue-179-pikpak-client-credentials.md @@ -0,0 +1,140 @@ + + +`fix(pikpak): restore built-in client credentials and fix 4126 login failure` + +## Summary / 摘要 + +本 PR 修复 PikPak 存储在 TSWorker 中**只能填账号密码却永远添加失败**的问题(Issue #179),以及恢复内置凭据后仍然出现的 `PikPak login failed (4126): AccessProhibited`。 + +**根因一(Issue #179)**:`a316fb9 security: remove hardcoded credentials and mask sensitive fields` 把 PikPak 三个平台的 `client_id` / `client_secret` 默认值整段删除,构造器改为 `overrideClientId || ""`,而 PikPak 的表单里**没有** `client_id` 字段可供填写。于是每次添加 PikPak 存储,`init()` → `login()` / `refreshToken()` → `captcha/init` 与 `auth/signin` 都会以**空 client_id** 发请求,被服务端拒绝,存储初始化失败;未挂载成功时访问 `/pikpak` 就返回 404 —— 与该 issue 里的日志一致。 + +**根因二(4126)**:登录流程与 Go 版 / Web 端不一致,导致验证码 token 被判为无效: + +1. `login()` 只在 `captchaTokenVal` 为空时才申请验证码。而 `captcha_token` 会在上次登录后持久化进 addition,第二次起就复用**已过期**的 token 去 signin。 +2. 登录前的 `captcha/init` 缺少 `client_version` / `package_name` / `timestamp` / `captcha_sign`,服务端只下发「未签名」的 token。 +3. `auth/signin` 带了原生 SDK 才有的 `X-Device-ID` / `X-Captcha-Token` 头;web 凭据 + 原生头的组合会被判为 `AccessProhibited`。 +4. `auth/token`(refresh_token 换发)带了浏览器 UA 与 `X-Device-ID`。 + +Go 版 OpenList(`OpenList-Backends/drivers/pikpak/util.go`,上游 `e73a80c7`)与 NextList 都内置了同一组官方客户端标识,且登录流程满足上面四点,因此「只填用户名密码」即可成功,这正是 issue 中「隔壁能加、这里不行」的差异来源。 + + +## 用户可感知的行为变化 + +- PikPak 存储**只需填写用户名 + 密码**(或已有 `refresh_token`)即可添加,不再要求 `client_id` / `client_secret`。 +- 存储编辑页新增两个**可选**字段 `client_id` / `client_secret`,仅用于自有 PikPak 应用场景;留空即使用内置默认值。 +- 登录不再受「上次持久化的验证码 token 已过期」影响;`4126` 时会用新验证码 token 再试一次,仍失败才报错并给出排查提示。 + +## 重要实现变化 + +- `src/backend/drivers/pikpak/util.ts` + - 恢复 `ANDROID_CLIENT_ID/SECRET`、`WEB_CLIENT_ID/SECRET`、`PC_CLIENT_ID/SECRET` 常量,并按平台作为默认值使用。 + - 覆盖值改为 `trim()` 后判断,避免表单把空白字符串写进 addition 时反而清空凭据。 + - 常量处补充注释,说明这是**官方客户端内置、全网共用的应用标识**,而非可申请替换的用户私有凭据(与 `189_tv` / `thunder` / `pikpak_share` 中保留协议级固定密钥的注释口径一致)。 + - `login()` 改为**每次**都重新申请 captcha token,并在开始前清空旧 access token(对齐 Go 版 `e73a80c7`)。 + - `refreshCaptchaTokenInLogin()` 补齐 `client_version` / `package_name` / `timestamp` / `captcha_sign` metas。 + - `auth/signin` 只保留 `Content-Type` 与 `User-Agent`,去掉 `X-Device-ID` / `X-Captcha-Token`。 + - `auth/token` 改为空 `User-Agent` 且不带 `X-Device-ID`。 + - `captcha/init` 补上 `X-Captcha-Token` 请求头;`4126` 时用新验证码 token 重试一次(最多两轮,避免触发风控)。 + - `refreshToken()` 的 `4126` 分支与 Go 版一致:有账号密码才自动重登,否则提示「请重新提供 refresh_token」。 + - 新增 `skip_verification`(对齐 Go 版同名选项):默认把 captcha 接口返回的验证链接视为失败,显式开启后忽略。 + - 服务端返回空 token 时直接报错,避免把空 token 持久化。 +- `src/backend/server/admin.ts` + - PikPak 表单新增 `client_id` / `client_secret`(`required: false`),并写明留空使用内置默认值。 + - PikPak 表单新增 `skip_verification`(`type: bool`,默认 `false`)。 + + +### 与 Go 版对齐 + +| 平台 | client_id | 与 Go 常量 | +| --- | --- | --- | +| web | `YUMx5nI8ZU8Ap8pm` | `WebClientID` ✅ | +| android | `YNxT9w7GMdWvEOKa` | `AndroidClientID` ✅ | +| pc | `YvtoWO6GNHiuCl7x` | `PCClientID` ✅ | + +client_secret 同样与 Go 的 `WebClientSecret` / `AndroidClientSecret` / `PCClientSecret` 逐字一致。 + +登录/换号链路逐点对照(Go 侧为 `OpenList-Backends/drivers/pikpak/util.go`,上游提交 `e73a80c7`): + +| 行为 | Go 版 | 本 PR | +| --- | --- | --- | +| 登录前刷新验证码 | `login()` 无条件调用 `RefreshCaptchaTokenInLogin`(注释:token 可能已过期) | ✅ 每次都刷新,不再复用持久化的旧 token | +| 清掉旧 Bearer | `d.AccessToken = ""`,注释:避免验证码请求带失效 bearer | ✅ `this.accessToken = ""` | +| 登录 metas | `email/phone_number/username` + `client_version` + `package_name` + `timestamp` + `captcha_sign` | ✅ 同 | +| signin 请求头 | resty 默认 UA + JSON body,无 `X-Device-ID` / `X-Captcha-Token` | ✅ 仅 `Content-Type` + `User-Agent` | +| 换发 refresh_token | `SetHeader("user-agent", "")`,无 device 头 | ✅ 空 UA、无 `X-Device-ID` | +| 空 token 校验 | `login`/`refreshToken` 返回空 token 即报错 | ✅ 同 | +| `4126` 处理 | 有账号密码 → `login()`;否则提示重新提供 refresh_token | ✅ 同(并额外用新验证码 token 重试一次) | +| 验证链接 | `resp.Url != "" && !SkipVerification` 才报错 | ✅ `skip_verification` | +| `init()` 后的 captcha 刷新 | 失败直接中止 `Init()` | 仍为「记录告警后继续」,留给后续错误码 9 的刷新重试兜底 | + +> 唯一保留的差异:Go 在 `Init()` 里把登录后的验证码刷新失败当作致命错误;TS 侧保持宽容(仅告警),避免一次临时失败就让整块存储挂载失败,真正的过期会由请求错误码 9 触发刷新重试。 + + +## Related Issues / 关联 Issue + +Fixes #179 + +## Testing / 测试 + +```bash +npx tsx --test "src/backend/drivers/pikpak/*.test.ts" # 15/15 通过 +npm run test:drivers # 207/207 通过 +npm run test:server # 220/220 通过 +npm run lint # tsc --noEmit,exit 0 +``` + +新增 `src/backend/drivers/pikpak/util.test.ts`(10 个用例): + +- 未提供 `client_id` 时,web / android / pc 三个平台分别落到对应的内置凭据。 +- 空白覆盖值(`" "`、`""`)回退默认值;显式覆盖值优先生效。 +- **回归主用例**:`init()` 在只有用户名密码的情况下完成登录 —— 校验 `captcha/init` 与 `auth/signin` 的 query 与 body 都带上了非空 `client_id` / `client_secret`,并回调持久化 token。 +- 新增:addition 里已有旧 `captcha_token` 时,登录仍会重新申请并只用新 token 去 signin。 +- 新增:登录前的 `captcha/init` meta 带 `client_version` / `package_name` / `timestamp` / `captcha_sign`。 +- 新增:`auth/signin` 不带 `X-Device-ID` / `X-Captcha-Token`,只带 web UA。 +- 新增:首次 signin 返回 `4126` 时,会用第二个验证码 token 重试并成功。 +- 新增:`auth/token` 的 UA 为空且不带 `X-Device-ID`。 +- 新增:`captcha/init` 返回验证链接时默认报错,`skip_verification: true` 时放行。 +- 新增:`auth/token` 返回 `4126` 时有账号密码自动重登(signin 一次),没有则提示重新提供 refresh_token(不发起 signin)。 + +> **未做的验证**:未使用真实 PikPak 账号跑端到端登录(CI 环境无凭据,且登录会触发风控/验证码)。建议合并前用测试账号手动添加一次存储并浏览目录。 +> 若真实账号仍返回 `4126`,说明是出口 IP 被 PikPak 风控(Cloudflare Workers 数据中心 IP 常见),与本次代码无关,可改用 `refresh_token` 或更换出口网络复测。 + + +## Checklist / 检查清单 + +- [x] I have read [CONTRIBUTING](https://github.com/OpenListTeam/OpenList/blob/main/CONTRIBUTING.md). + / 我已阅读 CONTRIBUTING。 +- [x] I confirm this contribution follows the repository license, contribution policy, and code of conduct. + / 我确认此贡献符合仓库许可证、贡献规范和行为准则。 +- [x] I have formatted the changed code with `prettier` where applicable. + / 我已按适用情况使用 prettier 格式化变更代码。 +- [ ] I have requested review from relevant maintainers or code owners where applicable. + / 我已在适用情况下请求相关维护者或代码所有者审查。 + +- [ ] This PR has breaking changes. / 此 PR 包含破坏性变更。 +- [ ] This PR changes public API, config, storage format, or migration behavior. / 此 PR 修改了公开 API、配置、存储格式或迁移行为。 +- [ ] This PR requires corresponding changes in related repositories. / 此 PR 需要关联仓库同步修改。 + +## AI Disclosure / AI 使用声明 + +- [x] This PR includes AI-assisted content. / 此 PR 包含 AI 辅助内容。 + +Tools used / 使用工具: + +- [x] Other (please specify) / 其他(请注明): CodeBuddy(DeepSeek-V4.1-Flash) + +Usage scope / 使用范围: + +- [x] Code generation / 代码生成 +- [x] Tests / 测试 +- [x] Documentation / 文档 + +- [x] I have reviewed and validated all AI-assisted content included in this PR. +- [x] I have ensured that all AI-assisted commits include `Co-Authored-By` attribution. +- [x] I can reproduce all AI-assisted content included in this PR without any AI tools. diff --git a/src/backend/drivers/pikpak/types.ts b/src/backend/drivers/pikpak/types.ts index 61839b18..9514d5bb 100644 --- a/src/backend/drivers/pikpak/types.ts +++ b/src/backend/drivers/pikpak/types.ts @@ -7,6 +7,11 @@ export interface PikPakAddition { device_id?: string root_folder_id?: string disable_media_link?: boolean + /** + * 忽略 captcha API 返回的人工验证链接(与 Go 版 skip_verification 对齐)。 + * 默认关闭:返回 url 即视为失败,避免用未签名 token 继续登录。 + */ + skip_verification?: boolean order_by?: string order_direction?: string /** 可选:覆盖内置 OAuth 客户端凭据(产品化时使用自有应用) */ diff --git a/src/backend/drivers/pikpak/util.test.ts b/src/backend/drivers/pikpak/util.test.ts new file mode 100644 index 00000000..aeb34cf6 --- /dev/null +++ b/src/backend/drivers/pikpak/util.test.ts @@ -0,0 +1,438 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { + ANDROID_CLIENT_ID, + ANDROID_CLIENT_SECRET, + PC_CLIENT_ID, + PC_CLIENT_SECRET, + PikPakApiClient, + WEB_CLIENT_ID, + WEB_CLIENT_SECRET, +} from "./util" + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) +} + +test("PikPak uses built-in official client credentials when addition omits them", () => { + const web = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + }) as any + assert.equal(web.clientId, WEB_CLIENT_ID) + assert.equal(web.clientSecret, WEB_CLIENT_SECRET) + + const android = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + platform: "android", + }) as any + assert.equal(android.clientId, ANDROID_CLIENT_ID) + assert.equal(android.clientSecret, ANDROID_CLIENT_SECRET) + + const pc = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + platform: "pc", + }) as any + assert.equal(pc.clientId, PC_CLIENT_ID) + assert.equal(pc.clientSecret, PC_CLIENT_SECRET) +}) + +test("PikPak treats blank overrides as absent and honors explicit ones", () => { + const blank = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + client_id: " ", + client_secret: "", + }) as any + assert.equal(blank.clientId, WEB_CLIENT_ID) + assert.equal(blank.clientSecret, WEB_CLIENT_SECRET) + + const custom = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + client_id: "custom-client", + client_secret: "custom-secret", + }) as any + assert.equal(custom.clientId, "custom-client") + assert.equal(custom.clientSecret, "custom-secret") +}) + +test("PikPak login succeeds without user-provided client_id (issue #179)", async () => { + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; body: any }> = [] + + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input) + const body = init?.body ? JSON.parse(String(init.body)) : undefined + calls.push({ url, body }) + + if (url.includes("/v1/shield/captcha/init")) { + return jsonResponse({ captcha_token: "captcha-token", expires_in: 300 }) + } + if (url.includes("/v1/auth/signin")) { + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + sub: "user-id", + }) + } + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const persisted: Array<{ accessToken: string; refreshToken: string }> = [] + const client = new PikPakApiClient( + { username: "user@example.com", password: "password123" }, + async (tokens) => { + persisted.push(tokens) + }, + ) + + await client.init() + assert.equal((client as any).accessToken, "access-token") + assert.equal((client as any).refreshTokenVal, "refresh-token") + assert.equal(persisted.length, 1) + assert.equal(persisted[0].accessToken, "access-token") + assert.equal(persisted[0].refreshToken, "refresh-token") + + const captcha = calls.find((c) => c.url.includes("/v1/shield/captcha/init")) + const signin = calls.find((c) => c.url.includes("/v1/auth/signin")) + assert.ok(captcha, "captcha init must be requested before signin") + assert.ok(signin, "signin must be requested") + + // 空 client_id 正是 issue #179 的失败根因:请求必须带上内置标识 + const signinUrl = new URL(signin!.url) + assert.equal(signinUrl.searchParams.get("client_id"), WEB_CLIENT_ID) + const captchaUrl = new URL(captcha!.url) + assert.equal(captchaUrl.searchParams.get("client_id"), WEB_CLIENT_ID) + assert.equal(signin!.body.client_id, WEB_CLIENT_ID) + assert.equal(signin!.body.client_secret, WEB_CLIENT_SECRET) + assert.equal(signin!.body.username, "user@example.com") + assert.equal(signin!.body.password, "password123") + } finally { + globalThis.fetch = originalFetch + } +}) + +interface RecordedCall { + url: string + body: any + headers: Record +} + +function installFetchMock( + handler: (url: string, call: RecordedCall, index: number) => Response | undefined, +): { calls: RecordedCall[]; restore: () => void } { + const originalFetch = globalThis.fetch + const calls: RecordedCall[] = [] + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + const url = String(input) + const headers: Record = {} + new Headers(init?.headers || {}).forEach((v, k) => { + headers[k.toLowerCase()] = v + }) + const call: RecordedCall = { + url, + body: init?.body ? JSON.parse(String(init.body)) : undefined, + headers, + } + const index = calls.push(call) - 1 + return ( + handler(url, call, index) || + new Response("unexpected URL", { status: 404 }) + ) + }) as typeof fetch + return { calls, restore: () => (globalThis.fetch = originalFetch) } +} + +test("PikPak login refreshes captcha token instead of reusing the persisted one (4126)", async () => { + let captchaCount = 0 + const mock = installFetchMock((url) => { + if (url.includes("/v1/shield/captcha/init")) { + captchaCount++ + return jsonResponse({ + captcha_token: `fresh-captcha-${captchaCount}`, + expires_in: 300, + }) + } + if (url.includes("/v1/auth/signin")) { + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + sub: "user-id", + }) + } + return undefined + }) + + try { + const client = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + // 上次登录后持久化的旧验证码 token,此时已经过期 + captcha_token: "stale-captcha", + }) as any + + await client.login() + + const signin = mock.calls.find((c) => c.url.includes("/v1/auth/signin"))! + const captcha = mock.calls.find((c) => + c.url.includes("/v1/shield/captcha/init"), + )! + assert.ok(captcha, "captcha init must run even when captcha_token exists") + assert.equal(signin.body.captcha_token, "fresh-captcha-1") + assert.notEqual(signin.body.captcha_token, "stale-captcha") + } finally { + mock.restore() + } +}) + +test("PikPak login captcha init carries client metadata and captcha_sign", async () => { + const mock = installFetchMock((url) => { + if (url.includes("/v1/shield/captcha/init")) { + return jsonResponse({ captcha_token: "ct", expires_in: 300 }) + } + if (url.includes("/v1/auth/signin")) { + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + sub: "user-id", + }) + } + return undefined + }) + + try { + const client = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + }) as any + await client.login() + + const captcha = mock.calls.find((c) => + c.url.includes("/v1/shield/captcha/init"), + )! + assert.equal(captcha.body.action, "POST:/v1/auth/signin") + assert.equal(captcha.body.meta.email, "user@example.com") + assert.equal(captcha.body.meta.client_version, client.clientVersion) + assert.equal(captcha.body.meta.package_name, client.packageName) + assert.ok(/^\d+$/.test(captcha.body.meta.timestamp)) + assert.ok(captcha.body.meta.captcha_sign.startsWith("1.")) + } finally { + mock.restore() + } +}) + +test("PikPak signin only sends web headers, not native SDK headers", async () => { + const mock = installFetchMock((url) => { + if (url.includes("/v1/shield/captcha/init")) { + return jsonResponse({ captcha_token: "ct", expires_in: 300 }) + } + if (url.includes("/v1/auth/signin")) { + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + sub: "user-id", + }) + } + return undefined + }) + + try { + const client = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + }) as any + await client.login() + + const signin = mock.calls.find((c) => c.url.includes("/v1/auth/signin"))! + // 原生 SDK 才带这两个头,web 凭据下带上会被判为 AccessProhibited + assert.equal(signin.headers["x-device-id"], undefined) + assert.equal(signin.headers["x-captcha-token"], undefined) + assert.equal(signin.headers["content-type"], "application/json") + assert.ok(signin.headers["user-agent"]) + } finally { + mock.restore() + } +}) + +test("PikPak retries signin once with a fresh captcha token on 4126", async () => { + let signinCount = 0 + let captchaCount = 0 + const mock = installFetchMock((url) => { + if (url.includes("/v1/shield/captcha/init")) { + captchaCount++ + return jsonResponse({ + captcha_token: `captcha-${captchaCount}`, + expires_in: 300, + }) + } + if (url.includes("/v1/auth/signin")) { + signinCount++ + if (signinCount === 1) { + return jsonResponse({ + error_code: 4126, + error: "AccessProhibited", + error_description: "AccessProhibited", + }) + } + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + sub: "user-id", + }) + } + return undefined + }) + + try { + const client = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + }) as any + await client.login() + + const signins = mock.calls.filter((c) => c.url.includes("/v1/auth/signin")) + assert.equal(signins.length, 2) + assert.equal(signins[0].body.captcha_token, "captcha-1") + assert.equal(signins[1].body.captcha_token, "captcha-2") + assert.equal((client as any).accessToken, "access-token") + } finally { + mock.restore() + } +}) + +test("PikPak refresh_token exchange omits UA and X-Device-ID", async () => { + const mock = installFetchMock((url) => { + if (url.includes("/v1/auth/token")) { + return jsonResponse({ + access_token: "new-access", + refresh_token: "new-refresh", + sub: "user-id", + }) + } + if (url.includes("/v1/shield/captcha/init")) { + return jsonResponse({ captcha_token: "ct", expires_in: 300 }) + } + return undefined + }) + + try { + const client = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + refresh_token: "old-refresh", + }) as any + await client.refreshToken() + + const tokenCall = mock.calls.find((c) => c.url.includes("/v1/auth/token"))! + assert.equal(tokenCall.body.grant_type, "refresh_token") + assert.equal(tokenCall.body.refresh_token, "old-refresh") + assert.equal(tokenCall.headers["user-agent"], "") + assert.equal(tokenCall.headers["x-device-id"], undefined) + assert.equal((client as any).accessToken, "new-access") + } finally { + mock.restore() + } +}) + +test("PikPak fails on a captcha verification url unless skip_verification is set", async () => { + const mock = installFetchMock((url) => { + if (url.includes("/v1/shield/captcha/init")) { + return jsonResponse({ + captcha_token: "ct", + expires_in: 300, + url: "https://user.mypikpak.net/forbidden/test", + }) + } + if (url.includes("/v1/auth/signin")) { + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + sub: "user-id", + }) + } + return undefined + }) + + try { + const strict = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + }) as any + await assert.rejects(() => strict.login(), /requires captcha verification/) + + const relaxed = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + skip_verification: true, + }) as any + await relaxed.login() + assert.equal(relaxed.accessToken, "access-token") + } finally { + mock.restore() + } +}) + +test("PikPak refresh 4126 re-logins with credentials, else asks for a new refresh_token", async () => { + const mock = installFetchMock((url) => { + if (url.includes("/v1/auth/token")) { + return jsonResponse({ + error_code: 4126, + error: "invalid_grant", + error_description: "invalid_grant", + }) + } + if (url.includes("/v1/shield/captcha/init")) { + return jsonResponse({ captcha_token: "ct", expires_in: 300 }) + } + if (url.includes("/v1/auth/signin")) { + return jsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + sub: "user-id", + }) + } + return undefined + }) + + try { + const withCredentials = new PikPakApiClient({ + username: "user@example.com", + password: "password123", + refresh_token: "stale-refresh", + }) as any + await withCredentials.refreshToken() + assert.equal( + mock.calls.filter((c) => c.url.includes("/v1/auth/signin")).length, + 1, + ) + assert.equal(withCredentials.accessToken, "access-token") + + mock.calls.length = 0 + const withoutCredentials = new PikPakApiClient({ + refresh_token: "stale-refresh", + }) as any + await assert.rejects( + () => withoutCredentials.refreshToken(), + /re-provide refresh_token/, + ) + assert.equal( + mock.calls.filter((c) => c.url.includes("/v1/auth/signin")).length, + 0, + ) + } finally { + mock.restore() + } +}) diff --git a/src/backend/drivers/pikpak/util.ts b/src/backend/drivers/pikpak/util.ts index a1910217..2f0e7bf0 100644 --- a/src/backend/drivers/pikpak/util.ts +++ b/src/backend/drivers/pikpak/util.ts @@ -48,15 +48,24 @@ export const PC_ALGORITHMS = [ "vk7hBjawK/rOSrSWajtbMk95nfgf3", ] -// client_id / client_secret 已移除内置默认值,须通过 addition.client_id / addition.client_secret 提供自有 OAuth 应用凭据。 +// client_id / client_secret:PikPak 官方客户端内置的应用标识,服务端按固定值校验、 +// 由所有第三方客户端共用(Go 版 OpenList 与上游驱动同样内置),并非可申请替换的 +// 用户私有凭据。清空会让登录/换号请求带空 client_id 而被服务端拒绝(Issue #179), +// 因此这里保留默认值,仅允许通过 addition.client_id / addition.client_secret 覆盖。 +export const ANDROID_CLIENT_ID = "YNxT9w7GMdWvEOKa" +export const ANDROID_CLIENT_SECRET = "dbw2OtmVEeuUvIptb1Coyg" export const ANDROID_CLIENT_VERSION = "1.53.2" export const ANDROID_PACKAGE_NAME = "com.pikcloud.pikpak" export const ANDROID_SDK_VERSION = "2.0.6.206003" +export const WEB_CLIENT_ID = "YUMx5nI8ZU8Ap8pm" +export const WEB_CLIENT_SECRET = "dbw2OtmVEeuUvIptb1Coyg" export const WEB_CLIENT_VERSION = "2.0.0" export const WEB_PACKAGE_NAME = "mypikpak.com" export const WEB_SDK_VERSION = "8.0.3" +export const PC_CLIENT_ID = "YvtoWO6GNHiuCl7x" +export const PC_CLIENT_SECRET = "1NIH5R1IEe2pAxZE3hv3uA" export const PC_CLIENT_VERSION = "undefined" export const PC_PACKAGE_NAME = "mypikpak.com" export const PC_SDK_VERSION = "8.0.3" @@ -134,6 +143,7 @@ export class PikPakApiClient { private accessToken: string = "" private refreshTokenVal: string = "" private captchaTokenVal: string = "" + private skipVerification: boolean = false private onTokenRefreshed?: (tokens: { accessToken: string refreshToken: string @@ -152,21 +162,26 @@ export class PikPakApiClient { this.onTokenRefreshed = onTokenRefreshed this.refreshTokenVal = addition.refresh_token || "" this.captchaTokenVal = addition.captcha_token || "" + // 表单/键值库可能把布尔值存成字符串,沿用仓库内 `=== true || === "true"` 口径 + const skipVerification: unknown = (addition as any).skip_verification + this.skipVerification = + skipVerification === true || skipVerification === "true" const platform = addition.platform || "web" - // 支持通过 addition 覆盖内置 OAuth 凭据(产品化时使用自有应用) - const overrideClientId = addition.client_id - const overrideClientSecret = addition.client_secret + // 允许通过 addition 覆盖内置应用标识(产品化时使用自有应用); + // 留空或仅含空白时回退到官方客户端默认值,保证「只填账号密码」即可添加(Issue #179)。 + const overrideClientId = (addition.client_id || "").trim() + const overrideClientSecret = (addition.client_secret || "").trim() if (platform === "android") { - this.clientId = overrideClientId || "" - this.clientSecret = overrideClientSecret || "" + this.clientId = overrideClientId || ANDROID_CLIENT_ID + this.clientSecret = overrideClientSecret || ANDROID_CLIENT_SECRET this.clientVersion = ANDROID_CLIENT_VERSION this.packageName = ANDROID_PACKAGE_NAME this.sdkVersion = ANDROID_SDK_VERSION this.algorithms = ANDROID_ALGORITHMS } else if (platform === "pc") { - this.clientId = overrideClientId || "" - this.clientSecret = overrideClientSecret || "" + this.clientId = overrideClientId || PC_CLIENT_ID + this.clientSecret = overrideClientSecret || PC_CLIENT_SECRET this.clientVersion = PC_CLIENT_VERSION this.packageName = PC_PACKAGE_NAME this.sdkVersion = PC_SDK_VERSION @@ -175,8 +190,8 @@ export class PikPakApiClient { "MainWindow Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) PikPak/2.6.11.4955 Chrome/100.0.4896.160 Electron/18.3.15 Safari/537.36" } else { // web - this.clientId = overrideClientId || "" - this.clientSecret = overrideClientSecret || "" + this.clientId = overrideClientId || WEB_CLIENT_ID + this.clientSecret = overrideClientSecret || WEB_CLIENT_SECRET this.clientVersion = WEB_CLIENT_VERSION this.packageName = WEB_PACKAGE_NAME this.sdkVersion = WEB_SDK_VERSION @@ -220,6 +235,10 @@ export class PikPakApiClient { "Content-Type": "application/json", "User-Agent": this.userAgent || "OpenList", "X-Device-ID": this.deviceId, + // 与 Go 版 request() 一致:刷新验证码时带上当前 captcha token + ...(this.captchaTokenVal + ? { "X-Captcha-Token": this.captchaTokenVal } + : {}), }, body: JSON.stringify(param), }, @@ -232,7 +251,9 @@ export class PikPakApiClient { ) } - if (data.url) { + // 与 Go 版 refreshCaptchaToken 一致:返回验证链接默认即失败, + // 仅当 addition.skip_verification 显式开启时才忽略(可能触发风控)。 + if (data.url && !this.skipVerification) { throw new Error(`PikPak requires captcha verification: ${data.url}`) } @@ -270,6 +291,14 @@ export class PikPakApiClient { } else { metas.username = username } + // 与 Go 版 OpenList 对齐:登录前的 captcha/init 同样要带客户端版本、 + // 包名与 captcha_sign,否则服务端只下发「未签名」的 token, + // 随后 /v1/auth/signin 会以 4126 AccessProhibited 拒绝本次登录。 + const { timestamp, sign } = this.getCaptchaSign() + metas.client_version = this.clientVersion + metas.package_name = this.packageName + metas.timestamp = timestamp + metas.captcha_sign = sign return this.refreshCaptchaToken(action, metas) } @@ -281,51 +310,77 @@ export class PikPakApiClient { } const url = "https://user.mypikpak.net/v1/auth/signin" - if (!this.captchaTokenVal) { + // 清空旧 access token,避免验证码请求带上已失效的 Bearer。 + this.accessToken = "" + + let lastError: PikPakErrResp | undefined + // 4126 AccessProhibited 基本等同于「captcha_token 无效/过期」。 + // 用新申请的 captcha token 重试一次即可覆盖时钟偏差/并发导致的偶发失效, + // 最多两轮,避免反复登录触发风控。 + for (let attempt = 0; attempt < 2; attempt++) { + // 每次登录都重新申请 captcha token:addition.captcha_token 是上次登录后 + // 持久化的旧值(会过期),直接复用会让 signin 带着失效 token 被拒。 + // Go 版同样在 login() 中无条件刷新验证码。 await this.refreshCaptchaTokenInLogin( getAction("POST", url), this.addition.username, ) - } - const res = await fetch( - `${url}?client_id=${encodeURIComponent(this.clientId)}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "User-Agent": this.userAgent || "OpenList", - "X-Device-ID": this.deviceId, - "X-Captcha-Token": this.captchaTokenVal, + // 与 Go 版 / Web 端一致:signin 只带 Content-Type 与 User-Agent, + // 不附带 X-Device-ID / X-Captcha-Token(原生 SDK 才带这两个头, + // 在 web 凭据下会被服务端判定为 AccessProhibited)。 + const res = await fetch( + `${url}?client_id=${encodeURIComponent(this.clientId)}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": this.userAgent || "OpenList", + }, + body: JSON.stringify({ + captcha_token: this.captchaTokenVal, + client_id: this.clientId, + client_secret: this.clientSecret, + username: this.addition.username, + password: this.addition.password, + }), }, - body: JSON.stringify({ - captcha_token: this.captchaTokenVal, - client_id: this.clientId, - client_secret: this.clientSecret, - username: this.addition.username, - password: this.addition.password, - }), - }, - ) - - const data: PikPakTokenResp & PikPakErrResp = await res.json() - if (data.error_code && data.error_code !== 0) { - throw new Error( - `PikPak login failed (${data.error_code}): ${data.error_description || data.error}`, ) - } - this.accessToken = data.access_token - this.refreshTokenVal = data.refresh_token - this.userId = data.sub || "" + const data: PikPakTokenResp & PikPakErrResp = await res.json() + if (!data.error_code || data.error_code === 0) { + if (!data.access_token || !data.refresh_token) { + throw new Error("PikPak login failed: server returned empty tokens") + } + + this.accessToken = data.access_token + this.refreshTokenVal = data.refresh_token + this.userId = data.sub || "" + + if (this.onTokenRefreshed) { + await this.onTokenRefreshed({ + accessToken: this.accessToken, + refreshToken: this.refreshTokenVal, + captchaToken: this.captchaTokenVal, + }) + } + return + } - if (this.onTokenRefreshed) { - await this.onTokenRefreshed({ - accessToken: this.accessToken, - refreshToken: this.refreshTokenVal, - captchaToken: this.captchaTokenVal, - }) + lastError = data + if (data.error_code !== 4126) break } + + const detail = + lastError?.error_description || lastError?.error || "unknown error" + if (lastError?.error_code === 4126) { + throw new Error( + `PikPak login failed (4126): ${detail} — 已用新的验证码 token 重试登录仍被拒绝。` + + "若账号能正常登录官网,通常是出口 IP(如 Cloudflare Workers 数据中心 IP)触发风控," + + "或该账号被要求人工完成验证码;请更换出口网络稍后重试,或改用 refresh_token 添加存储。", + ) + } + throw new Error(`PikPak login failed (${lastError?.error_code}): ${detail}`) } async refreshToken(): Promise { @@ -341,8 +396,9 @@ export class PikPakApiClient { method: "POST", headers: { "Content-Type": "application/json", - "User-Agent": this.userAgent || "OpenList", - "X-Device-ID": this.deviceId, + // 与 Go 版 / Web 端一致:refresh_token 换发不带 UA 与 X-Device-ID, + // 带浏览器 UA 会被服务端当作跨端调用而拒绝。 + "User-Agent": "", }, body: JSON.stringify({ client_id: this.clientId, @@ -355,15 +411,26 @@ export class PikPakApiClient { const data: PikPakTokenResp & PikPakErrResp = await res.json() if (data.error_code && data.error_code !== 0) { - if (data.error_code === 4126 && this.addition.username) { - await this.login() - return + // 与 Go 版一致:4126 = refresh_token 失效。有账号密码就自动重登, + // 否则只能提示用户重新提供 refresh_token。 + if (data.error_code === 4126) { + if (this.addition.username && this.addition.password) { + await this.login() + return + } + throw new Error( + "PikPak refresh_token is invalid, please re-provide refresh_token", + ) } throw new Error( `PikPak token refresh failed (${data.error_code}): ${data.error_description || data.error}`, ) } + if (!data.access_token || !data.refresh_token) { + throw new Error("PikPak token refresh failed: server returned empty tokens") + } + this.accessToken = data.access_token this.refreshTokenVal = data.refresh_token this.userId = data.sub || "" diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index 722c66d9..398aedf4 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -2910,12 +2910,33 @@ const driverConfigs: Record = { default: "", required: false, }, + { + name: "client_id", + type: "string", + default: "", + required: false, + help: "可选。留空使用内置 PikPak 官方客户端应用标识;仅在自有 PikPak 应用场景填写。", + }, + { + name: "client_secret", + type: "string", + default: "", + required: false, + help: "可选。与 client_id 配套,留空使用内置默认值。", + }, { name: "disable_media_link", type: "bool", default: "true", required: false, }, + { + name: "skip_verification", + type: "bool", + default: "false", + required: false, + help: "忽略 captcha 接口返回的人工验证链接(默认视为失败);开启后可能触发 PikPak 风控。", + }, { name: "order_by", type: "select",