diff --git a/.github/workflows/sns-media-cross-platform.yml b/.github/workflows/sns-media-cross-platform.yml new file mode 100644 index 00000000..f2eaea4b --- /dev/null +++ b/.github/workflows/sns-media-cross-platform.yml @@ -0,0 +1,111 @@ +name: SNS Media Cross-Platform Tests + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/sns-media-cross-platform.yml" + - "desktop/package.json" + - "desktop/package-lock.json" + - "desktop/scripts/smoke-macos-package.cjs" + - "desktop/scripts/smoke-windows-package.cjs" + - "desktop/scripts/sns-wasm-smoke.cjs" + - "desktop/src/main.cjs" + - "desktop/tests/**" + - "frontend/lib/sns-media-source.js" + - "frontend/pages/sns.vue" + - "frontend/tests/sns-media-source.test.mjs" + - "pyproject.toml" + - "src/wechat_decrypt_tool/backend_entry.py" + - "src/wechat_decrypt_tool/logging_config.py" + - "src/wechat_decrypt_tool/native/weflow_wasm/**" + - "src/wechat_decrypt_tool/request_logging.py" + - "src/wechat_decrypt_tool/routers/sns.py" + - "src/wechat_decrypt_tool/sns_export_service.py" + - "src/wechat_decrypt_tool/sns_media.py" + - "tests/test_sns_media.py" + - "tests/test_sns_media_route_weflow_default.py" + - "tests/test_sns_media_url.py" + - "tests/test_sns_video_thumbnail_proxy.py" + - "tests/test_request_log_redaction.py" + - "tests/test_logging_config_data_dir.py" + +permissions: + contents: read + +jobs: + sns-media: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Windows x64 + os: windows-2022 + arch: x64 + - name: macOS arm64 + os: macos-14 + arch: arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version-file: .python-version + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: | + desktop/package-lock.json + frontend/package-lock.json + + - name: Verify runner architecture + env: + EXPECTED_ARCH: ${{ matrix.arch }} + run: node -e "if (process.arch !== process.env.EXPECTED_ARCH) throw new Error('unexpected runner architecture ' + process.arch)" + + - name: Install uv + run: python -m pip install uv + + - name: Install Python dependencies + run: uv sync --frozen + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Install desktop dependencies + working-directory: desktop + run: npm ci + + - name: Run SNS Python unit and integration tests + env: + PYTHONPATH: src + run: >- + uv run pytest -q + tests/test_sns_media.py + tests/test_sns_media_route_weflow_default.py + tests/test_sns_media_url.py + tests/test_sns_video_thumbnail_proxy.py + tests/test_request_log_redaction.py + tests/test_logging_config_data_dir.py + -k "not html_export and not contacts_export_seal_request_logs_only_redacted_metadata" + + - name: Run frontend media source tests + working-directory: frontend + run: node --test tests/sns-media-source.test.mjs + + - name: Verify Electron run-as-node fixture and desktop contracts + working-directory: desktop + run: >- + node --test + tests/sns-wasm-runtime.test.cjs + tests/package-config.test.cjs + tests/native-core-runtime.test.cjs diff --git a/desktop/package.json b/desktop/package.json index d93d1825..6c8e2f30 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -38,6 +38,10 @@ }, "afterPack": "scripts/after-pack.cjs", "afterSign": "scripts/after-sign.cjs", + "electronFuses": { + "runAsNode": true, + "resetAdHocDarwinSignature": true + }, "files": [ "src/**/*", "!src/wcdb-sidecar.cjs", diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs index 37630988..98ab89da 100644 --- a/desktop/scripts/build-backend.cjs +++ b/desktop/scripts/build-backend.cjs @@ -386,6 +386,17 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat } function validateRuntimeNativeHelpers(destinationDir, platform = process.platform) { + for (const name of [ + "weflow_wasm_keystream.js", + "wasm_video_decode.js", + "wasm_video_decode.wasm", + "sns_image_fixture.json", + ]) { + const resource = path.join(destinationDir, "weflow_wasm", name); + if (!fs.existsSync(resource) || !fs.statSync(resource).isFile()) { + throw new Error(`Missing SNS WASM runtime resource: ${resource}`); + } + } if (platform !== "darwin") return; const imageScanHelper = path.join(destinationDir, "macos", "universal", "image_scan_helper"); if (!fs.existsSync(imageScanHelper)) { diff --git a/desktop/scripts/smoke-macos-package.cjs b/desktop/scripts/smoke-macos-package.cjs index c230fd6c..3891c5e9 100644 --- a/desktop/scripts/smoke-macos-package.cjs +++ b/desktop/scripts/smoke-macos-package.cjs @@ -18,6 +18,10 @@ const { applyNativeCoreRuntimePolicy, } = require("../src/native-core-runtime.cjs"); const { resolveMacosPrivatePkiRuntime } = require("../src/macos-private-pki-runtime.cjs"); +const { + smokeElectronNodeWasm, + smokePackagedBackendWasm, +} = require("./sns-wasm-smoke.cjs"); const desktopRoot = path.resolve(__dirname, ".."); const SUPPORTED_ARCHITECTURE = "arm64"; @@ -403,6 +407,17 @@ async function runPackagedRuntimeSmoke(appPath) { assert.equal(nativeCoreEnv[ENV_NATIVE_CORE_MODE], "required"); assert.equal(nativeCoreEnv[ENV_NATIVE_CORE_ALLOW_DEVELOPMENT_BUILD], undefined); + const snsKeystreamSha256 = smokeElectronNodeWasm({ + electronExecutable, + nativeRoot, + }); + const snsBackendSmoke = smokePackagedBackendWasm({ + backendExecutable: backend, + electronExecutable, + nativeRoot, + }); + assert.equal(snsBackendSmoke.keystreamSha256, snsKeystreamSha256); + assertArchitecture(electronExecutable, "arm64"); assertArchitecture(backend, "arm64"); assertArchitecture(nativeClient, "arm64"); diff --git a/desktop/scripts/smoke-windows-package.cjs b/desktop/scripts/smoke-windows-package.cjs index 2a4cd677..62c62613 100644 --- a/desktop/scripts/smoke-windows-package.cjs +++ b/desktop/scripts/smoke-windows-package.cjs @@ -13,6 +13,10 @@ const { isBackendHealthResponse } = require("../src/backend-startup.cjs"); const { ensurePrivatePkiIssuerCached, } = require("../src/windows-private-pki-runtime.cjs"); +const { + smokeElectronNodeWasm, + smokePackagedBackendWasm, +} = require("./sns-wasm-smoke.cjs"); const desktopRoot = path.resolve(__dirname, ".."); const defaultPackageRoot = path.join(desktopRoot, "dist", "win-unpacked"); @@ -119,6 +123,16 @@ async function smokeRuntime(packageRoot, tempRoot) { assert.equal(manifest.codeSignatureEnforced, true); assert.equal(manifest.stagingPinnedSignerTrust, false); assert.equal(manifest.windowsSignerTrustMode, "private-pki"); + const snsKeystreamSha256 = smokeElectronNodeWasm({ + electronExecutable: runtime.application, + nativeRoot: path.join(runtime.root, "resources", "backend", "native"), + }); + const snsBackendSmoke = smokePackagedBackendWasm({ + backendExecutable: runtime.backend, + electronExecutable: runtime.application, + nativeRoot: path.join(runtime.root, "resources", "backend", "native"), + }); + assert.equal(snsBackendSmoke.keystreamSha256, snsKeystreamSha256); const stdoutPath = path.join(tempRoot, "backend.out.log"); const stderrPath = path.join(tempRoot, "backend.err.log"); diff --git a/desktop/scripts/sns-wasm-smoke.cjs b/desktop/scripts/sns-wasm-smoke.cjs new file mode 100644 index 00000000..4e3be676 --- /dev/null +++ b/desktop/scripts/sns-wasm-smoke.cjs @@ -0,0 +1,105 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +function resolveSnsWasmFixture(nativeRoot) { + const wasmRoot = path.join(path.resolve(nativeRoot), "weflow_wasm"); + const helper = path.join(wasmRoot, "weflow_wasm_keystream.js"); + const fixturePath = path.join(wasmRoot, "sns_image_fixture.json"); + for (const filePath of [ + helper, + fixturePath, + path.join(wasmRoot, "wasm_video_decode.js"), + path.join(wasmRoot, "wasm_video_decode.wasm"), + ]) { + assert.ok(fs.statSync(filePath).isFile(), `Missing SNS WASM resource: ${filePath}`); + } + const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + assert.match(String(fixture.key || ""), /^\d+$/); + assert.ok(Number(fixture.size) > 0); + return { fixture, fixturePath, helper }; +} + +function decodeAndVerifyFixture(keystream, fixture) { + assert.equal(keystream.length, Number(fixture.size)); + assert.equal( + crypto.createHash("sha256").update(keystream).digest("hex"), + fixture.keystreamSha256, + "SNS WASM keystream hash differs from the fixed fixture" + ); + const encrypted = Buffer.from(String(fixture.encryptedBase64 || ""), "base64"); + assert.equal(encrypted.length, keystream.length); + const plaintext = Buffer.alloc(encrypted.length); + for (let index = 0; index < encrypted.length; index += 1) { + plaintext[index] = encrypted[index] ^ keystream[index]; + } + assert.equal(plaintext.subarray(0, 4).toString("hex"), fixture.plaintextMagicHex); + assert.equal(plaintext[0], 0xff); + assert.equal(plaintext[1], 0xd8); + assert.equal( + crypto.createHash("sha256").update(plaintext).digest("hex"), + fixture.plaintextSha256, + "SNS fixture did not decrypt to the expected JPEG" + ); + return plaintext; +} + +function smokeElectronNodeWasm({ electronExecutable, nativeRoot, env = process.env }) { + const { fixture, helper } = resolveSnsWasmFixture(nativeRoot); + const result = spawnSync( + path.resolve(electronExecutable), + [helper, String(fixture.key), String(fixture.size)], + { + cwd: path.dirname(helper), + encoding: "utf8", + windowsHide: true, + env: { ...env, ELECTRON_RUN_AS_NODE: "1" }, + timeout: 30_000, + } + ); + if (result.error) throw result.error; + assert.equal(result.status, 0, result.stderr || result.stdout); + const keystream = Buffer.from(String(result.stdout || "").trim(), "base64"); + decodeAndVerifyFixture(keystream, fixture); + return fixture.keystreamSha256; +} + +function smokePackagedBackendWasm({ backendExecutable, electronExecutable, nativeRoot, env = process.env }) { + const { fixture } = resolveSnsWasmFixture(nativeRoot); + const smokeEnv = { + ...env, + PYTHONPATH: "", + WECHAT_TOOL_NODE_EXECUTABLE: path.resolve(electronExecutable), + WECHAT_TOOL_NODE_MODE: "electron-run-as-node", + }; + delete smokeEnv.PYTHONHOME; + delete smokeEnv.ELECTRON_RUN_AS_NODE; + const result = spawnSync(path.resolve(backendExecutable), ["--smoke-sns-wasm"], { + cwd: path.dirname(backendExecutable), + encoding: "utf8", + windowsHide: true, + env: smokeEnv, + timeout: 30_000, + }); + if (result.error) throw result.error; + assert.equal(result.status, 0, result.stderr || result.stdout); + const line = String(result.stdout || "").trim().split(/\r?\n/).filter(Boolean).at(-1); + const payload = JSON.parse(line || "{}"); + assert.equal(payload.frozen, true); + assert.equal(payload.keystreamProvider, "electron-node-wasm"); + assert.equal(payload.mediaType, "image/jpeg"); + assert.equal(payload.plaintextSha256, fixture.plaintextSha256); + assert.equal(payload.keystreamSha256, fixture.keystreamSha256); + return payload; +} + +module.exports = { + decodeAndVerifyFixture, + resolveSnsWasmFixture, + smokeElectronNodeWasm, + smokePackagedBackendWasm, +}; diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index e0b34411..55055198 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -2130,10 +2130,17 @@ function startBackend() { WECHAT_TOOL_PORT: String(getBackendPort()), WECHAT_TOOL_DATA_DIR: resolvedDataPath, WECHAT_TOOL_OUTPUT_DIR: resolvedOutputPath, + // The packaged backend cannot rely on Finder/Explorer inheriting a shell PATH. + // Reuse this exact Electron executable as Node only for the SNS WASM child. + WECHAT_TOOL_NODE_EXECUTABLE: process.execPath, + WECHAT_TOOL_NODE_MODE: "electron-run-as-node", // Electron decodes the backend pipe as UTF-8. Do not inherit an ambient // Windows code page such as cp950, which cannot encode Simplified Chinese. PYTHONIOENCODING: "utf-8", }; + // Never turn the backend (or the Electron main process) globally into Node. + // Python scopes this flag to the single WASM helper subprocess. + delete env.ELECTRON_RUN_AS_NODE; configureNativeCoreRuntime(env); clearLegacyWcdbEnvironment(env); logMain( diff --git a/desktop/tests/native-core-runtime.test.cjs b/desktop/tests/native-core-runtime.test.cjs index 292e0b13..8ace48a8 100644 --- a/desktop/tests/native-core-runtime.test.cjs +++ b/desktop/tests/native-core-runtime.test.cjs @@ -544,6 +544,9 @@ test("desktop startBackend clears legacy WCDB state and never starts the sidecar assert.match(startBackend, /clearLegacyWcdbEnvironment\(env\)/); assert.match(startBackend, /spawn\("uv", \["run", "--no-dev", "main\.py"\]/); assert.match(startBackend, /PYTHONIOENCODING:\s*"utf-8"/); + assert.match(startBackend, /WECHAT_TOOL_NODE_EXECUTABLE:\s*process\.execPath/); + assert.match(startBackend, /WECHAT_TOOL_NODE_MODE:\s*"electron-run-as-node"/); + assert.match(startBackend, /delete env\.ELECTRON_RUN_AS_NODE/); assert.doesNotMatch(startBackend, /PYTHONIOENCODING:\s*process\.env\.PYTHONIOENCODING/); assert.doesNotMatch(startBackend, /startWcdbSidecar\(/); assert.doesNotMatch(startBackend, /ensureWcdbSidecarEnv\(/); diff --git a/desktop/tests/package-config.test.cjs b/desktop/tests/package-config.test.cjs index f64b9b1c..53575e51 100644 --- a/desktop/tests/package-config.test.cjs +++ b/desktop/tests/package-config.test.cjs @@ -21,6 +21,28 @@ test("desktop package excludes the retired Koffi and WCDB sidecar runtime", () = assert.ok(packageJson.build.files.includes("!src/wcdb-sidecar.cjs")); }); +test("desktop package keeps Electron run-as-node enabled for the SNS WASM helper", () => { + assert.equal(packageJson.build.electronFuses?.runAsNode, true); + assert.equal(packageJson.build.electronFuses?.resetAdHocDarwinSignature, true); +}); + +test("SNS media CI covers Windows x64 and macOS arm64 without release secrets", () => { + const workflow = fs.readFileSync( + path.join(repoRoot, ".github", "workflows", "sns-media-cross-platform.yml"), + "utf8", + ); + assert.match(workflow, /workflow_dispatch:/); + assert.match(workflow, /pull_request:/); + assert.match(workflow, /os:\s*windows-2022/); + assert.match(workflow, /arch:\s*x64/); + assert.match(workflow, /os:\s*macos-14/); + assert.match(workflow, /arch:\s*arm64/); + assert.match(workflow, /tests\/sns-wasm-runtime\.test\.cjs/); + assert.match(workflow, /tests\/sns-media-source\.test\.mjs/); + assert.match(workflow, /tests\/test_sns_media\.py/); + assert.doesNotMatch(workflow, /secrets\.|environment:\s*windows-private-pki-production/); +}); + test("development launcher owns the Electron process tree directly", () => { const source = fs.readFileSync(path.join(desktopRoot, "scripts", "dev.cjs"), "utf8"); assert.match(source, /const electronCommand = require\("electron"\);/); @@ -108,6 +130,8 @@ test("Windows release uses protected cloud private-PKI signing and installer smo assert.match(smokeSource, /wechatdb_broker\.exe/); assert.match(smokeSource, /\/api\/health/); assert.match(smokeSource, /smokeElectronApp/); + assert.match(smokeSource, /smokeElectronNodeWasm/); + assert.match(smokeSource, /smokePackagedBackendWasm/); assert.match(smokeSource, /AUTO_UPDATE_ENABLED:\s*"0"/); const workflow = fs diff --git a/desktop/tests/sns-wasm-runtime.test.cjs b/desktop/tests/sns-wasm-runtime.test.cjs new file mode 100644 index 00000000..0b905b17 --- /dev/null +++ b/desktop/tests/sns-wasm-runtime.test.cjs @@ -0,0 +1,43 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); + +const { + resolveSnsWasmFixture, + smokeElectronNodeWasm, +} = require("../scripts/sns-wasm-smoke.cjs"); + +const desktopRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(desktopRoot, ".."); +const nativeRoot = path.join(repoRoot, "src", "wechat_decrypt_tool", "native"); + +test("npm Electron executable decrypts the fixed SNS JPEG fixture as Node", () => { + const electronExecutable = require("electron"); + const expected = resolveSnsWasmFixture(nativeRoot).fixture.keystreamSha256; + assert.equal( + smokeElectronNodeWasm({ electronExecutable, nativeRoot }), + expected, + ); +}); + +test("packaged backend smoke contract requires Electron run-as-node and WASM", () => { + const source = fs.readFileSync( + path.join(repoRoot, "src", "wechat_decrypt_tool", "backend_entry.py"), + "utf8", + ); + assert.match(source, /--smoke-sns-wasm/); + assert.match(source, /weflow_decrypt_sns_image_bytes/); + assert.match(source, /keystreamProvider/); + + const mediaSource = fs.readFileSync( + path.join(repoRoot, "src", "wechat_decrypt_tool", "sns_media.py"), + "utf8", + ); + assert.match(mediaSource, /WECHAT_TOOL_NODE_EXECUTABLE/); + assert.match(mediaSource, /WECHAT_TOOL_NODE_MODE/); + assert.match(mediaSource, /helper_env\["ELECTRON_RUN_AS_NODE"\] = "1"/); + assert.doesNotMatch(mediaSource, /from \.isaac64 import Isaac64/); +}); diff --git a/frontend/lib/sns-media-source.js b/frontend/lib/sns-media-source.js new file mode 100644 index 00000000..dee2b8b2 --- /dev/null +++ b/frontend/lib/sns-media-source.js @@ -0,0 +1,96 @@ +const firstString = (...values) => { + for (const value of values) { + const text = String(value ?? '').trim() + if (text) return text + } + return '' +} + +const canonicalUrl = (value) => { + const raw = String(value ?? '').trim() + if (!raw) return '' + try { + const parsed = new URL(raw) + parsed.protocol = parsed.protocol.toLowerCase() + parsed.hostname = parsed.hostname.toLowerCase() + return parsed.toString() + } catch { + return raw + } +} + +export const getSnsOriginalImageSource = (media) => { + const value = media && typeof media === 'object' ? media : {} + const attrs = value.urlAttrs && typeof value.urlAttrs === 'object' ? value.urlAttrs : {} + return { + kind: 'origin', + url: firstString( + value.url, + value.originUrl, + value.originalUrl, + value.origin_url, + value.original_url, + ), + token: firstString(value.token, value.urlToken, value.url_token, attrs.token), + key: firstString(value.key, attrs.key), + } +} + +export const getSnsThumbnailImageSource = (media) => { + const value = media && typeof media === 'object' ? media : {} + const attrs = value.thumbAttrs && typeof value.thumbAttrs === 'object' ? value.thumbAttrs : {} + const isVideo = Number(value.type || 0) === 6 + return { + kind: 'thumbnail', + url: firstString(value.thumb, value.thumbUrl, value.thumb_url), + token: firstString( + value.thumbToken, + value.thumbUrlToken, + value.thumb_url_token, + attrs.token, + ), + // WeChat video covers share the explicit videoKey with the video body. + // thumbAttrs.key is commonly "0" and is not a usable decryption key. + key: isVideo + ? firstString(value.videoKey, value.thumbKey, value.thumb_key, attrs.key) + : firstString(value.thumbKey, value.thumb_key, attrs.key), + } +} + +export const selectSnsImageSource = (media, rawUrl = '', { preferFull = false } = {}) => { + const value = media && typeof media === 'object' ? media : {} + const isVideo = Number(value.type || 0) === 6 + const origin = getSnsOriginalImageSource(value) + const thumbnail = getSnsThumbnailImageSource(value) + + // A video download URL is never a valid image cover. Missing covers render a + // placeholder and the video endpoint remains responsible for the body bytes. + if (isVideo && !thumbnail.url) { + return { kind: 'placeholder', url: '', token: '', key: '', variant: '' } + } + if (isVideo) { + return { ...thumbnail, variant: '' } + } + + if (preferFull && origin.url) { + return { ...origin, variant: 'full' } + } + + const requested = canonicalUrl(rawUrl) + if (thumbnail.url && requested && requested === canonicalUrl(thumbnail.url)) { + return { ...thumbnail, variant: '' } + } + if (origin.url && requested && requested === canonicalUrl(origin.url)) { + return { ...origin, variant: preferFull ? 'full' : '' } + } + if (thumbnail.url && !requested) { + return { ...thumbnail, variant: '' } + } + if (origin.url) { + return { ...origin, variant: preferFull ? 'full' : '' } + } + if (thumbnail.url) { + return { ...thumbnail, variant: '' } + } + return { kind: 'placeholder', url: '', token: '', key: '', variant: '' } +} diff --git a/frontend/pages/sns.vue b/frontend/pages/sns.vue index 18bddbc9..039c1649 100644 --- a/frontend/pages/sns.vue +++ b/frontend/pages/sns.vue @@ -1126,6 +1126,7 @@ import { usePrivacyStore } from '~/stores/privacy' import { parseTextWithEmoji } from '~/lib/wechat-emojis' import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings' import { reportServerErrorFromError, reportServerErrorFromResponse } from '~/lib/server-error-logging' +import { selectSnsImageSource } from '~/lib/sns-media-source' useHead({ title: '朋友圈 - 微信数据分析助手' }) @@ -2682,10 +2683,11 @@ const mediaSizeGroupIndex = (post, m, idx) => { } const getSnsMediaUrl = (post, m, idx, rawUrl, options = {}) => { - const raw = upgradeTencentHttps(String(rawUrl || '').trim()) + const preferFull = !!options?.preferFull + const selectedSource = selectSnsImageSource(m, rawUrl, { preferFull }) + const raw = upgradeTencentHttps(String(selectedSource.url || '').trim()) if (!raw) return '' const rawLower = raw.toLowerCase() - const preferFull = !!options?.preferFull // If backend already provides a local media endpoint, rewrite it to the effective API base // (so web builds with a custom API port still work). @@ -2696,8 +2698,7 @@ const getSnsMediaUrl = (post, m, idx, rawUrl, options = {}) => { if (/^https?:\/\//i.test(raw)) { try { const host = new URL(raw).hostname.toLowerCase() - const thumbCandidate = String(m?.thumb || m?.thumbUrl || '').trim() - const isThumbRequest = (!preferFull) && !!thumbCandidate && raw === upgradeTencentHttps(thumbCandidate) + const isThumbRequest = selectedSource.kind === 'thumbnail' if ( host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn') @@ -2737,32 +2738,19 @@ const getSnsMediaUrl = (post, m, idx, rawUrl, options = {}) => { const mediaType = String(m?.type || '2').trim() if (mediaType) parts.set('media_type', mediaType) - const token = String( - isThumbRequest - ? (m?.thumbToken || m?.thumbUrlToken || m?.thumbAttrs?.token || m?.token || m?.urlAttrs?.token || '') - : (m?.token || m?.urlAttrs?.token || m?.thumbToken || m?.thumbUrlToken || m?.thumbAttrs?.token || '') - ).trim() + const token = String(selectedSource.token || '').trim() if (token) parts.set('token', token) - // 视频封面与视频本体共用 `` 里的 videoKey; - // thumbAttrs.key 常见值为 "0",不能用于解密加密封面。 - const videoKey = Number(m?.type || 0) === 6 - ? String(m?.videoKey || '').trim() - : '' - const key = String( - isThumbRequest - ? (videoKey || m?.thumbKey || m?.thumbAttrs?.key || m?.key || m?.urlAttrs?.key || '') - : (videoKey || m?.key || m?.urlAttrs?.key || m?.thumbKey || m?.thumbAttrs?.key || '') - ).trim() + const key = String(selectedSource.key || '').trim() if (key) parts.set('key', key) parts.set('use_cache', snsUseCache.value ? '1' : '0') // When cache is disabled, bust browser caching so backend really downloads+decrypts each time. if (!snsUseCache.value) parts.set('_t', String(Date.now())) if (md5) parts.set('md5', md5) - if (preferFull) parts.set('variant', 'full') + if (selectedSource.variant === 'full') parts.set('variant', 'full') // 修改后端媒体匹配逻辑时递增版本号,避免浏览器复用旧的错误缓存。 - parts.set('v', '14') + parts.set('v', '15') parts.set('url', raw) return `${apiBase}/sns/media?${parts.toString()}` } @@ -2773,11 +2761,14 @@ const getSnsMediaUrl = (post, m, idx, rawUrl, options = {}) => { } const getMediaThumbSrc = (post, m, idx = 0) => { - return getSnsMediaUrl(post, m, idx, m?.thumb || m?.url) + const source = selectSnsImageSource(m, '', { preferFull: false }) + return getSnsMediaUrl(post, m, idx, source.url) } const getMediaPreviewSrc = (post, m, idx = 0) => { - return getSnsMediaUrl(post, m, idx, m?.url || m?.originUrl || m?.originalUrl || m?.thumb || m?.thumbUrl, { preferFull: true }) + const source = selectSnsImageSource(m, '', { preferFull: true }) + if (!source.url) return getMediaThumbSrc(post, m, idx) + return getSnsMediaUrl(post, m, idx, source.url, { preferFull: source.variant === 'full' }) } const inferSnsDownloadExt = (blob, url, isVideo = false) => { @@ -2875,14 +2866,14 @@ const getCommentImages = (comment) => { const toCommentImageMedia = (img) => { if (!img || typeof img !== 'object') return null - const thumb = String(img.thumb || img.thumbUrl || img.thumb_url || img.url || '').trim() - const url = String(img.url || img.originUrl || img.origin_url || thumb || '').trim() + const thumb = String(img.thumb || img.thumbUrl || img.thumb_url || '').trim() + const url = String(img.url || img.originUrl || img.origin_url || '').trim() const mediaId = String(img.id || img.mediaId || img.media_id || '').trim() const md5 = String(img.md5 || '').trim() const token = String(img.token || img.urlToken || img.url_token || '').trim() const key = String(img.key || '').trim() - const thumbToken = String(img.thumbToken || img.thumbUrlToken || img.thumb_url_token || token || '').trim() - const thumbKey = String(img.thumbKey || img.thumb_key || key || '').trim() + const thumbToken = String(img.thumbToken || img.thumbUrlToken || img.thumb_url_token || '').trim() + const thumbKey = String(img.thumbKey || img.thumb_key || '').trim() const width = Number(img.width || img.size?.width || 0) || 0 const height = Number(img.height || img.size?.height || 0) || 0 const totalSize = Number(img.fileSize || img.file_size || img.size?.totalSize || img.size?.total_size || 0) || 0 diff --git a/frontend/tests/sns-media-source.test.mjs b/frontend/tests/sns-media-source.test.mjs new file mode 100644 index 00000000..489c6c47 --- /dev/null +++ b/frontend/tests/sns-media-source.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + getSnsOriginalImageSource, + getSnsThumbnailImageSource, + selectSnsImageSource, +} from '../lib/sns-media-source.js' + +const image = { + type: 2, + url: 'https://mmsns.qpic.cn/sns/item/0', + thumb: 'https://mmsns.qpic.cn/sns/item/150', + token: 'origin-token', + key: 'origin-key', + thumbToken: 'thumb-token', + thumbKey: 'thumb-key', + urlAttrs: { token: 'origin-attrs-token', key: 'origin-attrs-key' }, + thumbAttrs: { token: 'thumb-attrs-token', key: 'thumb-attrs-key' }, +} + +test('thumbnail URL is paired only with thumbnail token and key', () => { + assert.deepEqual(getSnsThumbnailImageSource(image), { + kind: 'thumbnail', + url: image.thumb, + token: 'thumb-token', + key: 'thumb-key', + }) + assert.deepEqual(selectSnsImageSource(image, image.thumb), { + kind: 'thumbnail', + url: image.thumb, + token: 'thumb-token', + key: 'thumb-key', + variant: '', + }) +}) + +test('full preview is paired only with original URL token and key', () => { + assert.deepEqual(getSnsOriginalImageSource(image), { + kind: 'origin', + url: image.url, + token: 'origin-token', + key: 'origin-key', + }) + assert.deepEqual(selectSnsImageSource(image, image.thumb, { preferFull: true }), { + kind: 'origin', + url: image.url, + token: 'origin-token', + key: 'origin-key', + variant: 'full', + }) +}) + +test('missing per-source credentials never fall back across thumbnail and origin', () => { + const value = { + type: 2, + url: image.url, + thumb: image.thumb, + urlAttrs: { token: 'origin-only-token', key: 'origin-only-key' }, + } + assert.deepEqual(getSnsThumbnailImageSource(value), { + kind: 'thumbnail', + url: image.thumb, + token: '', + key: '', + }) + assert.deepEqual(getSnsOriginalImageSource(value), { + kind: 'origin', + url: image.url, + token: 'origin-only-token', + key: 'origin-only-key', + }) +}) + +test('comment /60 thumbnails preserve their own source selection', () => { + const comment = { + type: 2, + url: 'https://wxapp.tc.qq.com/comment/0', + thumbUrl: 'https://wxapp.tc.qq.com/comment/60', + urlAttrs: { token: 'comment-origin-token', key: 'comment-origin-key' }, + thumbAttrs: { token: 'comment-thumb-token', key: 'comment-thumb-key' }, + } + const selected = selectSnsImageSource(comment, comment.thumbUrl) + assert.equal(selected.url, comment.thumbUrl) + assert.equal(selected.token, 'comment-thumb-token') + assert.equal(selected.key, 'comment-thumb-key') + assert.equal(selected.variant, '') +}) + +test('video body URL is never selected as an image when no cover exists', () => { + assert.deepEqual( + selectSnsImageSource({ + type: 6, + url: 'https://snsvideodownload.video.qq.com/body.mp4', + videoKey: 'video-key', + }), + { kind: 'placeholder', url: '', token: '', key: '', variant: '' }, + ) +}) + +test('video cover uses thumb token and explicit video decryption key', () => { + assert.deepEqual( + selectSnsImageSource({ + type: 6, + url: 'https://snsvideodownload.video.qq.com/body.mp4', + thumb: 'https://wxapp.tc.qq.com/video-cover/150', + videoKey: 'video-key', + thumbAttrs: { token: 'thumb-token', key: '0' }, + }), + { + kind: 'thumbnail', + url: 'https://wxapp.tc.qq.com/video-cover/150', + token: 'thumb-token', + key: 'video-key', + variant: '', + }, + ) +}) diff --git a/pyproject.toml b/pyproject.toml index a6b4c8e3..c9c3b9cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ include = [ "src/wechat_decrypt_tool/native/weflow_wasm/weflow_wasm_keystream.js", "src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.js", "src/wechat_decrypt_tool/native/weflow_wasm/wasm_video_decode.wasm", + "src/wechat_decrypt_tool/native/weflow_wasm/sns_image_fixture.json", "src/wechat_decrypt_tool/native/macos/source/*.c", "src/wechat_decrypt_tool/resources/*.json", ] diff --git a/src/wechat_decrypt_tool/backend_entry.py b/src/wechat_decrypt_tool/backend_entry.py index 0e3cfa2a..8c681c2b 100644 --- a/src/wechat_decrypt_tool/backend_entry.py +++ b/src/wechat_decrypt_tool/backend_entry.py @@ -5,8 +5,11 @@ """ import json +import base64 +import hashlib import multiprocessing import sys +from pathlib import Path # PyInstaller/frozen Windows builds re-launch this executable for # multiprocessing workers. The memory/DLL key scanners use process pools; if @@ -56,6 +59,33 @@ def _run_watchfiles_smoke() -> None: print(json.dumps(payload, ensure_ascii=True)) +def _run_sns_wasm_smoke() -> None: + from wechat_decrypt_tool import sns_media + + fixture_path = ( + Path(sns_media._weflow_wxisaac64_script_path()).resolve().parent + / "sns_image_fixture.json" + ) + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + encrypted = base64.b64decode(str(fixture["encryptedBase64"]), validate=False) + decoded = sns_media.weflow_decrypt_sns_image_bytes(encrypted, str(fixture["key"])) + executable, mode, provider = sns_media._resolve_weflow_node_runtime() + del executable + keystream = sns_media.weflow_wxisaac64_keystream( + str(fixture["key"]), + int(fixture["size"]), + ) + payload = { + "frozen": bool(getattr(sys, "frozen", False)), + "runtimeMode": mode, + "keystreamProvider": provider, + "keystreamSha256": hashlib.sha256(keystream).hexdigest(), + "plaintextSha256": hashlib.sha256(decoded).hexdigest(), + "mediaType": sns_media.detect_image_mime(decoded), + } + print(json.dumps(payload, ensure_ascii=True, sort_keys=True)) + + def main() -> None: if "--smoke-opencc" in sys.argv[1:]: _run_opencc_smoke() @@ -63,6 +93,9 @@ def main() -> None: if "--smoke-watchfiles" in sys.argv[1:]: _run_watchfiles_smoke() return + if "--smoke-sns-wasm" in sys.argv[1:]: + _run_sns_wasm_smoke() + return start_desktop_parent_watchdog_from_env() configure_native_core_entrypoint() diff --git a/src/wechat_decrypt_tool/logging_config.py b/src/wechat_decrypt_tool/logging_config.py index 9142d7d0..b7ad4adb 100644 --- a/src/wechat_decrypt_tool/logging_config.py +++ b/src/wechat_decrypt_tool/logging_config.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Optional -from .request_logging import SensitiveQueryLogFilter +from .request_logging import SensitiveHttpClientLogFilter, SensitiveQueryLogFilter _SAFE_RUNTIME_TOKEN_RE = re.compile(r"[^A-Za-z0-9._+-]+") @@ -211,6 +211,7 @@ def setup_logging(self, log_level: str = "INFO"): file_handler = RecreatingFileHandler(self.log_file, encoding='utf-8') file_handler.setFormatter(file_formatter) file_handler.setLevel(level) + file_handler.addFilter(SensitiveHttpClientLogFilter()) # 控制台处理器 console_handler = None @@ -218,6 +219,13 @@ def setup_logging(self, log_level: str = "INFO"): console_handler = logging.StreamHandler(sys.stdout) console_handler.setFormatter(console_formatter) console_handler.setLevel(level) + console_handler.addFilter(SensitiveHttpClientLogFilter()) + + # httpx/httpcore INFO and DEBUG messages include complete request URLs. + # SNS code emits its own redacted diagnostics, so dependency request + # logging is both redundant and unsafe even when debug logging is enabled. + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) # 配置根日志器 root_logger.setLevel(level) diff --git a/src/wechat_decrypt_tool/native/weflow_wasm/sns_image_fixture.json b/src/wechat_decrypt_tool/native/weflow_wasm/sns_image_fixture.json new file mode 100644 index 00000000..5864c866 --- /dev/null +++ b/src/wechat_decrypt_tool/native/weflow_wasm/sns_image_fixture.json @@ -0,0 +1,8 @@ +{ + "key": "2026090201020304", + "size": 786, + "keystreamSha256": "a4283a268f8b259572655931aaac1b2e822de7852112284d55bd5d6186e1f657", + "encryptedBase64": "M1JJuSU4sexrfvf71sQWpsdJg/kRy6Hak0UjhKw5q/HOMjjRlT8ZtaO3EF141HUJAJURQoQRAVU+9i4pTlak336s9S92sv4NoxyXOm3CzwezJc4Xnyjxw3Tulnnoc3YaDSn+s52GATvS3LlBvi5nhNdPbOsieUkXwJl5rlpZo59S3QbizRmWlR6vE361ISP+gv0tnXOCu5ZEJ6awEC7igDN4NG0hHhQ56e1XziVhkINk0Lr7yMpcbWgwObeWKVzQbobvWp6qiTlHjDqYOnrwzS3raN1n5KoHeyOEb02fhSy0oNEtZKs9YZSq6s95tf7NeSMBkWeqnQtWa8lvGGKzTEclOzBLcuciBC9cPvYpA1WdcXWdid0mYay4wnST+I9R7KBoj0pQHiyrhVvXY7PbnbxvZPGOQhO4ildwQ34JrW/TpGQjU/uAXX1rBOb4rULCrk4loeHUI7BBnqtIri7TnPIkEyIwluw7IkZKB+AxSo8gQLumwNuRYpv2v53hiNpirsIQNKdEOZaxDhrBtSrdu3Ba/vAPzUg+vwnxEY8cuw1SWskiuKi77OCwciW87AMzNjzFbWadmnOezM+DkHEO6cRfUSOPjdPwDm305cxzEPdnS2j6k8uOVZEyAdRzktY+ymPpXf9Lz96xig0IKVN6qFPIzbdsYqA/ptLy2xcsld87q9hxIUA6ixyHeepJ+ZmdfhiTmKxMNU6BydXiGBjhOLpRrQAefloPbA6Lysfca6sTqvcD0oz1S+RQeyT2Kh6UrPrnOjsBIH1Y4rBy21RWCsEelB/2zcicMOxP+95WgbXTV2qPwa2sBVfGZqZMtPgWVChIcxx/ZsrAURLuym2K3m+WyL/zGOj+cnQC8koA90YcG72TtAJ4lJGqr9umiB1fZrYdLZu9Y9TMSziXjftpEW0n5wjifx91W/SzmOIk32eZLz4pu1Xuh0RB0LVybuxJhUzHJvYymahLGKWsnVpnGRcByYJLHHV2uZ+i7pzB97QaIw4uG1MC2VcBukpOuVlBa/RkhbpB2fpD6u4gYIU+7x+/", + "plaintextSha256": "499a0591e13af2ab06c1807a9faae6adced4092c98d56fc0f288f5b2a2e61b35", + "plaintextMagicHex": "ffd8ffe0" +} diff --git a/src/wechat_decrypt_tool/request_logging.py b/src/wechat_decrypt_tool/request_logging.py index 70fe16c6..6dc1854a 100644 --- a/src/wechat_decrypt_tool/request_logging.py +++ b/src/wechat_decrypt_tool/request_logging.py @@ -45,6 +45,11 @@ "token", "xorkey", } +_URL_QUERY_LOG_KEYS = { + "remoteurl", + "sourceurl", + "url", +} def _normalized_log_key(key: Any) -> str: @@ -105,7 +110,10 @@ def redact_sensitive_query_text(value: Any) -> str: decoded_key = unquote_plus(raw_key) except Exception: decoded_key = raw_key - if separator and _is_sensitive_log_key(decoded_key): + normalized_key = _normalized_log_key(decoded_key) + if separator and ( + _is_sensitive_log_key(decoded_key) or normalized_key in _URL_QUERY_LOG_KEYS + ): redacted_parts.append(f"{raw_key}=") else: redacted_parts.append(part) @@ -126,6 +134,26 @@ def filter(self, record: logging.LogRecord) -> bool: return True +class SensitiveHttpClientLogFilter(logging.Filter): + """Drop dependency request logs that contain complete remote URLs. + + SNS media diagnostics are emitted separately with host, size suffix, byte + counts, and hashed identities. The raw httpx/httpcore request records add no + actionable information and can expose credential-bound CDN URLs. + """ + + _wda_sensitive_http_client_filter = True + + def filter(self, record: logging.LogRecord) -> bool: + logger_name = str(record.name or "").lower() + return not ( + logger_name == "httpx" + or logger_name.startswith("httpx.") + or logger_name == "httpcore" + or logger_name.startswith("httpcore.") + ) + + def _stringify_detail(detail: Any) -> str: if detail is None: return "" diff --git a/src/wechat_decrypt_tool/routers/sns.py b/src/wechat_decrypt_tool/routers/sns.py index b7af8e09..f676a640 100644 --- a/src/wechat_decrypt_tool/routers/sns.py +++ b/src/wechat_decrypt_tool/routers/sns.py @@ -1064,8 +1064,8 @@ def _parse_comment_images(comment_node: ET.Element) -> list[dict[str, Any]]: token = _direct_child_text(img, "token") key = _direct_child_text(img, "key") enc_idx = _direct_child_text(img, "enc_idx", "encidx") - thumb_token = _direct_child_text(img, "thumb_url_token", "thumb_token", "thumburltoken") or token - thumb_key = _direct_child_text(img, "thumb_key", "thumbkey") or key + thumb_token = _direct_child_text(img, "thumb_url_token", "thumb_token", "thumburltoken") + thumb_key = _direct_child_text(img, "thumb_key", "thumbkey") thumb_enc_idx = _direct_child_text(img, "thumb_enc_idx", "thumbencidx") media_id = _direct_child_text(img, "media_id", "mediaid", "id") md5 = _direct_child_text(img, "md5") @@ -3098,8 +3098,19 @@ def _is_allowed_sns_media_host(host: str) -> bool: return _sns_media.is_allowed_sns_media_host(host) -def _fix_sns_cdn_url(url: str, *, token: str = "", is_video: bool = False) -> str: - return _sns_media.fix_sns_cdn_url(url, token=token, is_video=is_video) +def _fix_sns_cdn_url( + url: str, + *, + token: str = "", + is_video: bool = False, + force_original: bool = False, +) -> str: + return _sns_media.fix_sns_cdn_url( + url, + token=token, + is_video=is_video, + force_original=force_original, + ) def _detect_mp4_ftyp(head: bytes) -> bool: @@ -3229,6 +3240,7 @@ async def _materialize_sns_remote_video( key: str, token: str, use_cache: bool, + diagnostic_id: str = "", ) -> Optional[Path]: return await _sns_media.materialize_sns_remote_video( account_dir=account_dir, @@ -3236,6 +3248,7 @@ async def _materialize_sns_remote_video( key=key, token=token, use_cache=use_cache, + diagnostic_id=diagnostic_id, ) @@ -3382,6 +3395,7 @@ async def _try_fetch_and_decrypt_sns_remote( trace: Optional[Any] = None, diagnostic_id: str = "", stage: str = "remote", + force_original: bool = False, ) -> Optional[Response]: """Try remote download+decrypt first (accurate when keys are present).""" if trace is not None: @@ -3394,6 +3408,7 @@ async def _try_fetch_and_decrypt_sns_remote( token=str(token or ""), use_cache=bool(use_cache), diagnostic_id=str(diagnostic_id or ""), + force_original=bool(force_original), ) if res is None: if trace is not None: @@ -3421,6 +3436,38 @@ async def _try_fetch_and_decrypt_sns_remote( return resp +def _sns_remote_http_exception( + exc: BaseException, + *, + diagnostic_id: str, +) -> HTTPException: + headers = {"X-SNS-Diagnostic-Id": str(diagnostic_id or "")} + if isinstance(exc, _sns_media.SnsWasmRuntimeUnavailable): + return HTTPException( + status_code=503, + detail="SNS media decryption runtime is unavailable.", + headers=headers, + ) + if isinstance( + exc, + (_sns_media.SnsRemoteMediaDecodeError, _sns_media.SnsRemoteMediaUpstreamError), + ): + return HTTPException( + status_code=502, + detail="SNS CDN media could not be downloaded or decoded.", + headers=headers, + ) + if isinstance(exc, HTTPException): + if exc.headers: + headers.update(exc.headers) + return HTTPException(status_code=exc.status_code, detail=exc.detail, headers=headers) + return HTTPException( + status_code=502, + detail="SNS CDN media processing failed.", + headers=headers, + ) + + def _sns_media_value_hash(value: object) -> str: text = str(value or "") if not text: @@ -3454,6 +3501,8 @@ def _sns_media_url_trace_fields(url: object) -> dict[str, Any]: "urlHost": host, "urlIdentity": _sns_media_value_hash(stable_url), "urlHasQuery": bool(has_query), + "sizeSuffix": _sns_media._sns_cdn_size_suffix(raw), + "mediaSource": _sns_media._sns_cdn_media_source(raw), } @@ -3512,12 +3561,14 @@ async def get_sns_media( wxid_dir = _resolve_account_wxid_dir(account_dir) try: - use_cache_flag = bool(int(use_cache or 1)) + use_cache_flag = bool(int(1 if use_cache is None else use_cache)) except Exception: use_cache_flag = True variant_norm = str(variant or "").strip().lower() - prefer_remote_original = variant_norm in {"full", "origin", "original", "large"} + # `/0` is credential-bound and must never be inferred from a loose alias. + # Only the documented `variant=full` contract may request the original path. + prefer_remote_original = variant_norm == "full" post_type_i = int(post_type or 1) media_type_i = int(media_type or 2) md5_norm = _normalize_hex32(md5) @@ -3553,19 +3604,30 @@ async def get_sns_media( ) trace("request:start") - # 点击预览需要高清原图:本地 sns 缓存有时只命中缩略图,所以 full/original 请求先按 - # WeFlow 的 CDN URL 修正 + token/key 解密链路取原图;失败后再回退本地缓存。 + # 点击预览需要高清原图:本地 SNS 缓存有时只命中缩略图,所以 full/original 请求先按 + # 原图 URL/token/key 链路取图;只有真实 404 才回退本地缓存,runtime/解密错误保持可诊断。 if prefer_remote_original and str(url or "").strip(): - remote_resp = await _try_fetch_and_decrypt_sns_remote( - account_dir=account_dir, - url=str(url or ""), - key=str(key or ""), - token=str(token or ""), - use_cache=use_cache_flag, - trace=trace, - diagnostic_id=request_id, - stage="remote-original", - ) + try: + remote_resp = await _try_fetch_and_decrypt_sns_remote( + account_dir=account_dir, + url=str(url or ""), + key=str(key or ""), + token=str(token or ""), + use_cache=use_cache_flag, + trace=trace, + diagnostic_id=request_id, + stage="remote-original", + force_original=True, + ) + except Exception as exc: + http_exc = _sns_remote_http_exception(exc, diagnostic_id=request_id) + trace( + "response:error", + result="remote-original-error", + statusCode=int(http_exc.status_code), + errorType=type(exc).__name__, + ) + raise http_exc from exc if remote_resp is not None: remote_resp.headers["X-SNS-Variant"] = "full" remote_resp.headers["X-SNS-Diagnostic-Id"] = request_id @@ -3738,16 +3800,26 @@ async def get_sns_media( trace("local-cache:skip", reason="use-cache-disabled") # 4) 最后再走远程:WeFlow 风格下载、解密和远程缓存。 - remote_resp = await _try_fetch_and_decrypt_sns_remote( - account_dir=account_dir, - url=str(url or ""), - key=str(key or ""), - token=str(token or ""), - use_cache=use_cache_flag, - trace=trace, - diagnostic_id=request_id, - stage="remote-fallback", - ) + try: + remote_resp = await _try_fetch_and_decrypt_sns_remote( + account_dir=account_dir, + url=str(url or ""), + key=str(key or ""), + token=str(token or ""), + use_cache=use_cache_flag, + trace=trace, + diagnostic_id=request_id, + stage="remote-fallback", + ) + except Exception as exc: + http_exc = _sns_remote_http_exception(exc, diagnostic_id=request_id) + trace( + "response:error", + result="remote-fallback-error", + statusCode=int(http_exc.status_code), + errorType=type(exc).__name__, + ) + raise http_exc from exc if remote_resp is not None: remote_resp.headers["X-SNS-Diagnostic-Id"] = request_id trace( @@ -3799,7 +3871,11 @@ async def proxy_article_thumb(url: str): ) except Exception as e: - logger.warning(f"[sns] 提取公众号封面失败 url={u[:50]}... : {e}") + logger.warning( + "[sns] article thumbnail failed urlIdentity=%s errorType=%s", + _sns_media_value_hash(u), + type(e).__name__, + ) raise HTTPException(status_code=404, detail="无法获取文章封面") @@ -3812,23 +3888,35 @@ async def get_sns_video_remote( use_cache: int = 1, ): account_dir = _resolve_account_dir(account) + request_id = f"sns-video-{time.time_ns()}-{threading.get_ident()}" try: - use_cache_flag = bool(int(use_cache or 1)) + use_cache_flag = bool(int(1 if use_cache is None else use_cache)) except Exception: use_cache_flag = True - path = await _materialize_sns_remote_video( - account_dir=account_dir, - url=str(url or ""), - key=str(key or ""), - token=str(token or ""), - use_cache=use_cache_flag, - ) + try: + path = await _materialize_sns_remote_video( + account_dir=account_dir, + url=str(url or ""), + key=str(key or ""), + token=str(token or ""), + use_cache=use_cache_flag, + diagnostic_id=request_id, + ) + except Exception as exc: + raise _sns_remote_http_exception(exc, diagnostic_id=request_id) from exc if path is None: - raise HTTPException(status_code=404, detail="SNS remote video not found.") + raise HTTPException( + status_code=404, + detail="SNS remote video not found.", + headers={"X-SNS-Diagnostic-Id": request_id}, + ) - headers = {"Cache-Control": "public, max-age=86400" if use_cache_flag else "no-store"} + headers = { + "Cache-Control": "public, max-age=86400" if use_cache_flag else "no-store", + "X-SNS-Diagnostic-Id": request_id, + } if use_cache_flag: return FileResponse(str(path), media_type="video/mp4", headers=headers) diff --git a/src/wechat_decrypt_tool/sns_export_service.py b/src/wechat_decrypt_tool/sns_export_service.py index 16a21fc0..781e81d1 100644 --- a/src/wechat_decrypt_tool/sns_export_service.py +++ b/src/wechat_decrypt_tool/sns_export_service.py @@ -110,7 +110,12 @@ class SnsPrefetchedImage: def _sns_remote_media_task_id(task: SnsRemoteMediaTask) -> str: is_video = task.kind == "video" - fixed = _fix_sns_cdn_url(task.url, token=task.token, is_video=is_video) + fixed = _fix_sns_cdn_url( + task.url, + token=task.token, + is_video=is_video, + force_original=bool(task.require_original and not is_video), + ) return f"{task.kind}|{_normalize_sns_cache_url(fixed)}" @@ -192,6 +197,7 @@ async def worker(http_client: httpx.AsyncClient) -> None: url=task.url, key=task.key, token=task.token, + force_original=task.require_original, ) if task.kind == "image" and cached is not None and not _sns_cached_image_meets_task_size(cached, task): @@ -226,6 +232,7 @@ async def worker(http_client: httpx.AsyncClient) -> None: token=task.token, use_cache=use_cache and not force_refresh, client=http_client, + force_original=task.require_original, ) if task.kind == "image" and fetched is not None and not _sns_cached_image_meets_task_size(fetched, task): fetched = None @@ -247,7 +254,15 @@ async def worker(http_client: httpx.AsyncClient) -> None: except Exception as exc: result.failed += 1 result.missing.append(task_id) - logger.info("sns media prefetch failed: kind=%s url=%s error=%s", task.kind, task.url, exc) + url_identity = hashlib.sha256( + _normalize_sns_cache_url(task.url).encode("utf-8", errors="ignore") + ).hexdigest()[:16] + logger.info( + "sns media prefetch failed: kind=%s urlIdentity=%s errorType=%s", + task.kind, + url_identity, + type(exc).__name__, + ) finally: completed += 1 if on_progress is not None: @@ -680,30 +695,24 @@ def _sns_image_source( media.get("thumbKey"), media.get("thumb_key"), thumb_attrs.get("key"), - media.get("key"), - url_attrs.get("key"), ), _pick_sns_media_str( media.get("thumbToken"), media.get("thumbUrlToken"), media.get("thumb_url_token"), thumb_attrs.get("token"), - media.get("token"), - url_attrs.get("token"), ), False, ) return ( original_url, - _pick_sns_media_str(media.get("key"), url_attrs.get("key"), media.get("thumbKey"), thumb_attrs.get("key")), + _pick_sns_media_str(media.get("key"), url_attrs.get("key")), _pick_sns_media_str( media.get("token"), media.get("urlToken"), media.get("url_token"), url_attrs.get("token"), - media.get("thumbToken"), - thumb_attrs.get("token"), ), True, ) @@ -2047,7 +2056,12 @@ def export_image_to_zip( if not raw_url: return "" - fixed = _fix_sns_cdn_url(raw_url, token=token, is_video=False) + fixed = _fix_sns_cdn_url( + raw_url, + token=token, + is_video=False, + force_original=source_is_original, + ) post_id = str(post.get("id") or post.get("tid") or "").strip() media_id = str(m.get("id") or "").strip() @@ -2108,6 +2122,7 @@ def load_remote_cache() -> None: url=fixed, key=str(key or ""), token=str(token or ""), + force_original=source_is_original, ) if cached_remote is not None and _sns_cached_image_meets_task_size(cached_remote, task): payload = bytes(cached_remote.payload or b"") @@ -2125,6 +2140,7 @@ def fetch_remote() -> None: key=str(key or ""), token=str(token or ""), use_cache=use_cache, + force_original=source_is_original, ) ) if res is not None and _sns_cached_image_meets_task_size(res, task): diff --git a/src/wechat_decrypt_tool/sns_media.py b/src/wechat_decrypt_tool/sns_media.py index 0de619f3..a24bc2d8 100644 --- a/src/wechat_decrypt_tool/sns_media.py +++ b/src/wechat_decrypt_tool/sns_media.py @@ -28,6 +28,7 @@ import os import queue import re +import shutil import subprocess import threading import time @@ -43,6 +44,22 @@ _WEFLOW_WASM_DIR = _NATIVE_DIR / "weflow_wasm" +class SnsWasmRuntimeUnavailable(RuntimeError): + """Raised when the authoritative WxIsaac64 WASM runtime cannot be started.""" + + +class SnsRemoteMediaUpstreamError(RuntimeError): + """Raised when Tencent CDN fails for a request that is not a real 404.""" + + def __init__(self, message: str, *, status_code: int = 0) -> None: + super().__init__(message) + self.status_code = int(status_code or 0) + + +class SnsRemoteMediaDecodeError(RuntimeError): + """Raised when the CDN returned bytes but they cannot be decoded as requested media.""" + + def is_allowed_sns_media_host(host: str) -> bool: h = str(host or "").strip().lower() if not h: @@ -107,6 +124,18 @@ def _sns_remote_diagnostic_log( if stable_url else "" ), + "sizeSuffix": _sns_cdn_size_suffix(raw_url), + "mediaSource": _sns_cdn_media_source(raw_url), + "tokenHash": ( + hashlib.sha256(str(token).encode("utf-8", errors="ignore")).hexdigest()[:16] + if str(token or "") + else "" + ), + "keyHash": ( + hashlib.sha256(str(key).encode("utf-8", errors="ignore")).hexdigest()[:16] + if str(key or "") + else "" + ), **fields, } @@ -133,11 +162,36 @@ def _sns_remote_diagnostic_log( ) -def fix_sns_cdn_url(url: str, *, token: str = "", is_video: bool = False) -> str: +def _sns_cdn_size_suffix(url: str) -> str: + try: + match = re.search(r"/(0|60|150|200|480)$", str(urlparse(str(url or "")).path or "")) + return str(match.group(1) or "") if match else "" + except Exception: + return "" + + +def _sns_cdn_media_source(url: str) -> str: + suffix = _sns_cdn_size_suffix(url) + if suffix == "0": + return "origin" + if suffix in {"60", "150", "200", "480"}: + return "thumbnail" + return "video-or-unknown" + + +def fix_sns_cdn_url( + url: str, + *, + token: str = "", + is_video: bool = False, + force_original: bool = False, +) -> str: """WeFlow-compatible SNS CDN URL normalization. - Force https for Tencent CDNs. - - For images, replace `/150`, `/200`, `/480` with `/0` to request the original. + - Preserve image size variants by default because Tencent binds `/60`, `/150`, + `/200`, `/480`, and `/0` to their matching credentials. + - Only an explicit original-image request may replace a size suffix with `/0`. - If token is provided, replace stale token/idx parameters with the current values. """ u = html.unescape(str(url or "")).strip() @@ -156,9 +210,8 @@ def fix_sns_cdn_url(url: str, *, token: str = "", is_video: bool = False) -> str # http -> https u = re.sub(r"^http://", "https://", u, flags=re.I) - # /150|/200|/480 -> /0 (image only; matches WeFlow's original-image request behavior). - if not is_video: - u = re.sub(r"/(?:150|200|480)(?=($|\?))", "/0", u) + if force_original and not is_video: + u = re.sub(r"/(?:60|150|200|480)(?=($|\?))", "/0", u) tok = str(token or "").strip() if tok: @@ -209,19 +262,37 @@ def __init__(self) -> None: self._process: Optional[subprocess.Popen[str]] = None self._responses: queue.Queue[Optional[dict[str, object]]] = queue.Queue() self._request_id = 0 - - def _start_locked(self, script: str) -> subprocess.Popen[str]: + self._runtime_signature: tuple[str, str, str] | None = None + + def _start_locked( + self, + script: str, + executable: str, + mode: str, + provider: str, + ) -> subprocess.Popen[str]: process = self._process - if process is not None and process.poll() is None: + signature = (executable, mode, script) + if process is not None and process.poll() is None and self._runtime_signature == signature: return process + if process is not None: + self._stop_locked() responses: queue.Queue[Optional[dict[str, object]]] = queue.Queue() creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0 + helper_env = os.environ.copy() + if mode == "electron-run-as-node": + helper_env["ELECTRON_RUN_AS_NODE"] = "1" + else: + # This variable is scoped to the Electron helper only. A global value + # would turn the desktop main process itself into a Node process. + helper_env.pop("ELECTRON_RUN_AS_NODE", None) process = subprocess.Popen( - ["node", script, "--stdio"], + [executable, script, "--stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + env=helper_env, text=True, encoding="utf-8", errors="replace", @@ -251,11 +322,36 @@ def read_responses() -> None: ).start() self._responses = responses self._process = process + self._runtime_signature = signature + logger.info( + "[sns_media] %s", + json.dumps( + { + "event": "keystream:helper-started", + "keystreamProvider": provider, + "runtimeMode": mode, + "runtimeIdentity": hashlib.sha256( + executable.encode("utf-8", errors="ignore") + ).hexdigest()[:16], + }, + ensure_ascii=False, + sort_keys=True, + ), + ) return process - def generate(self, script: str, key: str, size: int) -> bytes: + def generate( + self, + script: str, + key: str, + size: int, + *, + executable: str, + mode: str, + provider: str, + ) -> bytes: with self._lock: - process = self._start_locked(script) + process = self._start_locked(script, executable, mode, provider) assert process.stdin is not None self._request_id += 1 request_id = self._request_id @@ -286,6 +382,7 @@ def generate(self, script: str, key: str, size: int) -> bytes: def _stop_locked(self) -> None: process = self._process self._process = None + self._runtime_signature = None if process is None: return try: @@ -316,28 +413,57 @@ def close(self) -> None: atexit.register(_WEFLOW_WASM_PROCESS.close) +@lru_cache(maxsize=1) +def _resolve_weflow_node_runtime() -> tuple[str, str, str]: + """Resolve the helper runtime without depending on a desktop user's shell PATH.""" + configured = str(os.environ.get("WECHAT_TOOL_NODE_EXECUTABLE") or "").strip() + configured_mode = str(os.environ.get("WECHAT_TOOL_NODE_MODE") or "").strip().lower() + if configured: + executable = Path(configured) + if not executable.is_absolute() or not executable.exists() or not executable.is_file(): + raise SnsWasmRuntimeUnavailable( + "Configured Electron/Node runtime is unavailable." + ) + mode = configured_mode or "node" + if mode not in {"node", "electron-run-as-node"}: + raise SnsWasmRuntimeUnavailable("Configured Electron/Node runtime mode is invalid.") + provider = "electron-node-wasm" if mode == "electron-run-as-node" else "node-wasm" + return str(executable), mode, provider + + # Source/dev mode is the only path allowed to consult PATH. Packaged desktop + # launches always provide the absolute Electron executable above. + executable = str(shutil.which("node") or "").strip() + if not executable: + raise SnsWasmRuntimeUnavailable( + "WxIsaac64 requires the bundled Electron runtime or a source-mode Node executable." + ) + return executable, "node", "node-wasm" + + @lru_cache(maxsize=64) def weflow_wxisaac64_keystream(key: str, size: int) -> bytes: - """Generate keystream via WeFlow's WASM (preferred; matches real video decryption).""" + """Generate the authoritative WxIsaac64 keystream through the vendored WASM.""" key_text = str(key or "").strip() if not key_text or size <= 0: return b"" - # WeFlow is the source-of-truth; use its WASM first, then fall back to our pure-python ISAAC64. script = _weflow_wxisaac64_script_path() - if script: - try: - return _WEFLOW_WASM_PROCESS.generate(script, key_text, int(size)) - except Exception: - pass - - # Fallback: pure python ISAAC64 (best-effort; may not match WxIsaac64 for all versions). - from .isaac64 import Isaac64 # pylint: disable=import-outside-toplevel - - want = int(size) - # ISAAC64 generates 8-byte words; generate enough and slice. - size8 = ((want + 7) // 8) * 8 - return Isaac64(key_text).generate_keystream(size8)[:want] + if not script: + raise SnsWasmRuntimeUnavailable("Vendored WxIsaac64 WASM helper is unavailable.") + executable, mode, provider = _resolve_weflow_node_runtime() + try: + return _WEFLOW_WASM_PROCESS.generate( + script, + key_text, + int(size), + executable=executable, + mode=mode, + provider=provider, + ) + except SnsWasmRuntimeUnavailable: + raise + except Exception as exc: + raise SnsWasmRuntimeUnavailable("WxIsaac64 WASM helper failed.") from exc _SNS_REMOTE_VIDEO_CACHE_EXTS = [ @@ -432,6 +558,7 @@ async def _download_sns_remote_to_file( *, max_bytes: int, client: Optional[httpx.AsyncClient] = None, + response_meta: Optional[dict[str, object]] = None, ) -> tuple[str, str]: """Download SNS media to file (streaming) from Tencent CDN. @@ -480,6 +607,15 @@ async def download(http_client: httpx.AsyncClient) -> tuple[str, str]: if total > max_bytes: raise HTTPException(status_code=400, detail="SNS video too large.") f.write(chunk) + if response_meta is not None: + response_meta.update( + { + "upstreamStatus": int(resp.status_code), + "responseBytes": int(total), + "contentType": content_type, + "xEnc": x_enc, + } + ) return content_type, x_enc if client is not None: @@ -509,34 +645,32 @@ def maybe_decrypt_sns_video_file(path: Path, key: str) -> bool: if decrypt_size <= 0: return False - try: - with path.open("r+b") as f: - head = f.read(8) - if _detect_mp4_ftyp(head): - return False - - f.seek(0) - buf = bytearray(f.read(decrypt_size)) - if not buf: - return False - - ks = weflow_wxisaac64_keystream(key_text, decrypt_size) - n = min(len(buf), len(ks)) - for i in range(n): - buf[i] ^= ks[i] - - f.seek(0) - f.write(buf) - f.flush() - - f.seek(0) - head2 = f.read(8) - if _detect_mp4_ftyp(head2): - return True - # Still return True to indicate we mutated bytes; caller may treat as failure if desired. + with path.open("r+b") as f: + head = f.read(8) + if _detect_mp4_ftyp(head): + return False + + f.seek(0) + buf = bytearray(f.read(decrypt_size)) + if not buf: + return False + + ks = weflow_wxisaac64_keystream(key_text, decrypt_size) + n = min(len(buf), len(ks)) + for i in range(n): + buf[i] ^= ks[i] + + f.seek(0) + f.write(buf) + f.flush() + + f.seek(0) + head2 = f.read(8) + if _detect_mp4_ftyp(head2): return True - except Exception: - return False + raise SnsRemoteMediaDecodeError( + "SNS video bytes are invalid after WASM decryption." + ) async def materialize_sns_remote_video( @@ -547,6 +681,7 @@ async def materialize_sns_remote_video( token: str, use_cache: bool, client: Optional[httpx.AsyncClient] = None, + diagnostic_id: str = "", ) -> Optional[Path]: """Download SNS video from CDN, decrypt (if needed), and return a local mp4 path.""" fixed_url = fix_sns_cdn_url(str(url or ""), token=str(token or ""), is_video=True) @@ -568,22 +703,70 @@ async def materialize_sns_remote_video( # Download to a temp file first. cache_dir.mkdir(parents=True, exist_ok=True) tmp_path = cache_dir / f"{cache_stem}.mp4.{time.time_ns()}.tmp" + response_meta: dict[str, object] = {} try: await _download_sns_remote_to_file( fixed_url, tmp_path, max_bytes=200 * 1024 * 1024, client=client, + response_meta=response_meta, ) - except Exception: + except Exception as exc: try: tmp_path.unlink(missing_ok=True) except Exception: pass - return None + response = getattr(exc, "response", None) + upstream_status = int(getattr(response, "status_code", 0) or 0) + _sns_remote_diagnostic_log( + "video:download-error", + url=fixed_url, + diagnostic_id=diagnostic_id, + key=key, + token=token, + error=exc, + upstreamStatus=upstream_status, + responseBytes=0, + ) + if upstream_status == 404: + return None + if isinstance(exc, HTTPException): + raise + raise SnsRemoteMediaUpstreamError( + "SNS video CDN request failed.", + status_code=upstream_status, + ) from exc # Decrypt in-place if the file isn't already a mp4. - await asyncio.to_thread(maybe_decrypt_sns_video_file, tmp_path, str(key or "")) + try: + await asyncio.to_thread(maybe_decrypt_sns_video_file, tmp_path, str(key or "")) + except Exception as exc: + try: + tmp_path.unlink(missing_ok=True) + except Exception: + pass + _sns_remote_diagnostic_log( + "video:decrypt-error", + url=fixed_url, + diagnostic_id=diagnostic_id, + key=key, + token=token, + error=exc, + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=int(response_meta.get("responseBytes") or 0), + keystreamProvider=( + "unavailable" + if isinstance(exc, SnsWasmRuntimeUnavailable) + else ( + "electron-node-wasm" + if str(os.environ.get("WECHAT_TOOL_NODE_MODE") or "").strip().lower() + == "electron-run-as-node" + else "node-wasm" + ) + ), + ) + raise # Validate: mp4 must have `ftyp` at offset 4. ok_mp4 = False @@ -599,7 +782,17 @@ async def materialize_sns_remote_video( tmp_path.unlink(missing_ok=True) except Exception: pass - return None + _sns_remote_diagnostic_log( + "video:decode-rejected", + url=fixed_url, + diagnostic_id=diagnostic_id, + key=key, + token=token, + reason="bytes-not-mp4", + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=int(response_meta.get("responseBytes") or 0), + ) + raise SnsRemoteMediaDecodeError("SNS CDN returned invalid video bytes.") final_path = cache_dir / f"{cache_stem}.mp4" try: @@ -757,6 +950,7 @@ async def _download_sns_remote_bytes( url: str, *, client: Optional[httpx.AsyncClient] = None, + response_meta: Optional[dict[str, object]] = None, ) -> tuple[bytes, str, str]: """Download SNS media bytes from Tencent CDN with a few safe header variants.""" u = str(url or "").strip() @@ -785,6 +979,15 @@ async def download(http_client: httpx.AsyncClient) -> tuple[bytes, str, str]: raise HTTPException(status_code=400, detail="SNS media too large (>25MB).") content_type = str(resp.headers.get("Content-Type") or "").strip() x_enc = str(resp.headers.get("x-enc") or "").strip() + if response_meta is not None: + response_meta.update( + { + "upstreamStatus": int(resp.status_code), + "responseBytes": len(payload), + "contentType": content_type, + "xEnc": x_enc, + } + ) return payload, content_type, x_enc if client is not None: @@ -808,9 +1011,15 @@ def get_cached_sns_remote_image( url: str, key: str, token: str, + force_original: bool = False, ) -> Optional[SnsRemoteImageResult]: """Return a validated remote-image cache entry without doing network I/O.""" - u_fixed = fix_sns_cdn_url(url, token=token, is_video=False) + u_fixed = fix_sns_cdn_url( + url, + token=token, + is_video=False, + force_original=force_original, + ) if not u_fixed: return None @@ -889,13 +1098,21 @@ async def try_fetch_and_decrypt_sns_image_remote( use_cache: bool, client: Optional[httpx.AsyncClient] = None, diagnostic_id: str = "", + force_original: bool = False, ) -> Optional[SnsRemoteImageResult]: """Try WeFlow-style: download from CDN -> WxIsaac64 full-file XOR -> return bytes. - Returns a SnsRemoteImageResult on success, or None on failure so caller can fall back to - local cache matching logic. + Returns None only for a true miss (invalid/unsupported URL or upstream 404). + Runtime, upstream, and invalid-content failures remain distinguishable to API callers. """ - u_fixed = fix_sns_cdn_url(url, token=token, is_video=False) + raw_input_url = str(url or "") + u_fixed = fix_sns_cdn_url( + raw_input_url, + token=token, + is_video=False, + force_original=force_original, + ) + url_rewritten = u_fixed != html.unescape(raw_input_url).strip() if not u_fixed: if str(url or "").strip(): _sns_remote_diagnostic_log( @@ -905,6 +1122,7 @@ async def try_fetch_and_decrypt_sns_image_remote( key=key, token=token, reason="url-normalization-empty", + urlRewritten=url_rewritten, ) return None @@ -919,6 +1137,7 @@ async def try_fetch_and_decrypt_sns_image_remote( key=key, token=token, reason="url-parse-error", + urlRewritten=url_rewritten, error=exc, ) return None @@ -930,6 +1149,7 @@ async def try_fetch_and_decrypt_sns_image_remote( key=key, token=token, reason="host-not-allowed", + urlRewritten=url_rewritten, ) return None @@ -941,15 +1161,23 @@ async def try_fetch_and_decrypt_sns_image_remote( url=u_fixed, key=key, token=token, + force_original=force_original, ) if cached is not None: return cached cache_path: Optional[Path] = None + response_meta: dict[str, object] = {} try: - raw, _content_type, x_enc = await _download_sns_remote_bytes(u_fixed, client=client) + raw, _content_type, x_enc = await _download_sns_remote_bytes( + u_fixed, + client=client, + response_meta=response_meta, + ) except Exception as e: + response = getattr(e, "response", None) + upstream_status = int(getattr(response, "status_code", 0) or 0) _sns_remote_diagnostic_log( "remote:download-error", url=u_fixed, @@ -957,8 +1185,29 @@ async def try_fetch_and_decrypt_sns_image_remote( key=key, token=token, error=e, + upstreamStatus=upstream_status, + responseBytes=0, + urlRewritten=url_rewritten, ) - return None + if upstream_status == 404: + return None + if isinstance(e, HTTPException): + raise + raise SnsRemoteMediaUpstreamError( + "SNS CDN request failed.", + status_code=upstream_status, + ) from e + + _sns_remote_diagnostic_log( + "remote:downloaded", + url=u_fixed, + diagnostic_id=diagnostic_id, + key=key, + token=token, + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=len(raw), + urlRewritten=url_rewritten, + ) if not raw: _sns_remote_diagnostic_log( @@ -968,8 +1217,11 @@ async def try_fetch_and_decrypt_sns_image_remote( key=key, token=token, reason="empty-download", + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=0, + urlRewritten=url_rewritten, ) - return None + raise SnsRemoteMediaDecodeError("SNS CDN returned an empty media payload.") # First, validate whether the CDN already returned a real image. mt_raw = detect_image_mime(raw) @@ -1011,8 +1263,37 @@ async def try_fetch_and_decrypt_sns_image_remote( rawBytes=len(raw), rawMediaType=str(mt_raw or ""), xEnc=str(x_enc or ""), + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=len(raw), + urlRewritten=url_rewritten, + keystreamProvider=( + "electron-node-wasm" + if str(os.environ.get("WECHAT_TOOL_NODE_MODE") or "").strip().lower() + == "electron-run-as-node" + else "node-wasm" + ), + ) + raise SnsRemoteMediaDecodeError( + "SNS CDN media could not be decoded as an image." ) - return None + except SnsWasmRuntimeUnavailable as e: + _sns_remote_diagnostic_log( + "remote:runtime-unavailable", + url=u_fixed, + diagnostic_id=diagnostic_id, + key=key, + token=token, + error=e, + rawBytes=len(raw), + xEnc=str(x_enc or ""), + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=len(raw), + urlRewritten=url_rewritten, + keystreamProvider="unavailable", + ) + raise + except SnsRemoteMediaDecodeError: + raise except Exception as e: _sns_remote_diagnostic_log( "remote:decrypt-error", @@ -1024,9 +1305,14 @@ async def try_fetch_and_decrypt_sns_image_remote( rawBytes=len(raw), rawMediaType=str(mt_raw or ""), xEnc=str(x_enc or ""), + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=len(raw), + urlRewritten=url_rewritten, ) if not mt_raw: - return None + raise SnsRemoteMediaDecodeError( + "SNS CDN media decryption failed." + ) from e decoded = raw mt = mt_raw decrypted = False @@ -1041,8 +1327,11 @@ async def try_fetch_and_decrypt_sns_image_remote( reason="unsupported-image-bytes", rawBytes=len(raw), xEnc=str(x_enc or ""), + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=len(raw), + urlRewritten=url_rewritten, ) - return None + raise SnsRemoteMediaDecodeError("SNS CDN returned unsupported image bytes.") try: ext = _mime_to_ext(mt) @@ -1075,6 +1364,29 @@ async def try_fetch_and_decrypt_sns_image_remote( ) cache_path = None + _sns_remote_diagnostic_log( + "remote:ready", + url=u_fixed, + diagnostic_id=diagnostic_id, + key=key, + token=token, + upstreamStatus=int(response_meta.get("upstreamStatus") or 200), + responseBytes=len(raw), + decodedBytes=len(decoded), + mediaType=str(mt or ""), + urlRewritten=url_rewritten, + keystreamProvider=( + ( + "electron-node-wasm" + if str(os.environ.get("WECHAT_TOOL_NODE_MODE") or "").strip().lower() + == "electron-run-as-node" + else "node-wasm" + ) + if decrypted + else "not-required" + ), + ) + return SnsRemoteImageResult( payload=decoded, media_type=mt, @@ -1082,4 +1394,3 @@ async def try_fetch_and_decrypt_sns_image_remote( x_enc=str(x_enc or "").strip(), cache_path=cache_path, ) - diff --git a/tests/test_logging_config_data_dir.py b/tests/test_logging_config_data_dir.py index 57922324..a0a32fa5 100644 --- a/tests/test_logging_config_data_dir.py +++ b/tests/test_logging_config_data_dir.py @@ -121,7 +121,21 @@ def test_runtime_probe_failure_does_not_block_logging_or_leak_details(self): self.assertNotIn("private path", text) self.assertNotIn("/Users/alice", text) + def test_http_client_dependency_url_is_not_written(self): + import logging + + log_file = self.logging_config.setup_logging() + sentinel = "https://mmsns.qpic.cn/sns/private/150?token=PRIVATE_TOKEN&key=PRIVATE_KEY" + logging.getLogger("httpx").warning("HTTP Request: GET %s", sentinel) + logging.getLogger("httpcore.connection").error("connect failed url=%s", sentinel) + for handler in logging.getLogger().handlers: + handler.flush() + + text = log_file.read_text(encoding="utf-8") + self.assertNotIn("mmsns.qpic.cn", text) + self.assertNotIn("PRIVATE_TOKEN", text) + self.assertNotIn("PRIVATE_KEY", text) + if __name__ == "__main__": unittest.main() - diff --git a/tests/test_request_log_redaction.py b/tests/test_request_log_redaction.py index ae19880b..549e493d 100644 --- a/tests/test_request_log_redaction.py +++ b/tests/test_request_log_redaction.py @@ -118,6 +118,62 @@ def test_uvicorn_access_filter_redacts_query_secrets(self): self.assertNotIn("AES_SECRET", rendered) self.assertIn("db_storage_path=C%3A%5Cdb", rendered) + def test_uvicorn_access_filter_redacts_nested_remote_url(self): + from wechat_decrypt_tool.request_logging import SensitiveQueryLogFilter + + record = logging.LogRecord( + "uvicorn.access", + logging.INFO, + __file__, + 1, + '%s - "%s %s HTTP/%s" %d', + ( + "127.0.0.1:1234", + "GET", + ( + "/api/sns/media?url=https%3A%2F%2Fmmsns.qpic.cn%2Fsns%2Fprivate%2F150%3Ftoken%3D" + "NESTED_TOKEN&token=TOP_LEVEL_TOKEN&key=IMAGE_KEY" + ), + "1.1", + 200, + ), + None, + ) + + self.assertTrue(SensitiveQueryLogFilter().filter(record)) + rendered = record.getMessage() + self.assertNotIn("mmsns.qpic.cn", rendered) + self.assertNotIn("NESTED_TOKEN", rendered) + self.assertNotIn("TOP_LEVEL_TOKEN", rendered) + self.assertNotIn("IMAGE_KEY", rendered) + self.assertIn("url=", rendered) + + def test_http_client_dependency_request_logs_are_dropped(self): + from wechat_decrypt_tool.request_logging import SensitiveHttpClientLogFilter + + dependency_record = logging.LogRecord( + "httpx", + logging.INFO, + __file__, + 1, + "HTTP Request: GET https://mmsns.qpic.cn/sns/private/150?token=PRIVATE_TOKEN", + (), + None, + ) + application_record = logging.LogRecord( + "wechat_decrypt_tool.sns_media", + logging.INFO, + __file__, + 1, + "redacted diagnostic", + (), + None, + ) + + log_filter = SensitiveHttpClientLogFilter() + self.assertFalse(log_filter.filter(dependency_record)) + self.assertTrue(log_filter.filter(application_record)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sns_media.py b/tests/test_sns_media.py index 305b3a7c..91880cac 100644 --- a/tests/test_sns_media.py +++ b/tests/test_sns_media.py @@ -1,9 +1,13 @@ import asyncio +import base64 import hashlib +import json +import os import sys import threading import unittest import zipfile +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from tempfile import TemporaryDirectory from unittest import mock @@ -247,7 +251,9 @@ async def run(account_dir: Path): account_dir = Path(td) / "acc" account_dir.mkdir(parents=True, exist_ok=True) with mock.patch.object(sns_media.logger, "info") as log_info: - result = asyncio.run(run(account_dir)) + with self.assertRaises(sns_media.SnsRemoteMediaUpstreamError) as caught: + asyncio.run(run(account_dir)) + result = caught.exception rendered: list[str] = [] for item in log_info.call_args_list: @@ -260,7 +266,7 @@ async def run(account_dir: Path): rendered.append(" ".join(str(value) for value in args)) logs = "\n".join(rendered) - self.assertIsNone(result) + self.assertIsInstance(result, sns_media.SnsRemoteMediaUpstreamError) self.assertIn("remote:download-error", logs) self.assertIn('"diagnosticId": "diag-http-400"', logs) self.assertIn('"errorType": "HTTPStatusError"', logs) @@ -801,14 +807,34 @@ def test_weflow_wxisaac64_reuses_persistent_process(self): finally: sns_media._WEFLOW_WASM_PROCESS.close() - def test_fix_sns_cdn_url_image_rewrites_150_and_appends_token(self): + def test_fix_sns_cdn_url_image_preserves_150_and_appends_token(self): u = "http://mmsns.qpic.cn/sns/abc/150" out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=False) - self.assertEqual(out, "https://mmsns.qpic.cn/sns/abc/0?token=tkn&idx=1") + self.assertEqual(out, "https://mmsns.qpic.cn/sns/abc/150?token=tkn&idx=1") u2 = "https://mmsns.qpic.cn/sns/abc/150?foo=bar" out2 = sns_media.fix_sns_cdn_url(u2, token="tkn", is_video=False) - self.assertEqual(out2, "https://mmsns.qpic.cn/sns/abc/0?foo=bar&token=tkn&idx=1") + self.assertEqual(out2, "https://mmsns.qpic.cn/sns/abc/150?foo=bar&token=tkn&idx=1") + + out3 = sns_media.fix_sns_cdn_url( + u2, + token="origin-token", + is_video=False, + force_original=True, + ) + self.assertEqual(out3, "https://mmsns.qpic.cn/sns/abc/0?foo=bar&token=origin-token&idx=1") + + def test_fix_sns_cdn_url_preserves_all_thumbnail_size_variants(self): + for suffix in ("60", "150", "200", "480"): + with self.subTest(suffix=suffix): + out = sns_media.fix_sns_cdn_url( + f"https://wxapp.tc.qq.com/sns/comment/{suffix}?foo=bar&idx=9", + token="thumb-token", + ) + self.assertEqual( + out, + f"https://wxapp.tc.qq.com/sns/comment/{suffix}?foo=bar&token=thumb-token&idx=1", + ) def test_fix_sns_cdn_url_replaces_stale_token_and_idx(self): out = sns_media.fix_sns_cdn_url( @@ -838,6 +864,89 @@ def test_fix_sns_cdn_url_non_tencent_host_passthrough(self): out = sns_media.fix_sns_cdn_url(u, token="tkn", is_video=False) self.assertEqual(out, u) + def test_cdn_capture_keeps_thumbnail_and_original_credentials_paired(self): + requests: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + requests.append(str(request.url)) + return httpx.Response(200, content=b"\xff\xd8\xff\x00jpeg", request=request) + + async def run(account_dir: Path) -> None: + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + await sns_media.try_fetch_and_decrypt_sns_image_remote( + account_dir=account_dir, + url="http://mmsns.qpic.cn/sns/pair/150?token=stale&idx=9&foo=bar", + key="thumb-key", + token="thumb-token", + use_cache=False, + client=client, + ) + await sns_media.try_fetch_and_decrypt_sns_image_remote( + account_dir=account_dir, + url="https://mmsns.qpic.cn/sns/pair/150?foo=bar", + key="origin-key", + token="origin-token", + use_cache=False, + client=client, + force_original=True, + ) + + with TemporaryDirectory() as td: + asyncio.run(run(Path(td))) + + self.assertEqual( + requests, + [ + "https://mmsns.qpic.cn/sns/pair/150?foo=bar&token=thumb-token&idx=1", + "https://mmsns.qpic.cn/sns/pair/0?foo=bar&token=origin-token&idx=1", + ], + ) + + def test_true_cdn_not_found_remains_a_miss(self): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(404, request=request) + + async def run(account_dir: Path): + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: + return await sns_media.try_fetch_and_decrypt_sns_image_remote( + account_dir=account_dir, + url="https://mmsns.qpic.cn/sns/missing/150", + key="thumb-key", + token="thumb-token", + use_cache=False, + client=client, + ) + + with TemporaryDirectory() as td: + result = asyncio.run(run(Path(td))) + self.assertIsNone(result) + + def test_export_source_selection_never_crosses_thumbnail_and_origin_credentials(self): + media = { + "url": "https://mmsns.qpic.cn/sns/pair/0", + "thumb": "https://mmsns.qpic.cn/sns/pair/150", + "urlAttrs": {"token": "origin-token", "key": "origin-key"}, + "thumbAttrs": {"token": "thumb-token", "key": "thumb-key"}, + } + self.assertEqual( + sns_export_service._sns_image_source(media, prefer_thumb=True), + ( + "https://mmsns.qpic.cn/sns/pair/150", + "thumb-key", + "thumb-token", + False, + ), + ) + self.assertEqual( + sns_export_service._sns_image_source(media, prefer_thumb=False), + ( + "https://mmsns.qpic.cn/sns/pair/0", + "origin-key", + "origin-token", + True, + ), + ) + def test_maybe_decrypt_sns_video_file_xors_inplace(self): # Build a fake MP4 header (ftyp at offset 4) and encrypt it by XORing with a keystream. plain = b"\x00\x00\x00\x20ftypisom" + b"\x00" * 48 @@ -1005,7 +1114,7 @@ async def fake_download(_url: str, **_kwargs): self.assertEqual(res.x_enc, "1") self.assertEqual(res.payload, decoded) - def test_try_fetch_and_decrypt_sns_image_remote_decrypt_failure_returns_none(self): + def test_try_fetch_and_decrypt_sns_image_remote_decrypt_failure_is_502_class(self): raw = b"\x01\x02\x03\x04not_an_image" decoded_bad = b"\x00\x00\x00\x00still_bad" @@ -1018,17 +1127,87 @@ async def fake_download(_url: str, **_kwargs): with mock.patch("wechat_decrypt_tool.sns_media._download_sns_remote_bytes", side_effect=fake_download): with mock.patch("wechat_decrypt_tool.sns_media.weflow_decrypt_sns_image_bytes", return_value=decoded_bad): - res = asyncio.run( - sns_media.try_fetch_and_decrypt_sns_image_remote( - account_dir=account_dir, - url="https://mmsns.qpic.cn/sns/test/0", - key="123", - token="tkn", - use_cache=False, + with self.assertRaises(sns_media.SnsRemoteMediaDecodeError): + asyncio.run( + sns_media.try_fetch_and_decrypt_sns_image_remote( + account_dir=account_dir, + url="https://mmsns.qpic.cn/sns/test/0", + key="123", + token="tkn", + use_cache=False, + ) ) - ) - self.assertIsNone(res) + def test_fixed_encrypted_jpeg_fixture_uses_real_wasm_helper(self): + fixture_path = ( + ROOT + / "src" + / "wechat_decrypt_tool" + / "native" + / "weflow_wasm" + / "sns_image_fixture.json" + ) + fixture = json.loads(fixture_path.read_text(encoding="utf-8")) + encrypted = base64.b64decode(fixture["encryptedBase64"]) + sns_media.weflow_wxisaac64_keystream.cache_clear() + sns_media._resolve_weflow_node_runtime.cache_clear() + sns_media._WEFLOW_WASM_PROCESS.close() + try: + decoded = sns_media.weflow_decrypt_sns_image_bytes(encrypted, fixture["key"]) + keystream = sns_media.weflow_wxisaac64_keystream( + fixture["key"], fixture["size"] + ) + finally: + sns_media._WEFLOW_WASM_PROCESS.close() + self.assertEqual(decoded[:4].hex(), fixture["plaintextMagicHex"]) + self.assertEqual(sns_media.detect_image_mime(decoded), "image/jpeg") + self.assertEqual(hashlib.sha256(decoded).hexdigest(), fixture["plaintextSha256"]) + self.assertEqual(hashlib.sha256(keystream).hexdigest(), fixture["keystreamSha256"]) + + def test_explicit_electron_runtime_has_priority_and_is_scoped_to_helper(self): + sns_media._resolve_weflow_node_runtime.cache_clear() + executable = str(Path(sys.executable).resolve()) + with mock.patch.dict( + os.environ, + { + "WECHAT_TOOL_NODE_EXECUTABLE": executable, + "WECHAT_TOOL_NODE_MODE": "electron-run-as-node", + }, + clear=False, + ): + with mock.patch.object(sns_media.shutil, "which") as which: + resolved = sns_media._resolve_weflow_node_runtime() + which.assert_not_called() + self.assertEqual(resolved, (executable, "electron-run-as-node", "electron-node-wasm")) + + def test_missing_runtime_raises_without_python_isaac64_fallback(self): + sns_media.weflow_wxisaac64_keystream.cache_clear() + sns_media._resolve_weflow_node_runtime.cache_clear() + sns_media._WEFLOW_WASM_PROCESS.close() + with mock.patch.dict(os.environ, {}, clear=True): + with mock.patch.object(sns_media.shutil, "which", return_value=None): + with self.assertRaises(sns_media.SnsWasmRuntimeUnavailable): + sns_media.weflow_wxisaac64_keystream("123", 16) + + def test_real_wasm_helper_concurrent_requests_do_not_cross_response_ids(self): + sns_media.weflow_wxisaac64_keystream.cache_clear() + sns_media._resolve_weflow_node_runtime.cache_clear() + sns_media._WEFLOW_WASM_PROCESS.close() + requests = [(str(10_000 + index), 37 + index) for index in range(8)] + try: + with ThreadPoolExecutor(max_workers=8) as pool: + results = list( + pool.map( + lambda item: sns_media.weflow_wxisaac64_keystream(*item), + requests, + ) + ) + process = sns_media._WEFLOW_WASM_PROCESS._process + self.assertIsNotNone(process) + finally: + sns_media._WEFLOW_WASM_PROCESS.close() + self.assertEqual([len(value) for value in results], [size for _, size in requests]) + self.assertEqual(len({hashlib.sha256(value).hexdigest() for value in results}), len(results)) if __name__ == "__main__": diff --git a/tests/test_sns_media_route_weflow_default.py b/tests/test_sns_media_route_weflow_default.py index d05f262e..2834da79 100644 --- a/tests/test_sns_media_route_weflow_default.py +++ b/tests/test_sns_media_route_weflow_default.py @@ -93,6 +93,95 @@ def test_route_falls_back_to_remote_when_local_cache_misses(self): self.assertEqual(resp.body, b"remote") self.assertEqual(resp.headers.get("X-SNS-Source"), "remote-decrypt") + def test_route_honors_zero_to_disable_media_cache(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + remote_resp = sns.Response(content=b"remote", media_type="image/jpeg") + with ExitStack() as stack: + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_dir", + return_value=account_dir, + ) + ) + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_wxid_dir", + return_value=None, + ) + ) + remote = stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._try_fetch_and_decrypt_sns_remote", + return_value=remote_resp, + ) + ) + response = asyncio.run( + sns.get_sns_media( + account="acc", + url="https://mmsns.qpic.cn/sns/test/150", + key="thumb-key", + token="thumb-token", + use_cache=0, + ) + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(remote.await_args.kwargs["use_cache"]) + + def test_only_full_variant_can_force_original_cdn_path(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + with ExitStack() as stack: + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_dir", + return_value=account_dir, + ) + ) + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_wxid_dir", + return_value=None, + ) + ) + remote = stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._try_fetch_and_decrypt_sns_remote", + side_effect=[ + sns.Response(content=b"full", media_type="image/jpeg"), + sns.Response(content=b"alias", media_type="image/jpeg"), + ], + ) + ) + full_response = asyncio.run( + sns.get_sns_media( + account="acc", + url="https://mmsns.qpic.cn/sns/test/150", + key="origin-key", + token="origin-token", + use_cache=0, + variant="full", + ) + ) + alias_response = asyncio.run( + sns.get_sns_media( + account="acc", + url="https://mmsns.qpic.cn/sns/test/150", + key="thumb-key", + token="thumb-token", + use_cache=0, + variant="original", + ) + ) + + self.assertEqual(full_response.body, b"full") + self.assertTrue(remote.await_args_list[0].kwargs["force_original"]) + self.assertEqual(alias_response.body, b"alias") + self.assertFalse(remote.await_args_list[1].kwargs.get("force_original", False)) + def test_heuristic_rejects_cache_files_far_from_post_time(self): with TemporaryDirectory() as td: account_dir = Path(td) / "acc" @@ -395,6 +484,88 @@ def test_route_logs_final_not_found_with_diagnostic_id(self): self.assertIn('"result": "not-found"', logs) self.assertIn(f'"requestId": "{diagnostic_id}"', logs) + def test_route_reports_missing_wasm_runtime_as_503(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + with ExitStack() as stack: + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_dir", + return_value=account_dir, + ) + ) + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_wxid_dir", + return_value=None, + ) + ) + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._try_fetch_and_decrypt_sns_remote", + side_effect=sns._sns_media.SnsWasmRuntimeUnavailable("runtime unavailable"), + ) + ) + with self.assertRaises(sns.HTTPException) as caught: + asyncio.run( + sns.get_sns_media( + account="acc", + url="https://mmsns.qpic.cn/sns/encrypted/150", + key="image-key", + token="thumb-token", + use_cache=0, + ) + ) + + self.assertEqual(caught.exception.status_code, 503) + self.assertTrue( + str((caught.exception.headers or {}).get("X-SNS-Diagnostic-Id") or "").startswith( + "sns-media-" + ) + ) + + def test_route_reports_invalid_cdn_content_as_502(self): + with TemporaryDirectory() as td: + account_dir = Path(td) / "acc" + account_dir.mkdir(parents=True, exist_ok=True) + with ExitStack() as stack: + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_dir", + return_value=account_dir, + ) + ) + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._resolve_account_wxid_dir", + return_value=None, + ) + ) + stack.enter_context( + mock.patch( + "wechat_decrypt_tool.routers.sns._try_fetch_and_decrypt_sns_remote", + side_effect=sns._sns_media.SnsRemoteMediaDecodeError("invalid image"), + ) + ) + with self.assertRaises(sns.HTTPException) as caught: + asyncio.run( + sns.get_sns_media( + account="acc", + url="https://mmsns.qpic.cn/sns/invalid/150", + key="image-key", + token="thumb-token", + use_cache=0, + ) + ) + + self.assertEqual(caught.exception.status_code, 502) + self.assertTrue( + str((caught.exception.headers or {}).get("X-SNS-Diagnostic-Id") or "").startswith( + "sns-media-" + ) + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_sns_media_url.py b/tests/test_sns_media_url.py index 00be4b29..efb53b72 100644 --- a/tests/test_sns_media_url.py +++ b/tests/test_sns_media_url.py @@ -8,9 +8,14 @@ from wechat_decrypt_tool.sns_media import fix_sns_cdn_url # noqa: E402 -def test_fix_sns_cdn_url_requests_original_image_sizes(): - assert fix_sns_cdn_url("http://example.qpic.cn/path/150") == "https://example.qpic.cn/path/0" - assert fix_sns_cdn_url("https://example.qpic.cn/path/200?x=1") == "https://example.qpic.cn/path/0?x=1" +def test_fix_sns_cdn_url_preserves_credential_bound_image_sizes(): + assert fix_sns_cdn_url("http://example.qpic.cn/path/150") == "https://example.qpic.cn/path/150" + assert fix_sns_cdn_url("https://example.qpic.cn/path/200?x=1") == "https://example.qpic.cn/path/200?x=1" assert fix_sns_cdn_url("https://example.qpic.cn/path/480", token="abc") == ( - "https://example.qpic.cn/path/0?token=abc&idx=1" + "https://example.qpic.cn/path/480?token=abc&idx=1" + ) + assert fix_sns_cdn_url( + "https://example.qpic.cn/path/480", token="origin", force_original=True + ) == ( + "https://example.qpic.cn/path/0?token=origin&idx=1" ) diff --git a/tests/test_sns_video_thumbnail_proxy.py b/tests/test_sns_video_thumbnail_proxy.py index e0369a08..6aa83c41 100644 --- a/tests/test_sns_video_thumbnail_proxy.py +++ b/tests/test_sns_video_thumbnail_proxy.py @@ -18,23 +18,24 @@ def test_video_qq_thumbnail_uses_backend_media_proxy(self): self.assertIn("return `${apiBase}/sns/media?${parts.toString()}`", media_url_block) def test_video_thumbnail_prefers_video_decryption_key(self): + source = (ROOT / "frontend" / "lib" / "sns-media-source.js").read_text(encoding="utf-8") page = (ROOT / "frontend" / "pages" / "sns.vue").read_text(encoding="utf-8") media_url_block = page.split("const getSnsMediaUrl =", 1)[1].split( "const getMediaThumbSrc =", 1 )[0] - self.assertRegex( - media_url_block, - re.compile( - r"const videoKey = Number\(m\?\.type \|\| 0\) === 6" - r"[\s\S]{0,160}String\(m\?\.videoKey \|\| ''\)\.trim\(\)" - ), - ) - self.assertRegex( - media_url_block, - re.compile(r"isThumbRequest[\s\S]{0,100}\? \(videoKey \|\| m\?\.thumbKey"), - ) - self.assertIn("parts.set('v', '14')", media_url_block) + self.assertIn("const isVideo = Number(value.type || 0) === 6", source) + self.assertRegex(source, re.compile(r"key: isVideo[\s\S]{0,120}value\.videoKey")) + self.assertIn("const key = String(selectedSource.key || '').trim()", media_url_block) + self.assertIn("parts.set('v', '15')", media_url_block) + + def test_video_without_thumbnail_uses_placeholder_instead_of_image_route(self): + page = (ROOT / "frontend" / "pages" / "sns.vue").read_text(encoding="utf-8") + source = (ROOT / "frontend" / "lib" / "sns-media-source.js").read_text(encoding="utf-8") + + self.assertIn("if (isVideo && !thumbnail.url)", source) + self.assertIn("kind: 'placeholder'", source) + self.assertIn("const selectedSource = selectSnsImageSource", page) if __name__ == "__main__":