diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts index ce40f82c..93c6abad 100644 --- a/src/backend/drivers/139/driver.test.ts +++ b/src/backend/drivers/139/driver.test.ts @@ -2,7 +2,7 @@ import test from "node:test" import assert from "node:assert/strict" import { Yun139Driver } from "./driver" import { Yun139Addition } from "./types" -import { calSign } from "./util" +import { calSign, Yun139ApiClient } from "./util" test("Yun139 calculation and signing", () => { const sign = calSign("{}", "2026-08-24 16:00:00", "1234567890abcdef") @@ -58,3 +58,89 @@ test("Yun139Driver instantiation and methods", async () => { const link = await driver.link("/photo.jpg", "/photo.jpg") assert.equal(link.url, "https://download.yun.139.com/photo.jpg") }) + + +test("Yun139 refreshes a near-expiry token with no Cookie header", async () => { + const expiresAt = Date.now() + 5 * 24 * 60 * 60 * 1000 + const oldToken = `old-token|1|RCS|${expiresAt}|opaque` + const newToken = `new-token|1|RCS|${Date.now() + 30 * 24 * 60 * 60 * 1000}|opaque` + const account = "13800138000" + const addition: Yun139Addition = { + authorization: Buffer.from(`pc:${account}:${oldToken}`).toString("base64"), + user_domain_id: "test-user-domain", + type: "personal_new", + } + // Simulate a legacy config containing PC cookies; the refresh request must ignore them. + ;(addition as any).pc_cloud_cookies = "mc_at=must-not-send; mc_bt=must-not-send" + + const originalFetch = globalThis.fetch + const calls: Array<{ url: string; init?: RequestInit }> = [] + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input) + calls.push({ url, init }) + + if (url === "https://user-njs.yun.139.com/user/auth/refreshToken") { + return new Response( + JSON.stringify({ + success: true, + code: "0000", + message: "请求成功", + data: { token: newToken, expireTime: 2592000 }, + }), + { + status: 200, + headers: { + "content-type": "application/json", + }, + }, + ) + } + + if (url === "https://user-njs.yun.139.com/user/route/qryRoutePolicy") { + return new Response( + JSON.stringify({ + success: true, + code: "0000", + data: { routePolicyList: [] }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + } + + return new Response("unexpected URL", { status: 404 }) + }) as typeof fetch + + try { + const refreshedAuthorizations: string[] = [] + const client = new Yun139ApiClient(addition, async (authorization) => { + refreshedAuthorizations.push(authorization) + }) + await client.init() + + assert.equal(calls[0]?.url, "https://user-njs.yun.139.com/user/auth/refreshToken") + assert.deepEqual(JSON.parse(String(calls[0]?.init?.body)), { + userDomainId: "test-user-domain", + }) + + const sentHeaders = calls[0]?.init?.headers as Record + assert.equal(sentHeaders.authorization, `Basic ${Buffer.from(`pc:${account}:${oldToken}`).toString("base64")}`) + assert.equal(Object.keys(sentHeaders).some((key) => key.toLowerCase() === "cookie"), false) + assert.equal(calls[0]?.init?.credentials, "omit") + assert.equal(sentHeaders.app_cp, "1") + assert.equal(sentHeaders["x-yun-api-version"], "v1") + assert.equal(sentHeaders["x-yun-app-channel"], "10200153") + assert.equal(sentHeaders["x-yun-market-source"], "1") + assert.equal(sentHeaders["x-yun-module-type"], "1") + assert.equal(sentHeaders["x-yun-client-info"], "PC") + assert.equal(Object.keys(sentHeaders).some((key) => key.toLowerCase() === "x-yun-uni"), false) + assert.equal(Object.keys(sentHeaders).some((key) => key.toLowerCase() === "x-deviceinfo"), false) + + assert.equal( + Buffer.from(addition.authorization, "base64").toString("utf8"), + `pc:${account}:${newToken}`, + ) + assert.deepEqual(refreshedAuthorizations, [addition.authorization]) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts index f5b24c47..70241b42 100644 --- a/src/backend/drivers/139/driver.ts +++ b/src/backend/drivers/139/driver.ts @@ -11,9 +11,12 @@ export class Yun139Driver implements StorageDriver { private addition: Yun139Addition private client: Yun139ApiClient - constructor(addition: Yun139Addition) { + constructor( + addition: Yun139Addition, + onAuthorizationRefresh?: (authorization: string) => Promise, + ) { this.addition = addition - this.client = new Yun139ApiClient(addition) + this.client = new Yun139ApiClient(addition, onAuthorizationRefresh) } async init(): Promise { diff --git a/src/backend/drivers/139/types.ts b/src/backend/drivers/139/types.ts index ebf676e9..4a98abe3 100644 --- a/src/backend/drivers/139/types.ts +++ b/src/backend/drivers/139/types.ts @@ -1,3 +1,13 @@ +export interface Yun139TokenRefreshResp { + success: boolean + code: string + message?: string + data?: { + token?: string + expireTime?: number + } +} + export interface Yun139Addition { authorization: string username?: string diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index 9e4eb4d0..b86b570c 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -9,6 +9,7 @@ import { PersonalListResp, PersonalDownloadResp, PersonalFileItem, + Yun139TokenRefreshResp, } from "./types" export function encodeURIComponentCustom(str: string): string { @@ -55,9 +56,14 @@ export class Yun139ApiClient { public familyHost = "https://yun.139.com" public groupHost = "https://yun.139.com" public account = "" + private onAuthorizationRefresh?: (authorization: string) => Promise - constructor(addition: Yun139Addition) { + constructor( + addition: Yun139Addition, + onAuthorizationRefresh?: (authorization: string) => Promise, + ) { this.addition = addition + this.onAuthorizationRefresh = onAuthorizationRefresh this.extractAccount() } @@ -104,6 +110,10 @@ export class Yun139ApiClient { } async request(uriOrUrl: string, body: any): Promise { + // Worker driver instances are cached; check at request time as well as init + // so a long-lived isolate cannot miss the refresh window. + await this.refreshAuthorizationIfNeeded() + const ts = formatTime(new Date()) const randStr = randomString(16) const bodyStr = JSON.stringify(body || {}) @@ -169,11 +179,102 @@ export class Yun139ApiClient { return json as T } + /** + * Refresh before the token enters its final 15 days. Called during init and + * before each API request because Worker driver instances may stay cached. + */ + private async refreshAuthorizationIfNeeded(): Promise { + const auth = this.getAuthString() + let decoded: string + try { + decoded = CryptoJS.enc.Base64.parse(auth).toString(CryptoJS.enc.Utf8) + } catch { + // Keep the previous behavior for non-standard authorization values. + return + } + + const pieces = decoded.split(":") + if (pieces.length < 3) return + + const token = pieces.slice(2).join(":") + const tokenFields = token.split("|") + const expiresAt = Number(tokenFields[3]) + if (!Number.isFinite(expiresAt) || expiresAt <= 0) return + + const refreshWindowMs = 15 * 24 * 60 * 60 * 1000 + if (expiresAt - Date.now() > refreshWindowMs) return + + const userDomainId = (this.addition.user_domain_id || "").trim() + if (!userDomainId) { + throw new Error( + "139 Cloud token is near expiry; configure user_domain_id to use the PC refreshToken API", + ) + } + + const authHeader = `Basic ${auth}` + + // Match the minimal header set from the successful PowerShell request. + // Do not send Cookie or unverified synthetic PC/device headers. + const headers: Record = { + Accept: "application/json", + "Content-Type": "application/json; charset=utf-8", + app_auth: authHeader, + app_cp: "1", + authorization: authHeader, + cp_version: "8.9.1.20260929", + "x-yun-api-version": "v1", + "x-yun-app-channel": "10200153", + "x-yun-op-type": "1", + "x-yun-svc-type": "1", + "x-yun-module-type": "1", + "x-yun-market-source": "1", + "x-yun-client-info": "PC", + } + + const response = await fetch( + "https://user-njs.yun.139.com/user/auth/refreshToken", + { + method: "POST", + // Omit captured cookies (mc_at, mc_bt, mc_pat, mc_pbt, mc_pac): + // refresh succeeded without them in our test; other sessions remain unverified. + credentials: "omit", + headers, + body: JSON.stringify({ userDomainId }), + }, + ) + + if (!response.ok) { + const body = await response.text() + throw new Error( + `139 Cloud PC token refresh failed (${response.status}): ${body}`, + ) + } + + const result = (await response.json()) as Yun139TokenRefreshResp + const newToken = result.data?.token?.trim() + if (!result.success || result.code !== "0000" || !newToken) { + throw new Error( + `139 Cloud PC token refresh failed: code=${result.code || "unknown"}, message=${result.message || "empty token"}`, + ) + } + const newExpiresAt = Number(newToken.split("|")[3]) + if (!Number.isFinite(newExpiresAt) || newExpiresAt <= Date.now()) { + throw new Error("139 Cloud PC refresh returned a token without a valid future expiry") + } + + this.addition.authorization = CryptoJS.enc.Base64.stringify( + CryptoJS.enc.Utf8.parse(`${pieces[0]}:${this.account}:${newToken}`), + ) + await this.onAuthorizationRefresh?.(this.addition.authorization) + } + async init(): Promise { if (!this.addition.authorization) { throw new Error("139 Cloud Authorization is required") } + await this.refreshAuthorizationIfNeeded() + try { const routeRes = await this.request( "https://user-njs.yun.139.com/user/route/qryRoutePolicy", diff --git a/src/backend/internal/op/storage.ts b/src/backend/internal/op/storage.ts index d2900a96..90b6c989 100644 --- a/src/backend/internal/op/storage.ts +++ b/src/backend/internal/op/storage.ts @@ -987,7 +987,28 @@ async function createDriver( normDriver.includes("139") ) { const addition = parseAddition(storageConfig) - driver = new Yun139Driver(addition) + driver = new Yun139Driver(addition, async (authorization) => { + try { + const db = await getDb() + const st = (db.storages || []).find( + (s: any) => s.id === storageConfig?.id, + ) + if (!st) return + const stAddition = + typeof st.addition === "string" + ? JSON.parse(st.addition || "{}") + : st.addition || {} + stAddition.authorization = authorization + st.addition = JSON.stringify(stAddition) + // Keep the in-flight admin update object in sync so it cannot overwrite + // the newly refreshed authorization with the previous value. + storageConfig.addition = st.addition + if (deferredTokenPersistence.has(storageConfig)) return + await saveDb(db) + } catch (e) { + console.warn("[139] failed to persist refreshed authorization:", e) + } + }) await driver.init?.() } else if ( normDriver === "mega" ||