From 7955de9565dd754f3a752c517ddf8180d0dec9df Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:09 +0800 Subject: [PATCH 1/7] feat(139): add PC token refresh --- src/backend/drivers/139/driver.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 { From bd86e56c392c06f6c82f439a92bf9e2acec72dce Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:12 +0800 Subject: [PATCH 2/7] feat(139): add PC token refresh --- src/backend/drivers/139/types.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 From 86222103ca18a9192ba943b8f650fb6b11d1e1b0 Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:15 +0800 Subject: [PATCH 3/7] feat(139): add PC token refresh --- src/backend/drivers/139/util.ts | 102 +++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index 9e4eb4d0..62557bc5 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,101 @@ 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", + // This refresh endpoint must work without inheriting any ambient session cookies. + 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", From 2849afbd4b18aade5b479bb6e2c6a73c82e9684d Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:19 +0800 Subject: [PATCH 4/7] feat(139): add PC token refresh tests --- src/backend/drivers/139/driver.test.ts | 88 +++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) 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 + } +}) From d8fd0a29e77b13942f710ed81e4ff4e0302819f8 Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:47:44 +0800 Subject: [PATCH 5/7] feat(139): persist refreshed authorization --- src/backend/internal/op/storage.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) 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" || From 8cb5a1170f85efbb09e507373ebbd527ef29e322 Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:43:57 +0800 Subject: [PATCH 6/7] docs(139): explain cookie-free refresh limitation --- src/backend/drivers/139/util.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index 62557bc5..47764832 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -235,7 +235,12 @@ export class Yun139ApiClient { "https://user-njs.yun.139.com/user/auth/refreshToken", { method: "POST", - // This refresh endpoint must work without inheriting any ambient session cookies. + // Cookie is intentionally omitted: the captured successful request + // worked without it, and this Worker has no reliable browser-cookie + // jar to forward. This does NOT prove cookies are unnecessary for every + // account/session. If refresh fails for cookie-bound sessions, follow + // up by defining an explicit, securely stored cookie input and tests + // for expiry/rotation; do not silently depend on ambient cookies. credentials: "omit", headers, body: JSON.stringify({ userDomainId }), From fb2fe3927c79c70f7f46f45a70bb5a2ed2cafbad Mon Sep 17 00:00:00 2001 From: UcnacDx2 <127503808+UcnacDx2@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:46:51 +0800 Subject: [PATCH 7/7] docs(139): shorten omitted-cookie note --- src/backend/drivers/139/util.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts index 47764832..b86b570c 100644 --- a/src/backend/drivers/139/util.ts +++ b/src/backend/drivers/139/util.ts @@ -235,12 +235,8 @@ export class Yun139ApiClient { "https://user-njs.yun.139.com/user/auth/refreshToken", { method: "POST", - // Cookie is intentionally omitted: the captured successful request - // worked without it, and this Worker has no reliable browser-cookie - // jar to forward. This does NOT prove cookies are unnecessary for every - // account/session. If refresh fails for cookie-bound sessions, follow - // up by defining an explicit, securely stored cookie input and tests - // for expiry/rotation; do not silently depend on ambient cookies. + // 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 }),