diff --git a/src/backend/drivers/139/driver.test.ts b/src/backend/drivers/139/driver.test.ts
index 15f13672..b2fa8fad 100644
--- a/src/backend/drivers/139/driver.test.ts
+++ b/src/backend/drivers/139/driver.test.ts
@@ -174,6 +174,158 @@ test("Yun139 refreshes a near-expiry token with no Cookie header", async () => {
}
})
+test("Yun139 keeps a valid near-expiry token and stays quiet when user_domain_id is not configured", async () => {
+ const expiresAt = Date.now() + 5 * 24 * 60 * 60 * 1000
+ const token = `legacy-token|1|RCS|${expiresAt}|opaque`
+ const authorization = Buffer.from(`pc:13800138000:${token}`).toString(
+ "base64",
+ )
+ const originalFetch = globalThis.fetch
+ const originalWarn = console.warn
+ const originalDebug = console.debug
+ const calls: string[] = []
+ const warnings: string[] = []
+ console.warn = (...args: any[]) => {
+ warnings.push(args.map((arg) => String(arg)).join(" "))
+ }
+ console.debug = () => {}
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = String(input)
+ calls.push(url)
+ if (
+ url === "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do"
+ ) {
+ return new Response(
+ "1unavailable",
+ { status: 200, headers: { "content-type": "application/xml" } },
+ )
+ }
+ assert.equal(
+ url,
+ "https://user-njs.yun.139.com/user/route/qryRoutePolicy",
+ "missing user_domain_id must not block the normal route policy request",
+ )
+ return new Response(
+ JSON.stringify({
+ success: true,
+ code: "0000",
+ data: {
+ routePolicyList: [
+ {
+ modName: "personal",
+ httpsUrl: "https://personal-kd-njs.yun.139.com/hcy",
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ )
+ }) as typeof fetch
+
+ try {
+ const client = new Yun139ApiClient({ authorization, type: "personal_new" })
+ await client.init()
+
+ assert.deepEqual(calls, [
+ "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do",
+ "https://user-njs.yun.139.com/user/route/qryRoutePolicy",
+ ])
+ assert.deepEqual(warnings, [], "near-expiry warning must be suppressed")
+ assert.equal(
+ Buffer.from(client.getAuthString(), "base64").toString("utf8"),
+ `pc:13800138000:${token}`,
+ )
+ } finally {
+ globalThis.fetch = originalFetch
+ console.warn = originalWarn
+ console.debug = originalDebug
+ }
+})
+
+test("Yun139 refreshes a near-expiry token via the legacy endpoint when user_domain_id is absent", async () => {
+ const account = "13800138000"
+ const oldToken = `legacy-old|1|RCS|${Date.now() + 5 * 24 * 60 * 60 * 1000}|opaque`
+ const newToken = `legacy-new|1|RCS|${Date.now() + 30 * 24 * 60 * 60 * 1000}|opaque`
+ const addition: Yun139Addition = {
+ authorization: Buffer.from(`pc:${account}:${oldToken}`).toString("base64"),
+ type: "personal_new",
+ }
+ 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://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do"
+ ) {
+ // `` 可能仍是原 token,只有过期时间更晚的候选才能写回。
+ return new Response(
+ `0${oldToken}` +
+ `${newToken}` +
+ "2592000",
+ { status: 200, headers: { "content-type": "application/xml" } },
+ )
+ }
+
+ if (url === "https://user-njs.yun.139.com/user/route/qryRoutePolicy") {
+ return new Response(
+ JSON.stringify({
+ success: true,
+ code: "0000",
+ data: {
+ routePolicyList: [
+ {
+ modName: "personal",
+ httpsUrl: "https://personal-kd-njs.yun.139.com/hcy",
+ },
+ ],
+ },
+ }),
+ { 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.deepEqual(
+ calls.map((call) => call.url),
+ [
+ "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do",
+ "https://user-njs.yun.139.com/user/route/qryRoutePolicy",
+ ],
+ )
+ const refreshCall = calls[0]
+ assert.equal(refreshCall.init?.method, "POST")
+ const headers = refreshCall.init?.headers as Record
+ assert.equal(headers["Content-Type"], "application/xml;charset=UTF-8")
+ assert.equal(headers.Referer, "https://yun.139.com/")
+ const body = String(refreshCall.init?.body)
+ assert.ok(body.includes(`${oldToken}`))
+ assert.ok(body.includes(`${account}`))
+ assert.ok(body.includes("656"))
+
+ assert.equal(
+ Buffer.from(addition.authorization, "base64").toString("utf8"),
+ `pc:${account}:${newToken}`,
+ )
+ assert.deepEqual(refreshedAuthorizations, [addition.authorization])
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
test("Yun139Driver lists subdir contents; file path must not degrade to parent (issue #112)", async () => {
const addition: Yun139Addition = {
authorization: Buffer.from(
diff --git a/src/backend/drivers/139/driver.ts b/src/backend/drivers/139/driver.ts
index cf38306f..8d208a5a 100644
--- a/src/backend/drivers/139/driver.ts
+++ b/src/backend/drivers/139/driver.ts
@@ -5,6 +5,7 @@ import {
} from "../../internal/driver/base"
import { sortFileItems } from "../../internal/driver/sort"
import { Yun139Addition } from "./types"
+import { Yun139LoginResult } from "./login"
import { Yun139ApiClient } from "./util"
export class Yun139Driver implements StorageDriver {
@@ -14,9 +15,14 @@ export class Yun139Driver implements StorageDriver {
constructor(
addition: Yun139Addition,
onAuthorizationRefresh?: (authorization: string) => Promise,
+ onLoginStateChange?: (state: Yun139LoginResult) => Promise,
) {
this.addition = addition
- this.client = new Yun139ApiClient(addition, onAuthorizationRefresh)
+ this.client = new Yun139ApiClient(
+ addition,
+ onAuthorizationRefresh,
+ onLoginStateChange,
+ )
}
async init(): Promise {
diff --git a/src/backend/drivers/139/login.test.ts b/src/backend/drivers/139/login.test.ts
new file mode 100644
index 00000000..40f8c8aa
--- /dev/null
+++ b/src/backend/drivers/139/login.test.ts
@@ -0,0 +1,475 @@
+import test from "node:test"
+import assert from "node:assert/strict"
+import { constants, generateKeyPairSync, privateDecrypt } from "node:crypto"
+import CryptoJS from "crypto-js"
+import { Yun139Addition } from "./types"
+import { Yun139ApiClient } from "./util"
+import {
+ aesCbcDecrypt,
+ aesCbcEncrypt,
+ aesEcbDecrypt,
+ aesEcbEncrypt,
+ loginWithCredentials,
+ rsaEncryptPKCS1v15,
+ sortedJsonStringify,
+ Yun139SmsRequiredError,
+} from "./login"
+
+const KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d"
+const KEY_HEX_2 = "7150714477323633586746674c337538"
+const MAIL_PASSWORD_URL = "https://mail.10086.cn/Login/Login.ashx"
+const SSO_LOGIN_URL = "https://user-njs.yun.139.com/user/thirdlogin"
+const ROUTE_POLICY_URL =
+ "https://user-njs.yun.139.com/user/route/qryRoutePolicy"
+
+function bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("")
+}
+
+function encryptCbcBase64(plaintext: string, keyHex: string): string {
+ const iv = CryptoJS.lib.WordArray.random(16)
+ const encrypted = CryptoJS.AES.encrypt(
+ CryptoJS.enc.Utf8.parse(plaintext),
+ CryptoJS.enc.Hex.parse(keyHex),
+ {
+ iv,
+ mode: CryptoJS.mode.CBC,
+ padding: CryptoJS.pad.Pkcs7,
+ },
+ )
+ return CryptoJS.enc.Base64.stringify(iv.clone().concat(encrypted.ciphertext))
+}
+
+function encryptedStep3Response(payload: any): string {
+ const finalJson = JSON.stringify(payload)
+ const inner = bytesToHex(aesEcbEncrypt(finalJson, KEY_HEX_2))
+ return encryptCbcBase64(JSON.stringify({ data: inner }), KEY_HEX_1)
+}
+
+function routePolicyResponse(): Response {
+ return new Response(
+ JSON.stringify({
+ success: true,
+ code: "0000",
+ data: {
+ routePolicyList: [
+ {
+ modName: "personal",
+ httpsUrl: "https://personal-kd-njs.yun.139.com/hcy",
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ )
+}
+
+test("Yun139 sorted JSON matches Go sortedJsonStringify", () => {
+ assert.equal(
+ sortedJsonStringify({
+ z: true,
+ a: { d: "x", c: [2, 1], e: null },
+ n: 3,
+ }),
+ '{"a":{"c":[2,1],"d":"x","e":null},"n":3,"z":true}',
+ )
+})
+
+test("Yun139 AES CBC/ECB roundtrip", () => {
+ const { iv, ciphertext } = aesCbcEncrypt("hello 139", KEY_HEX_1)
+ assert.equal(aesCbcDecrypt(ciphertext, KEY_HEX_1, iv), "hello 139")
+ const ecb = aesEcbEncrypt("outer", KEY_HEX_2)
+ assert.equal(aesEcbDecrypt(ecb, KEY_HEX_2), "outer")
+})
+
+test("Yun139 RSA PKCS1 v1.5 encryption decrypts with the matching private key", () => {
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", {
+ modulusLength: 1024,
+ })
+ const der = publicKey.export({ type: "spki", format: "der" }) as Buffer
+ const ciphertext = rsaEncryptPKCS1v15(
+ Buffer.from("13800138000"),
+ der.toString("base64"),
+ )
+ const decrypted = privateDecrypt(
+ { key: privateKey, padding: constants.RSA_PKCS1_PADDING },
+ Buffer.from(ciphertext),
+ )
+ assert.equal(decrypted.toString("utf8"), "13800138000")
+})
+
+test("Yun139 password login obtains account, user_domain_id and authorization", async () => {
+ const addition: Yun139Addition = {
+ authorization: "",
+ username: "13800138000",
+ password: "secret",
+ type: "personal_new",
+ }
+ const originalFetch = globalThis.fetch
+ const calls: string[] = []
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = String(input)
+ calls.push(url)
+ if (url === MAIL_PASSWORD_URL) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "https://mail.10086.cn/default.html?sid=test-sid",
+ "Set-Cookie": "RMKEY=test-rmkey; Path=/; HttpOnly",
+ },
+ })
+ }
+ if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) {
+ return new Response(
+ JSON.stringify({ var: { artifact: "test-artifact" } }),
+ {
+ status: 200,
+ },
+ )
+ }
+ if (url === SSO_LOGIN_URL) {
+ return new Response(
+ encryptedStep3Response({
+ authToken: "test-token",
+ account: "13800138000",
+ userDomainId: "domain-123",
+ }),
+ { status: 200 },
+ )
+ }
+ return new Response("unexpected URL", { status: 404 })
+ }) as typeof fetch
+
+ try {
+ const result = await loginWithCredentials(addition)
+ assert.equal(result.account, "13800138000")
+ assert.equal(result.userDomainId, "domain-123")
+ assert.equal(
+ result.authorization,
+ Buffer.from("pc:13800138000:test-token").toString("base64"),
+ )
+ assert.ok(result.mailCookies.includes("RMKEY=test-rmkey"))
+ assert.equal(calls.length, 3)
+ assert.equal(calls[0], MAIL_PASSWORD_URL)
+ assert.ok(calls[1].startsWith("https://smsrebuild1.mail.10086.cn/"))
+ assert.equal(calls[2], SSO_LOGIN_URL)
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
+test("Yun139 login surfaces SMS verification and preserves refreshed cookies", async () => {
+ const addition: Yun139Addition = {
+ authorization: "",
+ username: "13800138000",
+ password: "secret",
+ type: "personal_new",
+ }
+ const originalFetch = globalThis.fetch
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = String(input)
+ if (url === MAIL_PASSWORD_URL) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "https://mail.10086.cn/default.html?ec=PML401010062",
+ },
+ })
+ }
+ if (url.startsWith("https://mail.10086.cn/s?")) {
+ return new Response(JSON.stringify({ code: "S_OK" }), {
+ status: 200,
+ headers: { "Set-Cookie": "Os_SSo_Sid=test-sid; Path=/" },
+ })
+ }
+ return new Response("unexpected URL", { status: 404 })
+ }) as typeof fetch
+
+ try {
+ await assert.rejects(
+ () => loginWithCredentials(addition),
+ (error: unknown) => {
+ assert.ok(error instanceof Yun139SmsRequiredError)
+ assert.ok(error.mailCookies.includes("Os_SSo_Sid=test-sid"))
+ assert.match(error.message, /fill sms_code/)
+ return true
+ },
+ )
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
+test("Yun139 login verifies the SMS code and continues the password flow", async () => {
+ const addition: Yun139Addition = {
+ authorization: "",
+ username: "13800138000",
+ password: "secret",
+ sms_code: "123456",
+ type: "personal_new",
+ }
+ const originalFetch = globalThis.fetch
+ const calls: string[] = []
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = String(input)
+ calls.push(url)
+ if (url === MAIL_PASSWORD_URL) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "https://mail.10086.cn/default.html?ec=MW0016",
+ },
+ })
+ }
+ if (url.startsWith("https://mail.10086.cn/s?")) {
+ return new Response(
+ JSON.stringify({
+ code: "S_OK",
+ var: {
+ loginSuccessUrl: "https://mail.10086.cn/default.html?sid=sms-sid",
+ },
+ }),
+ { status: 200, headers: { "Set-Cookie": "RMKEY=test-rmkey; Path=/" } },
+ )
+ }
+ if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) {
+ return new Response(
+ JSON.stringify({ var: { artifact: "test-artifact" } }),
+ { status: 200 },
+ )
+ }
+ if (url === SSO_LOGIN_URL) {
+ return new Response(
+ encryptedStep3Response({
+ authToken: "test-token",
+ account: "13800138000",
+ userDomainId: "domain-sms",
+ }),
+ { status: 200 },
+ )
+ }
+ return new Response("unexpected URL", { status: 404 })
+ }) as typeof fetch
+
+ try {
+ const result = await loginWithCredentials(addition)
+ assert.equal(result.userDomainId, "domain-sms")
+ assert.equal(calls.length, 4)
+ assert.ok(calls[1].includes("inlogin.action"))
+ assert.equal(calls[3], SSO_LOGIN_URL)
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
+test("Yun139 fast login with existing mail cookies obtains user_domain_id", async () => {
+ const addition: Yun139Addition = {
+ authorization: "",
+ mail_cookies: "Os_SSo_Sid=test-sid; RMKEY=test-rmkey",
+ type: "personal_new",
+ }
+ const originalFetch = globalThis.fetch
+ const calls: string[] = []
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = String(input)
+ calls.push(url)
+ if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) {
+ return new Response(
+ JSON.stringify({ var: { artifact: "test-artifact" } }),
+ {
+ status: 200,
+ },
+ )
+ }
+ if (url === SSO_LOGIN_URL) {
+ return new Response(
+ encryptedStep3Response({
+ authToken: "test-token",
+ account: "13800138000",
+ userDomainId: "domain-fast",
+ }),
+ { status: 200 },
+ )
+ }
+ return new Response("unexpected URL", { status: 404 })
+ }) as typeof fetch
+
+ try {
+ const result = await loginWithCredentials(addition)
+ assert.equal(result.userDomainId, "domain-fast")
+ assert.equal(result.mailCookies, addition.mail_cookies)
+ assert.equal(calls.length, 2)
+ assert.equal(calls[1], SSO_LOGIN_URL)
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
+test("Yun139 re-logins when the PC refresh fails and credentials are available", async () => {
+ const oldToken = `old-token|1|RCS|${Date.now() + 5 * 24 * 60 * 60 * 1000}|opaque`
+ const addition: Yun139Addition = {
+ authorization: Buffer.from(`pc:13800138000:${oldToken}`).toString("base64"),
+ user_domain_id: "old-domain",
+ username: "13800138000",
+ password: "secret",
+ type: "personal_new",
+ }
+ const originalFetch = globalThis.fetch
+ const calls: string[] = []
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = String(input)
+ calls.push(url)
+ if (url === "https://user-njs.yun.139.com/user/auth/refreshToken") {
+ return new Response("refresh rejected", { status: 500 })
+ }
+ if (url === MAIL_PASSWORD_URL) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "https://mail.10086.cn/default.html?sid=test-sid",
+ "Set-Cookie": "RMKEY=test-rmkey; Path=/",
+ },
+ })
+ }
+ if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) {
+ return new Response(
+ JSON.stringify({ var: { artifact: "test-artifact" } }),
+ { status: 200 },
+ )
+ }
+ if (url === SSO_LOGIN_URL) {
+ return new Response(
+ encryptedStep3Response({
+ authToken: "test-token",
+ account: "13800138000",
+ userDomainId: "domain-reborn",
+ }),
+ { status: 200 },
+ )
+ }
+ if (url === ROUTE_POLICY_URL) return routePolicyResponse()
+ return new Response("unexpected URL", { status: 404 })
+ }) as typeof fetch
+
+ try {
+ const states: Array<{ authorization: string; userDomainId: string }> = []
+ const client = new Yun139ApiClient(addition, undefined, async (state) => {
+ states.push({
+ authorization: state.authorization,
+ userDomainId: state.userDomainId,
+ })
+ })
+ await client.init()
+
+ assert.equal(addition.user_domain_id, "domain-reborn")
+ assert.equal(
+ addition.authorization,
+ Buffer.from("pc:13800138000:test-token").toString("base64"),
+ )
+ assert.equal(states.length, 1)
+ assert.equal(states[0].userDomainId, "domain-reborn")
+ assert.equal(
+ calls[0],
+ "https://user-njs.yun.139.com/user/auth/refreshToken",
+ )
+ assert.equal(calls.filter((url) => url === ROUTE_POLICY_URL).length, 1)
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
+
+test("Yun139 keeps a manually configured user_domain_id when Authorization is present", async () => {
+ let fetchCalls = 0
+ const result = await loginWithCredentials(
+ {
+ authorization: "existing-authorization",
+ user_domain_id: "manual-domain",
+ type: "personal_new",
+ },
+ (async () => {
+ fetchCalls++
+ return new Response("should not be called", { status: 500 })
+ }) as typeof fetch,
+ )
+ assert.equal(result.authorization, "existing-authorization")
+ assert.equal(result.userDomainId, "manual-domain")
+ assert.equal(fetchCalls, 0)
+})
+
+test("Yun139 rejects empty credentials with a clear error", async () => {
+ await assert.rejects(
+ () =>
+ new Yun139ApiClient({
+ authorization: "",
+ type: "personal_new",
+ }).init(),
+ /authorization is empty and credentials are not provided/,
+ )
+})
+
+test("Yun139 init logs in and persists authorization plus user_domain_id", async () => {
+ const addition: Yun139Addition = {
+ authorization: "",
+ username: "13800138000",
+ password: "secret",
+ type: "personal_new",
+ }
+ const originalFetch = globalThis.fetch
+ const calls: string[] = []
+ globalThis.fetch = (async (input: string | URL | Request) => {
+ const url = String(input)
+ calls.push(url)
+ if (url === MAIL_PASSWORD_URL) {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: "https://mail.10086.cn/default.html?sid=test-sid",
+ "Set-Cookie": "RMKEY=test-rmkey; Path=/",
+ },
+ })
+ }
+ if (url.startsWith("https://smsrebuild1.mail.10086.cn/setting/s?")) {
+ return new Response(
+ JSON.stringify({ var: { artifact: "test-artifact" } }),
+ { status: 200 },
+ )
+ }
+ if (url === SSO_LOGIN_URL) {
+ return new Response(
+ encryptedStep3Response({
+ authToken: "test-token",
+ account: "13800138000",
+ userDomainId: "domain-123",
+ }),
+ { status: 200 },
+ )
+ }
+ if (url === ROUTE_POLICY_URL) return routePolicyResponse()
+ return new Response("unexpected URL", { status: 404 })
+ }) as typeof fetch
+
+ try {
+ const states: Array<{ authorization: string; userDomainId: string }> = []
+ const client = new Yun139ApiClient(addition, undefined, async (state) => {
+ states.push({
+ authorization: state.authorization,
+ userDomainId: state.userDomainId,
+ })
+ })
+ await client.init()
+
+ assert.equal(addition.user_domain_id, "domain-123")
+ assert.equal(
+ addition.authorization,
+ Buffer.from("pc:13800138000:test-token").toString("base64"),
+ )
+ assert.equal(states.length, 1)
+ assert.equal(states[0].userDomainId, "domain-123")
+ assert.equal(states[0].authorization, addition.authorization)
+ assert.equal(calls.filter((url) => url === ROUTE_POLICY_URL).length, 1)
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+})
diff --git a/src/backend/drivers/139/login.ts b/src/backend/drivers/139/login.ts
new file mode 100644
index 00000000..c031e1b6
--- /dev/null
+++ b/src/backend/drivers/139/login.ts
@@ -0,0 +1,893 @@
+import CryptoJS from "crypto-js"
+import { Yun139Addition } from "./types"
+
+const KEY_HEX_1 = "73634235495062495331515373756c734e7253306c673d3d"
+const KEY_HEX_2 = "7150714477323633586746674c337538"
+const MAIL_PUBLIC_KEY =
+ "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnsOHTFtwW5rq/8gGhPlM5Z3RPdeN/d+FYIHb5JmcfBOCozXuT8c+0anvxtkjzghixwNlnmBuhN8OYfS789YuH/qReQbHC7OdlisLildNWPHRNUYcPa0W3lXSG3+81CXK7FDXPvXo5ubw2GqVbIsccMarI1dyfXdi4ITiCXvmM9wYBdUs9yXtoorhlpyYUI2GV8HNsQjWK9P5QZHT3ox5Qy+mjRmvv6RUFJLPOMkOS/pGZ0DwC1ypFZBxstW0/ftVupdOmGWvW7J2/e3dq3A/UvIkC4YUY/diL1wighJx1G9MiRROISjNMvNyUDSTqPJy516+l3sgHEbc067QIJx2NQIDAQAB"
+const MAIL_PASSWORD_URL = "https://mail.10086.cn/Login/Login.ashx"
+const MAIL_SMS_URL = "https://mail.10086.cn/s"
+const SSO_LOGIN_URL = "https://user-njs.yun.139.com/user/thirdlogin"
+
+export interface Yun139LoginResult {
+ authorization: string
+ account: string
+ userDomainId: string
+ mailCookies: string
+ smsCode: string
+}
+
+/** 邮箱登录触发了短信风控,需要用户填写 sms_code 后重新保存。 */
+export class Yun139SmsRequiredError extends Error {
+ constructor(
+ message: string,
+ public readonly mailCookies: string,
+ ) {
+ super(message)
+ this.name = "Yun139SmsRequiredError"
+ }
+}
+
+type MailLoginResponse = {
+ code: string
+ summary: string
+ loginSuccessUrl: string
+}
+
+export function sha1Hex(value: string): string {
+ return CryptoJS.SHA1(value).toString(CryptoJS.enc.Hex)
+}
+
+export function sortedJsonStringify(value: any): string {
+ if (value === null || value === undefined) return "null"
+ if (typeof value === "string") {
+ try {
+ return sortedJsonStringify(JSON.parse(value))
+ } catch {
+ return JSON.stringify(value)
+ }
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value)
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => sortedJsonStringify(item)).join(",")}]`
+ }
+ if (typeof value === "object") {
+ const keys = Object.keys(value).sort()
+ return `{${keys
+ .map((key) => `${JSON.stringify(key)}:${sortedJsonStringify(value[key])}`)
+ .join(",")}}`
+ }
+ return JSON.stringify(value)
+}
+
+function bytesToHex(bytes: Uint8Array): string {
+ return Array.from(bytes)
+ .map((byte) => byte.toString(16).padStart(2, "0"))
+ .join("")
+}
+
+function hexToBytes(hex: string): Uint8Array {
+ if (hex.length % 2 !== 0) throw new Error("invalid hex string")
+ const bytes = new Uint8Array(hex.length / 2)
+ for (let i = 0; i < bytes.length; i++) {
+ const value = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
+ if (Number.isNaN(value)) throw new Error("invalid hex string")
+ bytes[i] = value
+ }
+ return bytes
+}
+
+function bytesToWordArray(bytes: Uint8Array): CryptoJS.lib.WordArray {
+ return CryptoJS.enc.Hex.parse(bytesToHex(bytes))
+}
+
+function bytesToBase64(bytes: Uint8Array): string {
+ return CryptoJS.enc.Base64.stringify(bytesToWordArray(bytes))
+}
+
+function base64ToBytes(value: string): Uint8Array {
+ const hex = CryptoJS.enc.Base64.parse(value).toString(CryptoJS.enc.Hex)
+ return hexToBytes(hex)
+}
+
+function concatBytes(...parts: Uint8Array[]): Uint8Array {
+ const total = parts.reduce((sum, part) => sum + part.length, 0)
+ const result = new Uint8Array(total)
+ let offset = 0
+ for (const part of parts) {
+ result.set(part, offset)
+ offset += part.length
+ }
+ return result
+}
+
+function randomBytes(length: number): Uint8Array {
+ const bytes = new Uint8Array(length)
+ crypto.getRandomValues(bytes)
+ return bytes
+}
+
+export function aesCbcEncrypt(
+ plaintext: string,
+ keyHex: string,
+): { iv: Uint8Array; ciphertext: Uint8Array } {
+ const key = CryptoJS.enc.Hex.parse(keyHex)
+ const iv = randomBytes(16)
+ const encrypted = CryptoJS.AES.encrypt(
+ CryptoJS.enc.Utf8.parse(plaintext),
+ key,
+ {
+ iv: bytesToWordArray(iv),
+ mode: CryptoJS.mode.CBC,
+ padding: CryptoJS.pad.Pkcs7,
+ },
+ )
+ return {
+ iv,
+ ciphertext: hexToBytes(encrypted.ciphertext.toString(CryptoJS.enc.Hex)),
+ }
+}
+
+export function aesCbcDecrypt(
+ ciphertext: Uint8Array,
+ keyHex: string,
+ iv: Uint8Array,
+): string {
+ const decrypted = CryptoJS.AES.decrypt(
+ { ciphertext: bytesToWordArray(ciphertext) } as any,
+ CryptoJS.enc.Hex.parse(keyHex),
+ {
+ iv: bytesToWordArray(iv),
+ mode: CryptoJS.mode.CBC,
+ padding: CryptoJS.pad.Pkcs7,
+ },
+ )
+ return decrypted.toString(CryptoJS.enc.Utf8)
+}
+
+export function aesEcbDecrypt(ciphertext: Uint8Array, keyHex: string): string {
+ const decrypted = CryptoJS.AES.decrypt(
+ { ciphertext: bytesToWordArray(ciphertext) } as any,
+ CryptoJS.enc.Hex.parse(keyHex),
+ {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ },
+ )
+ return decrypted.toString(CryptoJS.enc.Utf8)
+}
+
+export function aesEcbEncrypt(plaintext: string, keyHex: string): Uint8Array {
+ const encrypted = CryptoJS.AES.encrypt(
+ CryptoJS.enc.Utf8.parse(plaintext),
+ CryptoJS.enc.Hex.parse(keyHex),
+ {
+ mode: CryptoJS.mode.ECB,
+ padding: CryptoJS.pad.Pkcs7,
+ },
+ )
+ return hexToBytes(encrypted.ciphertext.toString(CryptoJS.enc.Hex))
+}
+
+type DerElement = { tag: number; start: number; end: number }
+
+function readDer(bytes: Uint8Array, offset: number): DerElement {
+ if (offset + 2 > bytes.length) throw new Error("invalid DER")
+ const tag = bytes[offset]
+ let length = bytes[offset + 1]
+ offset += 2
+ if (length & 0x80) {
+ const count = length & 0x7f
+ if (count === 0 || offset + count > bytes.length) {
+ throw new Error("invalid DER length")
+ }
+ length = 0
+ for (let i = 0; i < count; i++) {
+ length = (length << 8) | bytes[offset + i]
+ }
+ offset += count
+ }
+ if (offset + length > bytes.length) throw new Error("invalid DER payload")
+ return { tag, start: offset, end: offset + length }
+}
+
+function bytesToBigInt(bytes: Uint8Array): bigint {
+ let hex = bytesToHex(bytes)
+ hex = hex.replace(/^0+/, "")
+ return hex ? BigInt(`0x${hex}`) : 0n
+}
+
+function modPow(base: bigint, exponent: bigint, modulus: bigint): bigint {
+ let result = 1n
+ let b = base % modulus
+ let e = exponent
+ while (e > 0n) {
+ if (e & 1n) result = (result * b) % modulus
+ b = (b * b) % modulus
+ e >>= 1n
+ }
+ return result
+}
+
+export function parseRsaPublicKey(der: Uint8Array): {
+ modulus: bigint
+ exponent: bigint
+} {
+ const outer = readDer(der, 0)
+ if (outer.tag !== 0x30) throw new Error("invalid RSA public key")
+ const algorithm = readDer(der, outer.start)
+ const bitString = readDer(der, algorithm.end)
+ if (bitString.tag !== 0x03)
+ throw new Error("invalid RSA public key bit string")
+ const inner = readDer(der, bitString.start + 1) // skip unused-bits byte
+ const modulusElement = readDer(der, inner.start)
+ const exponentElement = readDer(der, modulusElement.end)
+ if (modulusElement.tag !== 0x02 || exponentElement.tag !== 0x02) {
+ throw new Error("invalid RSA public key integers")
+ }
+ return {
+ modulus: bytesToBigInt(der.slice(modulusElement.start, modulusElement.end)),
+ exponent: bytesToBigInt(
+ der.slice(exponentElement.start, exponentElement.end),
+ ),
+ }
+}
+
+/** RSAES-PKCS1-v1_5,对应 Go 的 rsa.EncryptPKCS1v15。 */
+export function rsaEncryptPKCS1v15(
+ message: Uint8Array,
+ publicKeyDerBase64: string,
+): Uint8Array {
+ const der = base64ToBytes(publicKeyDerBase64)
+ const { modulus, exponent } = parseRsaPublicKey(der)
+ const keyLength = Math.ceil(modulus.toString(16).length / 2)
+ if (message.length > keyLength - 11) {
+ throw new Error("RSA message too long")
+ }
+
+ const padding = randomBytes(keyLength - message.length - 3)
+ for (let i = 0; i < padding.length; i++) {
+ while (padding[i] === 0) padding[i] = randomBytes(1)[0]
+ }
+
+ const encoded = new Uint8Array(keyLength)
+ encoded[0] = 0
+ encoded[1] = 2
+ encoded.set(padding, 2)
+ encoded[2 + padding.length] = 0
+ encoded.set(message, 3 + padding.length)
+
+ const ciphertext = modPow(bytesToBigInt(encoded), exponent, modulus).toString(
+ 16,
+ )
+ return hexToBytes(ciphertext.padStart(keyLength * 2, "0"))
+}
+
+export function parseCookies(
+ raw: string,
+): Array<{ name: string; value: string }> {
+ const cookies: Array<{ name: string; value: string }> = []
+ for (const part of (raw || "").split(";")) {
+ const item = part.trim()
+ if (!item) continue
+ const index = item.indexOf("=")
+ if (index <= 0) continue
+ const name = item.slice(0, index).trim()
+ const value = item.slice(index + 1).trim()
+ if (!name) continue
+ const existing = cookies.find((cookie) => cookie.name === name)
+ if (existing) existing.value = value
+ else cookies.push({ name, value })
+ }
+ return cookies
+}
+
+export function cookiesToString(
+ cookies: Array<{ name: string; value: string }>,
+): string {
+ return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join(";")
+}
+
+export function setCookie(raw: string, name: string, value: string): string {
+ const cookies = parseCookies(raw)
+ const existing = cookies.find((cookie) => cookie.name === name)
+ if (existing) existing.value = value
+ else cookies.push({ name, value })
+ return cookiesToString(cookies)
+}
+
+export function getCookie(raw: string, name: string): string {
+ return parseCookies(raw).find((cookie) => cookie.name === name)?.value || ""
+}
+
+export function hasCookiePair(raw: string): boolean {
+ return parseCookies(raw).some(
+ (cookie) => cookie.name !== "" && cookie.value !== "",
+ )
+}
+
+export function extractFastLoginCookies(mailCookies: string): {
+ sid: string
+ rmkey: string
+} {
+ const cookies = parseCookies(mailCookies)
+ return {
+ sid:
+ cookies.find((cookie) => cookie.name === "Os_SSo_Sid")?.value ||
+ cookies.find((cookie) => cookie.name === "sid")?.value ||
+ "",
+ rmkey: cookies.find((cookie) => cookie.name === "RMKEY")?.value || "",
+ }
+}
+
+function getSetCookieHeaders(response: Response): string[] {
+ const headers = response.headers as Headers & {
+ getAll?: (name: string) => string[]
+ getSetCookie?: () => string[]
+ }
+ // Cloudflare Workers exposes Set-Cookie through the non-standard getAll().
+ if (typeof headers.getAll === "function") {
+ try {
+ return headers.getAll("Set-Cookie")
+ } catch {
+ // fall through to the standard accessors
+ }
+ }
+ if (typeof headers.getSetCookie === "function") {
+ return headers.getSetCookie()
+ }
+ const raw = response.headers.get("set-cookie")
+ if (!raw) return []
+ // Fallback for runtimes without getSetCookie: the cookies used here do not
+ // contain commas in their values, so split before the next `name=`.
+ return raw.split(/,(?=\s*[^;,=\s]+=)/)
+}
+
+export function mergeResponseCookies(
+ mailCookies: string,
+ response: Response,
+): string {
+ let merged = mailCookies
+ for (const header of getSetCookieHeaders(response)) {
+ const pair = header.split(";")[0].trim()
+ const index = pair.indexOf("=")
+ if (index <= 0) continue
+ const name = pair.slice(0, index).trim()
+ const value = pair.slice(index + 1).trim()
+ if (!name) continue
+ merged = setCookie(merged, name, value)
+ }
+ return merged
+}
+
+function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'")
+}
+
+function mailXmlField(name: string, value: string): string {
+ return `${escapeXml(value)}`
+}
+
+function parseMailLoginResponse(text: string): MailLoginResponse {
+ try {
+ const json = JSON.parse(text)
+ const response = {
+ code: String(json?.code || ""),
+ summary: String(json?.summary || ""),
+ loginSuccessUrl: String(json?.var?.loginSuccessUrl || ""),
+ }
+ if (response.code || response.summary || response.loginSuccessUrl) {
+ return response
+ }
+ } catch {
+ // Fall back to the regex extraction used by the Go driver.
+ }
+ const pick = (name: string): string =>
+ new RegExp(`['"]?${name}['"]?\\s*:\\s*['"]([^'"]+)`).exec(text)?.[1] || ""
+ return {
+ code: pick("code"),
+ summary: pick("summary"),
+ loginSuccessUrl: pick("loginSuccessUrl"),
+ }
+}
+
+function smsSceneForRisk(riskCode: string): number | null {
+ switch (riskCode) {
+ case "PML401010062":
+ return 2
+ case "MW0016":
+ return 4
+ case "S025":
+ case "S035":
+ return 1
+ default:
+ return null
+ }
+}
+
+function mailRiskCode(location: string): string {
+ try {
+ return new URL(location, MAIL_PASSWORD_URL).searchParams.get("ec") || ""
+ } catch {
+ return ""
+ }
+}
+
+function mailHeaders(cookie: string): Record {
+ const headers: Record = {
+ "Content-Type": "application/xml; charset=utf-8",
+ "Accept-Encoding": "gzip",
+ "User-Agent": "okhttp/4.12.0",
+ }
+ if (cookie) headers.Cookie = cookie
+ return headers
+}
+
+function base64Of(value: string): string {
+ return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(value))
+}
+
+async function sendSmsCode(
+ username: string,
+ riskCode: string,
+ mailCookies: string,
+ fetchImpl: typeof fetch,
+): Promise {
+ const scene = smsSceneForRisk(riskCode)
+ if (scene === null) {
+ throw new Error(
+ `139 Mail risk control does not support SMS verification: ${riskCode}`,
+ )
+ }
+ const loginName = bytesToBase64(
+ rsaEncryptPKCS1v15(new TextEncoder().encode(username), MAIL_PUBLIC_KEY),
+ )
+ const body = [
+ "",
+ ].join("")
+ const url = `${MAIL_SMS_URL}?func=${encodeURIComponent("login:sendSmsCodeByScene")}&cguid=${Date.now()}`
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: mailHeaders(mailCookies),
+ body,
+ })
+ const merged = mergeResponseCookies(mailCookies, response)
+ const result = parseMailLoginResponse(await response.text())
+ if (result.code === "S_OK") return merged
+ switch (result.code) {
+ case "PML401010021":
+ case "PML401010022":
+ throw new Error(
+ `139 Mail requires picture verification before SMS can be sent: ${result.code}`,
+ )
+ case "PML404010001":
+ throw new Error(
+ "139 Mail SMS verification code was requested too frequently",
+ )
+ case "PML401010002":
+ throw new Error("139 Mail rejected the SMS verification parameters")
+ default:
+ throw new Error(
+ `send 139 Mail SMS verification code failed: code=${result.code} summary=${result.summary}`,
+ )
+ }
+}
+
+async function verifySmsCode(
+ username: string,
+ riskCode: string,
+ smsCode: string,
+ mailCookies: string,
+ fetchImpl: typeof fetch,
+): Promise<{ sid: string; mailCookies: string }> {
+ if (smsSceneForRisk(riskCode) === null) {
+ throw new Error(
+ `139 Mail risk control does not support SMS verification: ${riskCode}`,
+ )
+ }
+ const loginName = bytesToBase64(
+ rsaEncryptPKCS1v15(new TextEncoder().encode(username), MAIL_PUBLIC_KEY),
+ )
+ const fields = [
+ "")
+
+ const url = `${MAIL_SMS_URL}?func=${encodeURIComponent("/login/inlogin.action")}&cguid=${Date.now()}`
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: mailHeaders(mailCookies),
+ body: fields.join(""),
+ })
+ const merged = mergeResponseCookies(mailCookies, response)
+ const result = parseMailLoginResponse(await response.text())
+ if (result.code !== "S_OK") {
+ throw new Error(
+ `verify 139 Mail SMS code failed: code=${result.code} summary=${result.summary}`,
+ )
+ }
+
+ let sid = ""
+ if (result.loginSuccessUrl) {
+ try {
+ sid =
+ new URL(result.loginSuccessUrl, MAIL_PASSWORD_URL).searchParams.get(
+ "sid",
+ ) || ""
+ } catch {
+ sid = ""
+ }
+ }
+ if (!sid) {
+ sid = getCookie(merged, "Os_SSo_Sid") || getCookie(merged, "sid") || ""
+ }
+ if (!sid) {
+ throw new Error(
+ "139 Mail SMS verification succeeded but did not return sid",
+ )
+ }
+ return { sid, mailCookies: merged }
+}
+
+async function step1PasswordLogin(
+ addition: Yun139Addition,
+ fetchImpl: typeof fetch,
+): Promise<{ sid: string; mailCookies: string }> {
+ const username = (addition.username || "").trim()
+ const password = addition.password || ""
+ const cguid = String(Date.now())
+ const referer =
+ `https://mail.10086.cn/default.html?&s=1&v=0&u=${base64Of(username)}` +
+ `&m=1&ec=S001&resource=indexLogin&clientid=1003&auto=on&cguid=${cguid}&mtime=45`
+
+ const form = new URLSearchParams({
+ UserName: username,
+ passOld: "",
+ auto: "on",
+ Password: sha1Hex(`fetion.com.cn:${password}`),
+ webIndexPagePwdLogin: "1",
+ pwdType: "1",
+ clientId: "1003",
+ authType: "2",
+ })
+
+ let mailCookies = (addition.mail_cookies || "").trim()
+ const headers: Record = {
+ accept:
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
+ "accept-language":
+ "zh-CN,zh;q=0.9,zh-TW;q=0.8,en-US;q=0.7,en;q=0.6,en-GB;q=0.5",
+ "cache-control": "max-age=0",
+ "content-type": "application/x-www-form-urlencoded",
+ dnt: "1",
+ origin: "https://mail.10086.cn",
+ priority: "u=0, i",
+ referer,
+ "sec-ch-ua":
+ '"Microsoft Edge";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
+ "sec-ch-ua-mobile": "?0",
+ "sec-ch-ua-platform": '"Windows"',
+ "sec-fetch-dest": "document",
+ "sec-fetch-mode": "navigate",
+ "sec-fetch-site": "same-origin",
+ "sec-fetch-user": "?1",
+ "upgrade-insecure-requests": "1",
+ "user-agent":
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0",
+ }
+ if (mailCookies) headers.Cookie = mailCookies
+
+ const response = await fetchImpl(MAIL_PASSWORD_URL, {
+ method: "POST",
+ headers,
+ body: form.toString(),
+ redirect: "manual",
+ })
+ mailCookies = mergeResponseCookies(mailCookies, response)
+
+ const location = response.headers.get("location") || ""
+ const riskCode = mailRiskCode(location)
+ if (riskCode) {
+ if (smsSceneForRisk(riskCode) === null) {
+ throw new Error(`139 Mail risk control triggered: ${riskCode}`)
+ }
+ if (!(addition.sms_code || "").trim()) {
+ mailCookies = await sendSmsCode(
+ username,
+ riskCode,
+ mailCookies,
+ fetchImpl,
+ )
+ throw new Yun139SmsRequiredError(
+ "139 Mail SMS verification code sent; fill sms_code and save the storage again",
+ mailCookies,
+ )
+ }
+ const verified = await verifySmsCode(
+ username,
+ riskCode,
+ (addition.sms_code || "").trim(),
+ mailCookies,
+ fetchImpl,
+ )
+ return { sid: verified.sid, mailCookies: verified.mailCookies }
+ }
+
+ let sid = ""
+ if (location) {
+ try {
+ sid = new URL(location, MAIL_PASSWORD_URL).searchParams.get("sid") || ""
+ } catch {
+ sid = ""
+ }
+ }
+ if (!sid) {
+ sid =
+ getCookie(mailCookies, "Os_SSo_Sid") ||
+ getCookie(mailCookies, "sid") ||
+ ""
+ }
+ if (!sid) {
+ throw new Error("failed to extract sid from login response")
+ }
+ return { sid, mailCookies }
+}
+
+async function step2GetArtifact(
+ sid: string,
+ mailCookies: string,
+ fetchImpl: typeof fetch,
+): Promise {
+ const rmkey = getCookie(mailCookies, "RMKEY")
+ if (!rmkey) throw new Error("RMKEY not found in MailCookies")
+ const url =
+ "https://smsrebuild1.mail.10086.cn/setting/s?" +
+ `func=${encodeURIComponent("umc:getArtifact")}` +
+ `&sid=${encodeURIComponent(sid)}&cguid=${Date.now()}`
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers: mailHeaders(`RMKEY=${rmkey}`),
+ })
+ const text = await response.text()
+ let artifact = ""
+ try {
+ artifact = String(JSON.parse(text)?.var?.artifact || "")
+ } catch {
+ artifact = ""
+ }
+ if (!artifact)
+ throw new Error("failed to extract dycpwd from artifact exchange response")
+ return artifact
+}
+
+async function encryptedRequest(
+ url: string,
+ body: any,
+ headers: Record,
+ keyHex: string,
+ fetchImpl: typeof fetch,
+): Promise {
+ const { iv, ciphertext } = aesCbcEncrypt(sortedJsonStringify(body), keyHex)
+ const payload = bytesToBase64(concatBytes(iv, ciphertext))
+ const response = await fetchImpl(url, {
+ method: "POST",
+ headers,
+ body: payload,
+ })
+ if (response.status !== 200) {
+ throw new Error(
+ `139 encrypted request failed (${response.status}): ${await response.text()}`,
+ )
+ }
+ const text = (await response.text()).trim()
+ let jsonText: string
+ if (text.startsWith("{")) {
+ jsonText = text
+ } else {
+ const decoded = base64ToBytes(text)
+ if (decoded.length < 16) {
+ throw new Error(
+ `139 encrypted response is too short to contain an IV (${decoded.length})`,
+ )
+ }
+ jsonText = aesCbcDecrypt(decoded.slice(16), keyHex, decoded.slice(0, 16))
+ }
+ try {
+ return JSON.parse(jsonText)
+ } catch {
+ throw new Error("139 encrypted response is not valid JSON")
+ }
+}
+
+async function step3ThirdPartyLogin(
+ username: string,
+ dycpwd: string,
+ fetchImpl: typeof fetch,
+): Promise<{ authorization: string; account: string; userDomainId: string }> {
+ const body = {
+ clientkey_decrypt: "l3TryM&Q+X7@dzwk)qP",
+ clienttype: "886",
+ cpid: "507",
+ dycpwd,
+ extInfo: { ifOpenAccount: "0" },
+ loginMode: "0",
+ msisdn: username,
+ pintype: "13",
+ secinfo: sha1Hex(`fetion.com.cn:${dycpwd}`).toUpperCase(),
+ version: "20250901",
+ }
+ const headers: Record = {
+ "hcy-cool-flag": "1",
+ "x-huawei-channelSrc": "10246600",
+ "x-sdk-channelSrc": "",
+ "x-MM-Source": "0",
+ "x-UserAgent": "android|23116PN5BC|android15|1.2.6|||1440x3200|10246600",
+ "x-DeviceInfo":
+ "4|127.0.0.1|5|1.2.6|Xiaomi|23116PN5BC||02-00-00-00-00-00|android 15|1440x3200|android|||",
+ "Content-Type": "text/plain;charset=UTF-8",
+ "Accept-Encoding": "gzip",
+ "User-Agent": "okhttp/3.12.2",
+ }
+
+ const firstLayer = await encryptedRequest(
+ SSO_LOGIN_URL,
+ body,
+ headers,
+ KEY_HEX_1,
+ fetchImpl,
+ )
+ const hexInner = String(firstLayer?.data || "")
+ if (!hexInner) {
+ throw new Error("missing data field in first layer decryption result")
+ }
+ const final = JSON.parse(aesEcbDecrypt(hexToBytes(hexInner), KEY_HEX_2))
+ const authToken = String(final?.authToken || "")
+ const account = String(final?.account || "")
+ const userDomainId = String(final?.userDomainId || "")
+ if (!authToken || !account || !userDomainId) {
+ throw new Error(
+ "failed to extract authToken, account or userDomainId from final decryption result",
+ )
+ }
+ return {
+ authorization: base64Of(`pc:${account}:${authToken}`),
+ account,
+ userDomainId,
+ }
+}
+
+async function loginWithPassword(
+ addition: Yun139Addition,
+ fetchImpl: typeof fetch,
+): Promise {
+ const username = (addition.username || "").trim()
+ const password = addition.password || ""
+ if (!username || !password) {
+ throw new Error("username or password is empty")
+ }
+ const step1 = await step1PasswordLogin(addition, fetchImpl)
+ const artifact = await step2GetArtifact(
+ step1.sid,
+ step1.mailCookies,
+ fetchImpl,
+ )
+ const step3 = await step3ThirdPartyLogin(username, artifact, fetchImpl)
+ return {
+ authorization: step3.authorization,
+ account: step3.account,
+ userDomainId: step3.userDomainId,
+ mailCookies: step1.mailCookies,
+ smsCode: "",
+ }
+}
+
+async function tryFastLoginWithCookies(
+ addition: Yun139Addition,
+ fetchImpl: typeof fetch,
+): Promise {
+ const mailCookies = (addition.mail_cookies || "").trim()
+ const { sid, rmkey } = extractFastLoginCookies(mailCookies)
+ if (!sid || !rmkey) return null
+ try {
+ const artifact = await step2GetArtifact(sid, mailCookies, fetchImpl)
+ const step3 = await step3ThirdPartyLogin(
+ (addition.username || "").trim(),
+ artifact,
+ fetchImpl,
+ )
+ return {
+ authorization: step3.authorization,
+ account: step3.account,
+ userDomainId: step3.userDomainId,
+ mailCookies,
+ smsCode: "",
+ }
+ } catch {
+ return null
+ }
+}
+
+/**
+ * 对齐 Go 主项目的凭证初始化:
+ * - Authorization 非空:直接使用;
+ * - MailCookies 含 SID/RMKEY:先尝试快速登录;
+ * - Username + Password:完整密码登录(必要时走短信风控)。
+ */
+export async function loginWithCredentials(
+ addition: Yun139Addition,
+ fetchImpl: typeof fetch = fetch,
+ options: { ignoreAuthorization?: boolean; skipFastLogin?: boolean } = {},
+): Promise {
+ const authorization = (addition.authorization || "").trim()
+ const username = (addition.username || "").trim()
+ const password = addition.password || ""
+ const mailCookies = (addition.mail_cookies || "").trim()
+
+ if (authorization && !options.ignoreAuthorization) {
+ if (/^basic\s/i.test(authorization)) {
+ throw new Error("authorization should not include Basic prefix")
+ }
+ return {
+ authorization,
+ account: "",
+ userDomainId: (addition.user_domain_id || "").trim(),
+ mailCookies,
+ smsCode: (addition.sms_code || "").trim(),
+ }
+ }
+
+ if (mailCookies && !hasCookiePair(mailCookies)) {
+ throw new Error(
+ "MailCookies format is invalid, please check your configuration",
+ )
+ }
+ const hasUsername = username !== ""
+ const hasPassword = password.trim() !== ""
+ if (hasUsername !== hasPassword) {
+ throw new Error("username and password must be provided together")
+ }
+
+ if (mailCookies && !options.skipFastLogin) {
+ const fast = await tryFastLoginWithCookies(addition, fetchImpl)
+ if (fast) return fast
+ }
+ if (hasUsername) {
+ return loginWithPassword(addition, fetchImpl)
+ }
+ if (mailCookies) {
+ throw new Error(
+ "fast login with cookies failed, and cannot fallback to password login (missing username/password)",
+ )
+ }
+ throw new Error("authorization is empty and credentials are not provided")
+}
diff --git a/src/backend/drivers/139/types.ts b/src/backend/drivers/139/types.ts
index dceeba76..10f8d19d 100644
--- a/src/backend/drivers/139/types.ts
+++ b/src/backend/drivers/139/types.ts
@@ -12,6 +12,7 @@ export interface Yun139Addition {
authorization: string
username?: string
password?: string
+ sms_code?: string
mail_cookies?: string
root_folder_id?: string
type?: "personal_new" | "family" | "group" | "personal" | "share"
diff --git a/src/backend/drivers/139/util.ts b/src/backend/drivers/139/util.ts
index edc6cca4..cb5f4466 100644
--- a/src/backend/drivers/139/util.ts
+++ b/src/backend/drivers/139/util.ts
@@ -1,4 +1,10 @@
import CryptoJS from "crypto-js"
+import { DOMParser } from "@xmldom/xmldom"
+import {
+ loginWithCredentials,
+ Yun139LoginResult,
+ Yun139SmsRequiredError,
+} from "./login"
import {
Yun139Addition,
QueryRoutePolicyResp,
@@ -58,6 +64,26 @@ export function formatTime(d: Date): string {
// host 就是硬失败,绝不允许静默回退到这个占位域名。
const YUN139_DEFAULT_HOST = "https://yun.139.com"
+/**
+ * 旧版 token 刷新接口,主项目 Go 驱动仍在用:只需要当前 token 与账号,
+ * 不要求 user_domain_id。用于替代 PC refreshToken 接口不可用时的硬失败。
+ */
+const YUN139_LEGACY_REFRESH_URL =
+ "https://aas.caiyun.feixin.10086.cn/tellin/authTokenRefresh.do"
+
+/** 旧接口失败或刚刷新后,至少隔这么久再试,避免每次文件操作都请求一次。 */
+const YUN139_LEGACY_REFRESH_COOLDOWN_MS = 30 * 60 * 1000
+
+/** 读取旧版刷新接口 XML 响应中第一个同名标签的文本。 */
+function readXmlTag(xml: string, tag: string): string {
+ try {
+ const doc = new DOMParser().parseFromString(xml, "text/xml")
+ return (doc.getElementsByTagName(tag)[0]?.textContent || "").trim()
+ } catch {
+ return ""
+ }
+}
+
/** 139 目录 id 的"根"写法:这些父 id 不做内容归属校验(根列表出现什么都不奇怪)。 */
const ROOT_PARENT_IDS = new Set(["/", "0", "root", "cloud139_root"])
@@ -110,13 +136,17 @@ export class Yun139ApiClient {
public groupHost = YUN139_DEFAULT_HOST
public account = ""
private onAuthorizationRefresh?: (authorization: string) => Promise
+ private onLoginStateChange?: (state: Yun139LoginResult) => Promise
+ private legacyRefreshNextAttemptAt = 0
constructor(
addition: Yun139Addition,
onAuthorizationRefresh?: (authorization: string) => Promise,
+ onLoginStateChange?: (state: Yun139LoginResult) => Promise,
) {
this.addition = addition
this.onAuthorizationRefresh = onAuthorizationRefresh
+ this.onLoginStateChange = onLoginStateChange
this.extractAccount()
}
@@ -401,20 +431,106 @@ export class Yun139ApiClient {
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 expiresAt = this.tokenExpiresAt(token)
+ if (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",
+ if (userDomainId) {
+ try {
+ await this.refreshWithPcApi(auth, pieces, userDomainId)
+ return
+ } catch (e) {
+ if (await this.tryReLogin()) return
+ throw e
+ }
+ }
+
+ // user_domain_id 只是 PC refreshToken 接口的必填项,不是自动续期的前提。
+ // 留空时改走主项目仍在使用的旧版刷新接口(只需要当前 token)。旧接口
+ // 失败也静默保留现有 token:不再因为“即将过期”打断初始化或刷告警。
+ if (Date.now() < this.legacyRefreshNextAttemptAt) return
+ try {
+ const newToken = await this.refreshWithLegacyApi(token, expiresAt)
+ await this.applyRefreshedAuthorization(pieces, newToken)
+ this.legacyRefreshNextAttemptAt =
+ Date.now() + YUN139_LEGACY_REFRESH_COOLDOWN_MS
+ } catch (e) {
+ this.legacyRefreshNextAttemptAt =
+ Date.now() + YUN139_LEGACY_REFRESH_COOLDOWN_MS
+ if (await this.tryReLogin()) return
+ console.debug(
+ "[139] automatic token refresh skipped:",
+ e instanceof Error ? e.message : String(e),
+ )
+ }
+ }
+
+ /**
+ * 对齐 Go 的 loginAfterAuthorizationFailure:刷新失败时,如果配置里还有
+ * 邮箱 Cookie 或用户名/密码,就重新登录一次并保存新的 authorization 与
+ * user_domain_id;没有可用凭证时返回 false,由调用方保持原有降级行为。
+ */
+ private async tryReLogin(): Promise {
+ const hasUsername = (this.addition.username || "").trim() !== ""
+ const hasPassword = (this.addition.password || "") !== ""
+ // 对齐 Go 的 loginAfterAuthorizationFailure:刷新失败时只走密码登录回退,
+ // 不重新尝试 Cookie 快速登录;mail_cookies 仅作为密码登录的设备上下文。
+ if (!hasUsername || !hasPassword) return false
+ try {
+ const result = await loginWithCredentials(this.addition, fetch, {
+ ignoreAuthorization: true,
+ skipFastLogin: true,
+ })
+ this.addition.authorization = result.authorization
+ this.addition.user_domain_id = result.userDomainId
+ this.addition.mail_cookies = result.mailCookies
+ this.addition.sms_code = ""
+ this.account = result.account || this.account
+ await this.onLoginStateChange?.(result)
+ return true
+ } catch (e) {
+ if (e instanceof Yun139SmsRequiredError) {
+ this.addition.mail_cookies = e.mailCookies
+ await this.onLoginStateChange?.({
+ authorization: "",
+ account: this.account,
+ userDomainId: (this.addition.user_domain_id || "").trim(),
+ mailCookies: e.mailCookies,
+ smsCode: (this.addition.sms_code || "").trim(),
+ })
+ }
+ console.debug(
+ "[139] re-login after refresh failure skipped:",
+ e instanceof Error ? e.message : String(e),
)
+ return false
}
+ }
+ /** 从 `|...||...` 中读取毫秒时间戳,非法时返回 0。 */
+ private tokenExpiresAt(token: string): number {
+ const expiresAt = Number(token.split("|")[3])
+ return Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt : 0
+ }
+
+ private async applyRefreshedAuthorization(
+ pieces: string[],
+ newToken: string,
+ ): Promise {
+ this.addition.authorization = CryptoJS.enc.Base64.stringify(
+ CryptoJS.enc.Utf8.parse(`${pieces[0]}:${this.account}:${newToken}`),
+ )
+ await this.onAuthorizationRefresh?.(this.addition.authorization)
+ }
+
+ private async refreshWithPcApi(
+ auth: string,
+ pieces: string[],
+ userDomainId: string,
+ ): Promise {
const authHeader = `Basic ${auth}`
// Match the minimal header set from the successful PowerShell request.
@@ -461,24 +577,106 @@ export class Yun139ApiClient {
`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()) {
+ if (this.tokenExpiresAt(newToken) <= 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.applyRefreshedAuthorization(pieces, newToken)
+ }
+
+ /**
+ * 旧版刷新接口(对齐主项目 Go 驱动):使用 XML 请求,不需要 user_domain_id。
+ * 响应里 `` 与 `` 都可能是新 token,只接受过期时间比
+ * 旧 token 更晚的值,避免把原 token 或非 pipe 格式的字段写回 Authorization。
+ */
+ private async refreshWithLegacyApi(
+ token: string,
+ currentExpiresAt: number,
+ ): Promise {
+ const body =
+ `${token}${this.account}` +
+ "656"
+
+ const response = await fetch(YUN139_LEGACY_REFRESH_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/xml;charset=UTF-8",
+ Referer: "https://yun.139.com/",
+ },
+ body,
+ })
+
+ const text = await response.text()
+ if (!response.ok) {
+ throw new Error(
+ `139 Cloud legacy token refresh failed (${response.status}): ${text}`,
+ )
+ }
+
+ const code = readXmlTag(text, "return")
+ if (code !== "0") {
+ throw new Error(
+ `139 Cloud legacy token refresh failed: return=${code || "unknown"}, ` +
+ `desc=${readXmlTag(text, "desc") || "empty"}`,
+ )
+ }
+
+ for (const candidate of [
+ readXmlTag(text, "token"),
+ readXmlTag(text, "accessToken"),
+ ]) {
+ if (
+ this.tokenExpiresAt(candidate) > Math.max(currentExpiresAt, Date.now())
+ ) {
+ return candidate
+ }
+ }
+
+ throw new Error(
+ "139 Cloud legacy refresh returned no token with a valid future expiry",
)
- await this.onAuthorizationRefresh?.(this.addition.authorization)
}
- async init(): Promise {
- if (!this.addition.authorization) {
- throw new Error("139 Cloud Authorization is required")
+ /**
+ * 对齐主项目:Authorization 为空时用邮箱 Cookie / 用户名密码登录,并在登录
+ * 成功后保存 Authorization、user_domain_id 与更新后的 MailCookies。
+ */
+ private async ensureAuthorization(): Promise {
+ const authorization = (this.addition.authorization || "").trim()
+ if (authorization) {
+ if (/^basic\s/i.test(authorization)) {
+ throw new Error("authorization should not include Basic prefix")
+ }
+ return
}
+ try {
+ const result = await loginWithCredentials(this.addition)
+ this.addition.authorization = result.authorization
+ this.addition.user_domain_id = result.userDomainId
+ this.addition.mail_cookies = result.mailCookies
+ this.addition.sms_code = ""
+ this.account = result.account || this.account
+ await this.onLoginStateChange?.(result)
+ } catch (e) {
+ if (e instanceof Yun139SmsRequiredError) {
+ this.addition.mail_cookies = e.mailCookies
+ await this.onLoginStateChange?.({
+ authorization: "",
+ account: this.account,
+ userDomainId: (this.addition.user_domain_id || "").trim(),
+ mailCookies: e.mailCookies,
+ smsCode: (this.addition.sms_code || "").trim(),
+ })
+ }
+ throw e
+ }
+ }
+
+ async init(): Promise {
+ await this.ensureAuthorization()
await this.refreshAuthorizationIfNeeded()
let routeError: unknown = null
diff --git a/src/backend/internal/op/storage.ts b/src/backend/internal/op/storage.ts
index ef4d6f25..0fdeb406 100644
--- a/src/backend/internal/op/storage.ts
+++ b/src/backend/internal/op/storage.ts
@@ -1016,28 +1016,57 @@ async function createDriver(
normDriver.includes("139")
) {
const addition = parseAddition(storageConfig)
- 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)
- }
- })
+ 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)
+ }
+ },
+ async (state) => {
+ 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 || {}
+ if (state.authorization)
+ stAddition.authorization = state.authorization
+ if (state.userDomainId) {
+ stAddition.user_domain_id = state.userDomainId
+ }
+ stAddition.mail_cookies = state.mailCookies
+ stAddition.sms_code = state.smsCode
+ st.addition = JSON.stringify(stAddition)
+ storageConfig.addition = st.addition
+ if (deferredTokenPersistence.has(storageConfig)) return
+ await saveDb(db)
+ } catch (e) {
+ console.warn("[139] failed to persist login state:", e)
+ }
+ },
+ )
await driver.init?.()
} else if (
normDriver === "mega" ||
diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts
index 5a4b236e..f6740e0b 100644
--- a/src/backend/server/admin.ts
+++ b/src/backend/server/admin.ts
@@ -3452,7 +3452,43 @@ const driverConfigs: Record = {
name: "authorization",
type: "text",
default: "",
- required: true,
+ required: false,
+ help: "Authorization can be used alone. If empty, use username + password; mail_cookies is optional and will be established/updated automatically. Existing mail_cookies can also be used alone for fast login.",
+ },
+ {
+ name: "username",
+ type: "string",
+ default: "",
+ required: false,
+ help: "Use together with password when Authorization is empty. mail_cookies may be left empty on the first login.",
+ },
+ {
+ name: "password",
+ type: "string",
+ default: "",
+ required: false,
+ help: "Use together with username when Authorization is empty. mail_cookies may be left empty on the first login.",
+ },
+ {
+ name: "sms_code",
+ type: "string",
+ default: "",
+ required: false,
+ help: "Fill this only after the driver reports that a 139 Mail SMS verification code was sent, then save the storage again.",
+ },
+ {
+ name: "mail_cookies",
+ type: "text",
+ default: "",
+ required: false,
+ help: "Optional cookies from mail.10086.cn. Leave empty for a first username/password login; cookies created or updated by password/SMS login are persisted and reused as device context. Existing cookies may also be used alone for fast login.",
+ },
+ {
+ name: "user_domain_id",
+ type: "string",
+ default: "",
+ required: false,
+ help: "Optional. ud_id cookie value; filled automatically after password/fast login. Used by the PC refreshToken API, otherwise the legacy refresh endpoint is used.",
},
{
name: "root_folder_id",