Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion src/backend/drivers/139/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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<string, string>
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
}
})
7 changes: 5 additions & 2 deletions src/backend/drivers/139/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>,
) {
this.addition = addition
this.client = new Yun139ApiClient(addition)
this.client = new Yun139ApiClient(addition, onAuthorizationRefresh)
}

async init(): Promise<void> {
Expand Down
10 changes: 10 additions & 0 deletions src/backend/drivers/139/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
103 changes: 102 additions & 1 deletion src/backend/drivers/139/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
PersonalListResp,
PersonalDownloadResp,
PersonalFileItem,
Yun139TokenRefreshResp,
} from "./types"

export function encodeURIComponentCustom(str: string): string {
Expand Down Expand Up @@ -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<void>

constructor(addition: Yun139Addition) {
constructor(
addition: Yun139Addition,
onAuthorizationRefresh?: (authorization: string) => Promise<void>,
) {
this.addition = addition
this.onAuthorizationRefresh = onAuthorizationRefresh
this.extractAccount()
}

Expand Down Expand Up @@ -104,6 +110,10 @@ export class Yun139ApiClient {
}

async request<T = any>(uriOrUrl: string, body: any): Promise<T> {
// 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 || {})
Expand Down Expand Up @@ -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<void> {
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<string, string> = {
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<void> {
if (!this.addition.authorization) {
throw new Error("139 Cloud Authorization is required")
}

await this.refreshAuthorizationIfNeeded()

try {
const routeRes = await this.request<QueryRoutePolicyResp>(
"https://user-njs.yun.139.com/user/route/qryRoutePolicy",
Expand Down
23 changes: 22 additions & 1 deletion src/backend/internal/op/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" ||
Expand Down
Loading