From e1a1916bc5b39b3bc8e3430a68f5100dc69ea5ec Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 27 Apr 2026 12:21:12 +0800 Subject: [PATCH] fix(publish): harden zol login detection and content image upload - Detect zol login redirects via passport/service hostnames and verify console access by re-detecting the bound account so a different logged in user is treated as expired instead of active. - Skip networkPic upload for local/private URLs and fall back to file upload (with siteType retry) so desktop-hosted assets no longer reach ZOL as unreachable links and produce blank images. - Surface zol_content_image_upload_failed and challenge errors instead of silently substituting empty image URLs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../desktop-client/src/main/account-binder.ts | 106 ++++++++++-- .../src/main/adapters/zol.test.ts | 46 +++++ apps/desktop-client/src/main/adapters/zol.ts | 159 +++++++++++++++++- 3 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 apps/desktop-client/src/main/adapters/zol.test.ts diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 3d9950f..dac6255 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -60,6 +60,7 @@ interface PublishPlatformBindingDefinition { label: string; loginUrl: string; consoleUrl: string; + loginRedirectUrls?: string[]; detect(context: DetectContext): Promise; } @@ -242,6 +243,13 @@ const TOUTIAO_LOGIN_URL = "https://mp.toutiao.com/auth/page/login"; const TOUTIAO_CONSOLE_HOME_URL = "https://mp.toutiao.com/profile_v4/index"; const TOUTIAO_WORKBENCH_URL_PATTERN = /^https:\/\/mp\.toutiao\.com\/profile_v4\//i; const BILIBILI_API_ORIGIN = "https://api.bilibili.com"; +const ZOL_API_ORIGIN = "https://open-api.zol.com.cn"; +const ZOL_REFERER = "https://post.zol.com.cn/"; +const ZOL_CONSOLE_URL = "https://post.zol.com.cn/v2/manage/works/all"; +const ZOL_LOGIN_REDIRECT_URLS = [ + "https://passport.zol.com.cn/", + "https://service.zol.com.cn/user/login", +]; const BILIBILI_WBI_MIXIN_KEY_ENC_TAB = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, @@ -1729,7 +1737,7 @@ export async function probePublishAccountSession( }; } - if (looksLikeLoginRedirect(currentURL, definition.loginUrl)) { + if (definitionLooksLikeLoginRedirect(currentURL, definition)) { return { verdict: "expired", reason: "login_redirect", @@ -1810,7 +1818,7 @@ export async function probePublishAccountSession( }; } - if (pageState.loggedOutSignalCount > 0 || looksLikeLoginRedirect(currentURL, definition.loginUrl)) { + if (pageState.loggedOutSignalCount > 0 || definitionLooksLikeLoginRedirect(currentURL, definition)) { return { verdict: "expired", reason: "login_redirect", @@ -1868,6 +1876,19 @@ export async function probePublishAccountSession( webContents: window.webContents, }); if (detected) { + if (account.platform === "zol" && !detectedAccountMatchesIdentity(detected, account)) { + return { + verdict: "expired", + reason: "login_redirect", + profile: null, + sessionFingerprint: sanitizeDetectedAccount(detected).platformUid, + evidence: { + detectedPlatformUid: detected.platformUid, + expectedPlatformUid: account.platformUid, + }, + }; + } + return { verdict: "active", reason: "probe_success", @@ -1876,7 +1897,9 @@ export async function probePublishAccountSession( }; } - const consoleReady = await verifyPublishAccountConsoleAccess(account, handle); + const consoleReady = account.platform === "zol" + ? await verifyZolConsoleAccess(window, handle.session, account) + : await verifyPublishAccountConsoleAccess(account, handle); if (consoleReady) { return { verdict: "active", @@ -1944,7 +1967,11 @@ export async function silentRefreshPublishAccountSession( await sleep(1_200); await flushSessionPersistence(handle.session); - return !looksLikeLoginRedirect(window.webContents.getURL(), definition.loginUrl); + if (account.platform === "zol") { + return await verifyZolConsoleAccess(window, handle.session, account); + } + + return !definitionLooksLikeLoginRedirect(window.webContents.getURL(), definition); } catch { return false; } finally { @@ -2112,6 +2139,37 @@ async function verifyToutiaoConsoleAccess(window: BrowserWindow, session: Sessio return extractToutiaoAccount(sessionResponse) !== null; } +function detectedAccountMatchesIdentity( + detected: DetectedAccount, + account: PublishAccountIdentity, +): boolean { + const expected = normalizeAccountPlatformUid(account.platformUid) || account.platformUid; + if (!expected) { + return true; + } + return sameAccountPlatformUid(detected.platformUid, expected); +} + +async function verifyZolConsoleAccess( + window: BrowserWindow, + session: Session, + account: PublishAccountIdentity, +): Promise { + if (window.isDestroyed()) { + return false; + } + + const detected = await detectZol({ + session, + webContents: window.webContents, + }).catch(() => null); + return detected ? detectedAccountMatchesIdentity(detected, account) : false; +} + +const LOGIN_REDIRECT_TOKEN_RE = + /(^|[/?#=&._-])(login|signin|sign-in|sign_in|signup|sign-up|register|auth|oauth|passport|scan|qrcode|qrlogin|wxlogin|sso)(?=$|[/?#=&._-])/i; +const LOGIN_REDIRECT_HOST_RE = /(^|[.-])(login|passport|auth|sso)(?=$|[.-])/i; + function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean { if (!currentURL || !loginURL) { return false; @@ -2127,8 +2185,8 @@ function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean { const currentPath = normalizeURLPath(current.pathname); const loginPath = normalizeURLPath(login.pathname); if (!loginPath) { - return /(^|[/?#=&._-])(login|signin|sign-in|signup|sign-up|register|auth|oauth|passport|scan|qrcode|qrlogin|wxlogin|sso)(?=$|[/?#=&._-])/i - .test(`${current.pathname}${current.search}${current.hash}`); + return LOGIN_REDIRECT_TOKEN_RE.test(`${current.pathname}${current.search}${current.hash}`) + || LOGIN_REDIRECT_HOST_RE.test(current.hostname); } return currentPath === loginPath || currentPath.startsWith(`${loginPath}/`); @@ -2137,6 +2195,14 @@ function looksLikeLoginRedirect(currentURL: string, loginURL: string): boolean { } } +function definitionLooksLikeLoginRedirect( + currentURL: string, + definition: PublishPlatformBindingDefinition, +): boolean { + const loginRedirectUrls = definition.loginRedirectUrls ?? [definition.loginUrl]; + return loginRedirectUrls.some((loginURL) => looksLikeLoginRedirect(currentURL, loginURL)); +} + async function verifyPublishAccountConsoleAccess( account: PublishAccountIdentity, handle: SessionHandle, @@ -2176,7 +2242,11 @@ async function verifyPublishAccountConsoleAccess( return detected !== null; } - return !looksLikeLoginRedirect(currentURL, definition.loginUrl); + if (account.platform === "zol") { + return await verifyZolConsoleAccess(window, handle.session, account); + } + + return !definitionLooksLikeLoginRedirect(currentURL, definition); } finally { if (!window.isDestroyed()) { window.destroy(); @@ -2699,14 +2769,23 @@ async function detectWeixinGzh({ session }: DetectContext): Promise> { + const cookie = await sessionCookieHeader(session, "zol.com.cn").catch(() => ""); + return { + accept: "application/json, text/plain, */*", + origin: "https://post.zol.com.cn", + referer: ZOL_REFERER, + ...(cookie ? { cookie } : {}), + }; +} + async function detectZol({ session }: DetectContext): Promise { const response = await sessionFetchJson( session, - "https://open-api.zol.com.cn/api/v1/creator.user.getinfo", + `${ZOL_API_ORIGIN}/api/v1/creator.user.getinfo`, { - headers: { - accept: "application/json, text/plain, */*", - }, + credentials: "include", + headers: await zolRequestHeaders(session), }, ).catch(() => null); @@ -2822,8 +2901,9 @@ const publishBindingDefinitions: Record ({ + nativeImage: { + createFromBuffer: () => ({ + isEmpty: () => false, + getSize: () => ({ width: 1, height: 1 }), + crop: () => ({ + isEmpty: () => false, + getSize: () => ({ width: 1, height: 1 }), + toPNG: () => Buffer.from([]), + }), + toPNG: () => Buffer.from([]), + }), + }, +})); + +vi.mock("../transport/api-client", () => ({ + fetchDesktopApiURL: vi.fn(), + resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`, +})); + +import { + directNetworkPicUrl, + normalizeZolUploadedImageUrl, +} from "./zol"; + +describe("zol image helpers", () => { + it("does not send local asset URLs to ZOL networkPic upload", () => { + expect(directNetworkPicUrl("/api/public/assets/12")).toBeNull(); + expect(directNetworkPicUrl("http://127.0.0.1:8080/api/public/assets/12")).toBeNull(); + expect(directNetworkPicUrl("data:image/png;base64,abc")).toBeNull(); + }); + + it("keeps public remote image URLs eligible for networkPic upload", () => { + expect(directNetworkPicUrl("//cdn.example.com/a.png")).toBe("https://cdn.example.com/a.png"); + expect(directNetworkPicUrl("https://img.example.com/a.png")).toBe("https://img.example.com/a.png"); + }); + + it("normalizes uploaded ZOL image URLs for editor content", () => { + expect(normalizeZolUploadedImageUrl("//icon.zol-img.com.cn/a.png")).toBe("https://icon.zol-img.com.cn/a.png"); + expect(normalizeZolUploadedImageUrl("/upload/a.png")).toBe("https://post.zol.com.cn/upload/a.png"); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/zol.ts b/apps/desktop-client/src/main/adapters/zol.ts index 66efe2e..c956803 100644 --- a/apps/desktop-client/src/main/adapters/zol.ts +++ b/apps/desktop-client/src/main/adapters/zol.ts @@ -6,7 +6,6 @@ import { normalizeRemoteUrl, sessionCookieHeader, sessionFetchJson, - uploadHtmlImages, } from "./common"; import { cropImageAssetBlob, @@ -72,6 +71,7 @@ const ZOL_API_ORIGIN = "https://open-api.zol.com.cn"; const ZOL_REFERER = "https://post.zol.com.cn"; const ZOL_MANAGE_URL = "https://post.zol.com.cn/v2/manage/works/all"; const ZOL_SUBJECT_LIMIT = 5; +const LOCAL_IMAGE_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0", "api"]); function stringId(value: unknown): string { if (typeof value === "number") { @@ -92,6 +92,57 @@ function isZolChallengeMessage(message: string): boolean { .test(message); } +export function normalizeZolUploadedImageUrl(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + if (/^\/\//.test(trimmed)) { + return `https:${trimmed}`; + } + if (/^\//.test(trimmed)) { + return new URL(trimmed, ZOL_REFERER).toString(); + } + return trimmed; +} + +function zolUploadedImageUrl(response: ZolImageUploadResponse | null | undefined): string | null { + return normalizeZolUploadedImageUrl(response?.data?.pic) ?? normalizeZolUploadedImageUrl(response?.data?.fileUrl); +} + +function zolImageUploadMessage(response: ZolImageUploadResponse | null | undefined): string { + if (!response) { + return "empty_response"; + } + return zolResponseMessage(response); +} + +function isLocalImageHost(hostname: string): boolean { + return LOCAL_IMAGE_HOSTS.has(hostname.toLowerCase()); +} + +export function directNetworkPicUrl(sourceUrl: string): string | null { + const trimmed = sourceUrl.trim(); + if (!trimmed || /^(data|blob):/i.test(trimmed) || /^\/(?!\/)/.test(trimmed)) { + return null; + } + + const normalized = normalizeRemoteUrl(trimmed); + if (!normalized || /^(data|blob):/i.test(normalized)) { + return null; + } + + try { + const parsed = new URL(normalized); + if (!["http:", "https:"].includes(parsed.protocol) || isLocalImageHost(parsed.hostname)) { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + async function zolHeaders( context: PublishAdapterContext, extra: Record = {}, @@ -231,7 +282,21 @@ async function uploadCover(context: PublishAdapterContext, sourceUrl: string): P } async function uploadContentImage(context: PublishAdapterContext, sourceUrl: string): Promise { - const networkPic = normalizeRemoteUrl(sourceUrl); + const networkPic = directNetworkPicUrl(sourceUrl); + if (networkPic) { + const uploaded = await uploadContentImageByNetworkPic(context, networkPic); + if (uploaded) { + return uploaded; + } + } + + return await uploadContentImageByFile(context, sourceUrl); +} + +async function uploadContentImageByNetworkPic( + context: PublishAdapterContext, + networkPic: string, +): Promise { if (!networkPic) { return null; } @@ -251,7 +316,80 @@ async function uploadContentImage(context: PublishAdapterContext, sourceUrl: str }, ).catch(() => null); - return response?.errcode === 0 ? response.data?.pic?.trim() || null : null; + const uploaded = zolUploadedImageUrl(response); + if (response && response.errcode !== 0 && response.errcode != null) { + const message = zolResponseMessage(response); + if (isZolChallengeMessage(message)) { + throw new Error(`zol_challenge_required:${message}`); + } + return null; + } + + return uploaded; +} + +async function uploadContentImageByFile(context: PublishAdapterContext, sourceUrl: string): Promise { + const image = await fetchImageAssetBlob(sourceUrl); + if (!image) { + throw new Error(`zol_content_image_upload_failed:${sourceUrl.slice(0, 160)}:source_fetch_failed`); + } + + const errors: string[] = []; + for (const siteType of ["1", "0"]) { + const form = new FormData(); + form.append("file", image.blob, image.fileName); + form.append("siteType", siteType); + + const response = await sessionFetchJson( + context.session, + `${ZOL_API_ORIGIN}/api/v1/creator.content.image.upload`, + { + method: "POST", + credentials: "include", + headers: await zolHeaders(context), + body: form, + }, + ).catch((error): ZolImageUploadResponse => ({ + errcode: -1, + errmsg: error instanceof Error ? error.message : "zol_content_image_upload_failed", + })); + + const uploaded = zolUploadedImageUrl(response); + if (uploaded) { + return uploaded; + } + + const message = zolImageUploadMessage(response); + if (isZolChallengeMessage(message)) { + throw new Error(`zol_challenge_required:${message}`); + } + errors.push(`siteType=${siteType}:${message}`); + } + + throw new Error(`zol_content_image_upload_failed:${sourceUrl.slice(0, 160)}:${errors.join(";")}`); +} + +async function uploadContentImages(context: PublishAdapterContext, html: string): Promise { + const sources = extractImageSources(html); + if (!sources.length) { + return html; + } + + const uploaded = new Map(); + for (const source of sources) { + const target = await uploadContentImage(context, source); + if (!target) { + throw new Error(`zol_content_image_upload_failed:${source.slice(0, 160)}`); + } + uploaded.set(source, target); + } + + let next = html; + for (const [source, target] of uploaded.entries()) { + next = next.split(source).join(target); + } + + return next; } async function applyRecommendSubjects(context: PublishAdapterContext, form: FormData): Promise { @@ -349,6 +487,17 @@ function failureResult(error: unknown): ReturnType ex }; } + if (message.startsWith("zol_content_image_upload_failed")) { + return { + status: "failed", + summary: "中关村在线正文图片上传失败,已停止发布以避免生成空白图片。", + error: { + code: "zol_content_image_upload_failed", + message, + }, + }; + } + if (message.startsWith("zol_challenge_required") || isZolChallengeMessage(message)) { return { status: "failed", @@ -409,11 +558,11 @@ export const zolAdapter: PublishAdapter = { await applyRecommendSubjects(context, form); context.reportProgress("zol.upload_content_images"); - const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadContentImage(context, sourceUrl)); + const processedHtml = await uploadContentImages(context, html); form.set("draftId", draftId); form.set("saveType", "1"); - form.set("scontent", processed.html); + form.set("scontent", processedHtml); form.append("draftUpdateId", draftId); form.append("taskType", "1"); form.append("taskIds", "[]");