From 219aed691055fe040a85d63a3256f359a6f8374a Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Mon, 3 Aug 2026 13:48:46 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(mobile):=20HTML=20=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E6=80=81=E9=80=8F=E4=BC=A0=E5=90=8C=E7=9B=AE=E5=BD=95=E8=B5=84?= =?UTF-8?q?=E6=BA=90,=E5=A4=9A=E6=96=87=E4=BB=B6=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E7=BC=BA=E5=9B=BE=E7=BC=BA=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一步(#1441)让手机端能渲染 HTML,但只拿到 HTML 本身:页面里 `` / `` 这类相对引用在 about:blank 文档里解析不到,于是多文件产物「页面能开、图和样式全缺」。桌面端靠 `file://` 的同目录天然没这个问题。 把相对引用挑出来 → 换算成被控端绝对路径 → 逐个走**既有** media:fetch 绝对路径 取件通道拿 presign 地址 → 回填进 HTML,取完一次性渲染。不新增 device-link channel、不新增安全面,用的还是单文件预览已在用的那条通道。 - htmlLocalResources:纯函数层。白名单标签的资源属性(img/script/link/source/ video/audio/embed/iframe)+ `'; + expect(values(html)).toEqual(['bg.png', 'm.svg']); + }); + + it('空文档 / 无 baseDir 返回空', () => { + expect(collectHtmlLocalResourceRefs('', BASE)).toEqual([]); + expect(collectHtmlLocalResourceRefs('', '')).toEqual([]); + }); + + it('区间精确指向属性值本身(不含引号)', () => { + const html = ''; + const [ref] = collectHtmlLocalResourceRefs(html, BASE); + expect(html.slice(ref.start, ref.end)).toBe('chart.png'); + expect(ref.absPath).toBe('/Users/me/drafts/chart.png'); + }); + + it('多处引用按位置升序(回填从后往前才安全)', () => { + const refs = collectHtmlLocalResourceRefs( + '', + BASE, + ); + expect(refs.map((r) => r.raw)).toEqual(['bg.png', 'chart.png']); + expect(refs[0].start).toBeLessThan(refs[1].start); + }); +}); + +describe('applyHtmlResourceUrls(回填)', () => { + it('多处引用整体替换,区间不串位', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + const urls = new Map([ + ['/Users/me/drafts/a.css', 'https://oss/a'], + ['/Users/me/drafts/b.png', 'https://oss/b'], + ['/Users/me/drafts/c.js', 'https://oss/c'], + ]); + expect(applyHtmlResourceUrls(html, refs, urls)).toBe( + '', + ); + }); + + it('取不到的保留原引用(渲染成破图比换成错地址诚实)', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + const urls = new Map([['/Users/me/drafts/b.png', 'https://oss/b']]); + expect(applyHtmlResourceUrls(html, refs, urls)).toBe( + '', + ); + }); + + it('同一路径多处引用共用一个取回地址', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + const urls = new Map([['/Users/me/drafts/a.png', 'https://oss/a']]); + expect(applyHtmlResourceUrls(html, refs, urls)).toBe( + '', + ); + }); + + it('style 块与属性混排也不串位', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + const urls = new Map([ + ['/Users/me/drafts/bg.png', 'https://oss/bg'], + ['/Users/me/drafts/chart.png', 'https://oss/chart'], + ]); + expect(applyHtmlResourceUrls(html, refs, urls)).toBe( + '', + ); + }); +}); + +describe('planHtmlResourceFetches(去重与上限)', () => { + it('按首次出现顺序去重', () => { + const refs = collectHtmlLocalResourceRefs( + '', + BASE, + ); + expect(planHtmlResourceFetches(refs)).toEqual({ + absPaths: ['/Users/me/drafts/b.png', '/Users/me/drafts/a.png'], + skipped: 0, + }); + }); + + it('超上限的计入 skipped,不静默截断', () => { + const html = Array.from({ length: HTML_RESOURCE_LIMIT + 3 }, (_, i) => ``).join(''); + const plan = planHtmlResourceFetches(collectHtmlLocalResourceRefs(html, BASE)); + expect(plan.absPaths).toHaveLength(HTML_RESOURCE_LIMIT); + expect(plan.skipped).toBe(3); + }); + + it('自包含页面 → 零待取(零请求路径)', () => { + const html = ''; + expect(planHtmlResourceFetches(collectHtmlLocalResourceRefs(html, BASE)).absPaths).toEqual([]); + }); +}); + +describe('fetchHtmlResourceUrls(限并发批量取件)', () => { + it('全部成功:地址齐全,失败数为 0', async () => { + const out = await fetchHtmlResourceUrls( + ['/a.png', '/b.png'], + async (p) => `https://oss${p}`, + ); + expect(out.failed).toBe(0); + expect([...out.urlByAbsPath]).toEqual([ + ['/a.png', 'https://oss/a.png'], + ['/b.png', 'https://oss/b.png'], + ]); + }); + + it('单个失败不影响其它(整页不因一张图取不到而失败)', async () => { + const out = await fetchHtmlResourceUrls( + ['/a.png', '/bad.png', '/c.png'], + async (p) => { + if (p === '/bad.png') throw new Error('nope'); + return `https://oss${p}`; + }, + ); + expect(out.failed).toBe(1); + expect(out.urlByAbsPath.has('/bad.png')).toBe(false); + expect(out.urlByAbsPath.size).toBe(2); + }); + + it('回空地址也算失败(不把空串回填进 HTML)', async () => { + const out = await fetchHtmlResourceUrls(['/a.png'], async () => ''); + expect(out.failed).toBe(1); + expect(out.urlByAbsPath.size).toBe(0); + }); + + it('并发不超过上限,且每个路径只取一次', async () => { + let inFlight = 0; + let peak = 0; + const calls: string[] = []; + const paths = Array.from({ length: 9 }, (_, i) => `/a${i}.png`); + const out = await fetchHtmlResourceUrls( + paths, + async (p) => { + calls.push(p); + inFlight += 1; + peak = Math.max(peak, inFlight); + await Promise.resolve(); + inFlight -= 1; + return `https://oss${p}`; + }, + { concurrency: 3 }, + ); + expect(peak).toBeLessThanOrEqual(3); + expect(calls).toHaveLength(9); + expect(new Set(calls).size).toBe(9); + expect(out.urlByAbsPath.size).toBe(9); + }); + + it('已取消时停止后续取件(卸载 / 换文档后不白发请求)', async () => { + const calls: string[] = []; + let cancelled = false; + const out = await fetchHtmlResourceUrls( + Array.from({ length: 8 }, (_, i) => `/a${i}.png`), + async (p) => { + calls.push(p); + cancelled = true; // 第一批发出后即取消 + return `https://oss${p}`; + }, + { concurrency: 1, isCancelled: () => cancelled }, + ); + expect(calls).toEqual(['/a0.png']); + expect(out.urlByAbsPath.size).toBe(1); + }); + + it('空清单不发请求', async () => { + let called = false; + const out = await fetchHtmlResourceUrls([], async () => { + called = true; + return 'x'; + }); + expect(called).toBe(false); + expect(out).toEqual({ urlByAbsPath: new Map(), failed: 0 }); + }); +}); diff --git a/apps/mobile/src/i18n/locales/en/files.json b/apps/mobile/src/i18n/locales/en/files.json index 7769caa5b7f..c34616286c3 100644 --- a/apps/mobile/src/i18n/locales/en/files.json +++ b/apps/mobile/src/i18n/locales/en/files.json @@ -66,6 +66,9 @@ "readFailed": "Read failed", "truncated2mb": "File exceeds 2 MB; showing the first 2 MB only", "truncatedLines": "Showing the first {{lines}} lines only", + "fetchingHtmlResources": "Fetching page resources…", + "htmlResourcesMissing": "{{count}} page resource(s) could not be fetched; images or styles may be missing", + "htmlResourcesTruncated": "Too many page resources; only the first {{limit}} were fetched", "mdRendered": "Rendered", "mdSource": "Source", "mdViewA11y": "{{view}} view", diff --git a/apps/mobile/src/i18n/locales/ja/files.json b/apps/mobile/src/i18n/locales/ja/files.json index eaf96b439da..791e80bbab6 100644 --- a/apps/mobile/src/i18n/locales/ja/files.json +++ b/apps/mobile/src/i18n/locales/ja/files.json @@ -66,6 +66,9 @@ "readFailed": "読み込みに失敗しました", "truncated2mb": "ファイルが 2 MB を超えています。先頭 2 MB のみ表示します", "truncatedLines": "先頭 {{lines}} 行のみ表示します", + "fetchingHtmlResources": "ページリソースを取得中…", + "htmlResourcesMissing": "{{count}} 件のページリソースを取得できませんでした。画像やスタイルが欠ける場合があります", + "htmlResourcesTruncated": "ページリソースが多すぎます。先頭 {{limit}} 件のみ取得しました", "mdRendered": "レンダリング", "mdSource": "ソース", "mdViewA11y": "{{view}}表示", diff --git a/apps/mobile/src/i18n/locales/ko/files.json b/apps/mobile/src/i18n/locales/ko/files.json index cdff7163758..19407ab722d 100644 --- a/apps/mobile/src/i18n/locales/ko/files.json +++ b/apps/mobile/src/i18n/locales/ko/files.json @@ -66,6 +66,9 @@ "readFailed": "읽기에 실패했습니다", "truncated2mb": "파일이 2 MB를 초과합니다. 처음 2 MB만 표시합니다", "truncatedLines": "처음 {{lines}}줄만 표시합니다", + "fetchingHtmlResources": "페이지 리소스를 가져오는 중…", + "htmlResourcesMissing": "페이지 리소스 {{count}}개를 가져오지 못했습니다. 이미지나 스타일이 누락될 수 있습니다", + "htmlResourcesTruncated": "페이지 리소스가 너무 많습니다. 처음 {{limit}}개만 가져왔습니다", "mdRendered": "렌더링", "mdSource": "소스", "mdViewA11y": "{{view}} 보기", diff --git a/apps/mobile/src/i18n/locales/zh-CN/files.json b/apps/mobile/src/i18n/locales/zh-CN/files.json index e90653906a8..a5458eec90d 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/files.json +++ b/apps/mobile/src/i18n/locales/zh-CN/files.json @@ -66,6 +66,9 @@ "readFailed": "读取失败", "truncated2mb": "文件超过 2 MB,仅显示前 2 MB 内容", "truncatedLines": "仅显示前 {{lines}} 行", + "fetchingHtmlResources": "正在取回页面资源…", + "htmlResourcesMissing": "{{count}} 项页面资源没取到,可能缺图或缺样式", + "htmlResourcesTruncated": "页面资源过多,只取回前 {{limit}} 项", "mdRendered": "渲染", "mdSource": "源码", "mdViewA11y": "{{view}}视图", diff --git a/apps/mobile/src/session/htmlLocalResources.ts b/apps/mobile/src/session/htmlLocalResources.ts new file mode 100644 index 00000000000..788f3ef2219 --- /dev/null +++ b/apps/mobile/src/session/htmlLocalResources.ts @@ -0,0 +1,216 @@ +/** + * htmlLocalResources —— 本地 HTML 里「同目录资源引用」的纯字符串识别与改写。 + * --------------------------------------------------------------------------- + * 手机端渲染 agent 产出的 HTML(见 HtmlFileReader)只拿到 HTML 本身,页面里 + * `` / `` 这类相对引用在 + * `source={{ html }}` 的 about:blank 文档里解析不到,于是多文件产物「页面能开、 + * 图和样式全缺」。桌面端靠 `file://` 的同目录天然没有这个问题。 + * + * 这里补齐:把相对引用挑出来 → 换算成被控端绝对路径 → 上层逐个走既有 + * `media:fetch` 取件通道拿 presign 地址 → 回填进 HTML。**不新增 device-link + * channel、不新增安全面**,用的还是单文件预览已经在用的那条绝对路径取件通道。 + * + * 全部纯函数,无 IO、无 RN 依赖,可单测。取件与并发编排在 useHtmlLocalResources。 + * + * ── 边界(刻意收窄,fail-closed) ────────────────────────────────────────── + * - **只认相对引用**。`/assets/a.png`(根相对)在文件系统里指向盘根,语义上是 + * web root、换算不出正确路径;`file:///…`、`C:\…` 这类本机绝对引用则是最该 + * 警惕的形态(一个 HTML 就能把被控端任意路径拉进渲染)。两者一律不改写, + * 保持原样(渲染成破图),不猜。 + * - **含 `..` 段的一律拒绝**。想放行就必须定义「逃到哪一层还算安全」,那是独立 + * 的边界决定;这里选最简单且明确安全的口径:引用只能落在 HTML 自己所在目录 + * 的子树内。真实的「单文件 + assets/」产物不受影响。 + * - http(s) / data: / blob: / 协议相对 `//host` / 纯锚点 `#x` 不属于本地资源, + * 原样保留(它们本来就能加载,或本来就不该加载)。 + * - `srcset` 不处理(多候选 + 密度描述符,收益低于复杂度),`'; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.fragment)).toEqual(['#logo', '#download']); + // 取件按无 fragment 的路径走(同一个文件只取一次)。 + expect(refs[0].absPath).toBe('/Users/me/drafts/icons.svg'); + expect(refs[1].absPath).toBe('/Users/me/drafts/sprite.svg'); + const urls = new Map([ + ['/Users/me/drafts/icons.svg', 'data:image/svg+xml;base64,AAA'], + ['/Users/me/drafts/sprite.svg', 'data:image/svg+xml;base64,BBB'], + ]); + expect(applyHtmlResourceUrls(html, refs, urls)).toBe( + '' + + '', + ); + }); + + it('无 fragment 时不多加 `#`', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs[0].fragment).toBe(''); + expect(applyHtmlResourceUrls(html, refs, new Map([['/Users/me/drafts/a.png', 'data:image/png;base64,X']]))) + .toBe(''); + }); + + it('同一 SVG 的不同 fragment 只取一次件', () => { + const refs = collectHtmlLocalResourceRefs('', BASE); + expect(planHtmlResourceFetches(refs).targets).toEqual([ + { absPath: '/Users/me/drafts/s.svg', mimeType: 'image/svg+xml' }, + ]); + }); +}); diff --git a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts index 682222114eb..39e7728f520 100644 --- a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts +++ b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts @@ -33,42 +33,51 @@ describe('HTML_PREVIEW_CSP(策略内容)', () => { describe('withHtmlPreviewCsp(注入位置)', () => { const cspTag = ``; - it('有 时插在 head 开标签之后(策略只对其后内容生效)', () => { - const out = withHtmlPreviewCsp('xb'); - expect(out).toBe(`${cspTag}xb`); - // 必须在任何可加载资源之前。 - expect(out.indexOf(cspTag)).toBeLessThan(out.indexOf('')); + it('插在 doctype 之后、任何作者内容之前', () => { + expect(withHtmlPreviewCsp('<!doctype html><html><head><title>xb')) + .toBe(`${cspTag}xb`); }); - it('带属性的 也认', () => { - const out = withHtmlPreviewCsp(''); - expect(out).toContain(`${cspTag}`); + it('无 doctype 时直接前置', () => { + expect(withHtmlPreviewCsp('b')).toBe(`${cspTag}b`); + expect(withHtmlPreviewCsp('

hi

')).toBe(`${cspTag}

hi

`); }); - it('无 时补一个,插在 之后', () => { - const out = withHtmlPreviewCsp('b'); - expect(out).toBe(`${cspTag}b`); + it('**不去找 ** —— 注释里的假标签会把策略插进注释、整份失效', () => { + // review P1:`` 正则会命中注释内容,CSP 落在注释里等于没有策略。 + const html = ''; + const out = withHtmlPreviewCsp(html); + // 策略必须在注释之前,而不是被塞进注释里。 + expect(out.indexOf(cspTag)).toBeLessThan(out.indexOf('')); + expect(out).toBe(`${cspTag}`); }); - it('只有 doctype 时插在 doctype 之后 —— 不能挤到 doctype 之前', () => { - const out = withHtmlPreviewCsp('b'); - expect(out.startsWith('')).toBe(true); - expect(out).toBe(`${cspTag}b`); - }); - - it('片段(无 doctype 无 html)才整份前置', () => { - expect(withHtmlPreviewCsp('

hi

')).toBe(`${cspTag}

hi

`); + it('真实 之前的脚本也必须在策略之后执行', () => { + // 前置 script 会被浏览器照常执行;插在 head 里的话它已经在策略生效前跑完了。 + const html = ''; + const out = withHtmlPreviewCsp(html); + expect(out.indexOf(cspTag)).toBeLessThan(out.indexOf(''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.raw)).toEqual(['app.js']); + }); + + it('CSS 注释里的 url() 不进候选,同块内真 url() 照旧取', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs.map((r) => r.raw)).toEqual(['new.png']); + }); + + it('未闭合注释 / 未闭合脚本掩到文末(真 parser 同样吞掉后面)', () => { + expect(collectHtmlLocalResourceRefs('', BASE); + expect(refs.map((r) => r.raw)).toEqual(['real.png']); + }); + + it('回填下标仍对齐原文(掩码只服务扫描)', () => { + const html = ''; + const refs = collectHtmlLocalResourceRefs(html, BASE); + expect(refs).toHaveLength(1); + expect(html.slice(refs[0].start, refs[0].end)).toBe('new.png'); + const out = applyHtmlResourceUrls(html, refs, new Map([[refs[0].absPath, 'data:image/png;base64,AAA']])); + expect(out).toContain('url(data:image/png;base64,AAA)'); + // 注释原文一字不动。 + expect(out).toContain(''); + }); +}); diff --git a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts index ad03fb03839..f8cab5413b8 100644 --- a/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts +++ b/apps/mobile/src/__tests__/htmlPreviewCsp.test.ts @@ -114,9 +114,16 @@ describe('渲染载体与取件的安全接线(源码级守卫)', () => { // 且对象删掉后缓存命中会回死 URL。 expect(pageSource).toContain('fetchRemoteAbsFileOnce('); // 删除必须在 finally 里:下载失败 / 超限同样要回收(失败路径最容易漏)。 + // + // 回收对象取自 onOssKey 累加的集合,**不是** media.ossKey(review P1 第二轮): + // presign 失败时取件在返回 media 之前就抛错,围绕 media 写的 finally 不会执行; + // 瞬断重试还会重复上传、产出不同的 key。 const body = /const fetchResourceDataUri = useCallback\(([\s\S]*?)\n \);/.exec(pageSource); expect(body, '未找到 fetchResourceDataUri 实现').not.toBeNull(); - expect(body![1]).toMatch(/finally\s*\{[\s\S]*?deleteResourceOssObject\(media\.ossKey\)/); + expect(body![1]).toMatch(/finally\s*\{[\s\S]*?for \(const ossKey of uploadedKeys\) deleteResourceOssObject\(ossKey\)/); + expect(body![1]).not.toContain('deleteResourceOssObject(media.ossKey)'); + // 集合必须在 try 之外声明,否则取件抛错时 finally 拿不到它。 + expect(body![1]).toMatch(/const uploadedKeys = new Set\(\);\s*\n\s*try \{/); }); it('SSH 会话的资源取件必须带会话上下文', () => { diff --git a/apps/mobile/src/__tests__/remoteMedia.test.ts b/apps/mobile/src/__tests__/remoteMedia.test.ts index 867c0206cc8..9affab38699 100644 --- a/apps/mobile/src/__tests__/remoteMedia.test.ts +++ b/apps/mobile/src/__tests__/remoteMedia.test.ts @@ -45,6 +45,49 @@ describe('mobile remote media', () => { expect(presignGet).toHaveBeenCalledWith('cindy/device-link/user-1/a.png'); }); + it('hands the ossKey to onOssKey before presign, so a presign failure is still recoverable', async () => { + // presign 失败会让本函数在返回之前抛错 —— 调用方拿不到 resolved 结果,围绕它写的 + // finally 不会执行,已上传的对象永久遗留(review P1)。onOssKey 让 key 在上传成功那一刻 + // 就交出去,失败路径也能 best-effort DELETE。 + const order: string[] = []; + const fetchRemoteMedia = vi.fn(async () => { + order.push('upload'); + return { ossKey: 'cindy/device-link/user-1/a.png', mimeType: 'image/png', size: 2048 }; + }); + const presignGet = vi.fn(async () => { + order.push('presign'); + throw new Error('relay down'); + }); + const seen: string[] = []; + + await expect(resolveMobileRemoteMedia( + { kind: 'image', url: 'xdt-image://cache/a.png' }, + { fetchRemoteMedia, presignGet }, + { onOssKey: (key) => { order.push('onOssKey'); seen.push(key); } }, + )).rejects.toThrow('relay down'); + + expect(seen).toEqual(['cindy/device-link/user-1/a.png']); + // 顺序必须是 上传 → 交出 key → presign,否则失败窗口依旧漏。 + expect(order).toEqual(['upload', 'onOssKey', 'presign']); + }); + + it('does not call onOssKey for inline results (no OSS object to reclaim)', async () => { + const fetchRemoteMedia = vi.fn(async () => ({ + ossKey: '', + mimeType: 'image/webp', + size: 4096, + inlineBase64: 'aGVsbG8=', + })); + const onOssKey = vi.fn(); + + await resolveMobileRemoteMedia( + { kind: 'image', url: 'xdt-image://cache/a.png' }, + { fetchRemoteMedia, presignGet: vi.fn() }, + { thumbnail: true, onOssKey }, + ); + expect(onOssKey).not.toHaveBeenCalled(); + }); + it('returns inline thumbnail bytes as a data uri without touching presign', async () => { const fetchRemoteMedia = vi.fn(async () => ({ ossKey: '', diff --git a/apps/mobile/src/i18n/locales/en/files.json b/apps/mobile/src/i18n/locales/en/files.json index c34616286c3..3a07036261b 100644 --- a/apps/mobile/src/i18n/locales/en/files.json +++ b/apps/mobile/src/i18n/locales/en/files.json @@ -69,6 +69,7 @@ "fetchingHtmlResources": "Fetching page resources…", "htmlResourcesMissing": "{{count}} page resource(s) could not be fetched; images or styles may be missing", "htmlResourcesTruncated": "Too many page resources; only the first {{limit}} were fetched", + "htmlResourcesOverBudget": "{{count}} more resource(s) were skipped because the page's total size limit was reached", "mdRendered": "Rendered", "mdSource": "Source", "mdViewA11y": "{{view}} view", diff --git a/apps/mobile/src/i18n/locales/ja/files.json b/apps/mobile/src/i18n/locales/ja/files.json index 791e80bbab6..cbbd0fc07a5 100644 --- a/apps/mobile/src/i18n/locales/ja/files.json +++ b/apps/mobile/src/i18n/locales/ja/files.json @@ -69,6 +69,7 @@ "fetchingHtmlResources": "ページリソースを取得中…", "htmlResourcesMissing": "{{count}} 件のページリソースを取得できませんでした。画像やスタイルが欠ける場合があります", "htmlResourcesTruncated": "ページリソースが多すぎます。先頭 {{limit}} 件のみ取得しました", + "htmlResourcesOverBudget": "合計サイズの上限に達したため、さらに {{count}} 件のリソースを取得していません", "mdRendered": "レンダリング", "mdSource": "ソース", "mdViewA11y": "{{view}}表示", diff --git a/apps/mobile/src/i18n/locales/ko/files.json b/apps/mobile/src/i18n/locales/ko/files.json index 19407ab722d..8b9d82573d8 100644 --- a/apps/mobile/src/i18n/locales/ko/files.json +++ b/apps/mobile/src/i18n/locales/ko/files.json @@ -69,6 +69,7 @@ "fetchingHtmlResources": "페이지 리소스를 가져오는 중…", "htmlResourcesMissing": "페이지 리소스 {{count}}개를 가져오지 못했습니다. 이미지나 스타일이 누락될 수 있습니다", "htmlResourcesTruncated": "페이지 리소스가 너무 많습니다. 처음 {{limit}}개만 가져왔습니다", + "htmlResourcesOverBudget": "전체 크기 상한에 도달해 리소스 {{count}}개를 더 가져오지 않았습니다", "mdRendered": "렌더링", "mdSource": "소스", "mdViewA11y": "{{view}} 보기", diff --git a/apps/mobile/src/i18n/locales/zh-CN/files.json b/apps/mobile/src/i18n/locales/zh-CN/files.json index a5458eec90d..d36fd0de8e1 100644 --- a/apps/mobile/src/i18n/locales/zh-CN/files.json +++ b/apps/mobile/src/i18n/locales/zh-CN/files.json @@ -69,6 +69,7 @@ "fetchingHtmlResources": "正在取回页面资源…", "htmlResourcesMissing": "{{count}} 项页面资源没取到,可能缺图或缺样式", "htmlResourcesTruncated": "页面资源过多,只取回前 {{limit}} 项", + "htmlResourcesOverBudget": "另有 {{count}} 项资源因总大小超限未取回", "mdRendered": "渲染", "mdSource": "源码", "mdViewA11y": "{{view}}视图", diff --git a/apps/mobile/src/session/HtmlFileReader.tsx b/apps/mobile/src/session/HtmlFileReader.tsx index 1395891625b..fa20a47a5fe 100644 --- a/apps/mobile/src/session/HtmlFileReader.tsx +++ b/apps/mobile/src/session/HtmlFileReader.tsx @@ -16,9 +16,9 @@ * 页面(内联样式与脚本、`data:` 图、公网图)完整可读;多文件站点式产物会缺资源,退路 * 是工具栏「分享」把文件送到电脑上看。桌面靠 `file://` 的同目录天然没有这个问题。 * - * 导航一律拦下:about: 放行(文档自身与页内锚点),**用户点击**的 http(s) 转系统浏览器, - * 其余一切(程序化导航、`file://`、`tel:`、`mailto:`、自定义 scheme)明确拒绝且 - * **不交给 Linking**。 + * 导航一律拦下:只有 `about:`(文档自身与页内锚点)放行,**其余一切明确拒绝,且不交给 + * Linking** —— 包含用户主动点击的 http(s) 外链。这是一个**完全离线的预览沙箱**,没有任何 + * 出网信道(理由见 interceptHtmlNavigation)。 * 不挂 onMessage:页面里的 postMessage 无人消费,不给任意生成物开一条通向 RN 侧的通道。 * Android 另关多窗口:`window.open` / `target="_blank"` 走的是 onCreateWindow,不经过下面 * 的导航回调,不关掉等于给策略留一个后门(见 setSupportMultipleWindows 处的说明)。 @@ -29,7 +29,7 @@ * 会拉起外部应用,把下面这段策略整个绕过去。放到 `['*']` 之后,回调是唯一决策点。 */ import { useMemo } from 'react'; -import { Linking, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { WebView } from 'react-native-webview'; import type { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes'; @@ -73,29 +73,35 @@ export function HtmlFileReader({ html, testID }: { html: string; testID?: string /** * 唯一的导航决策点(originWhitelist 已放到 `['*']`,所有请求都会先到这里)。 * - * 三档,默认拒绝: + * 两档,默认拒绝: * - `about:` —— 文档自身(`source={{ baseUrl: 'about:blank' }}`)与页内锚点,放行; - * - **用户点击的** `http(s)` —— 不在预览 WebView 里导航走,交系统浏览器打开; - * - **其余一切** —— 拒绝,且**不调 Linking**:`file://` 在手机上指向 app 沙盒而非 - * 被控端,`tel:` / `mailto:` / `intent:` 等会拉起外部应用。生成物不该有这个能力。 + * - **其余一切** —— 拒绝,且**不调 Linking**(不 import 它,守卫用例钉住)。 * - * ⚠️ 为什么必须卡 `navigationType === 'click'`(review P1 实捉):HTML 与静态 markdown - * 不同,这里 JavaScript 是开启的。`location.href = '…'`、表单自动提交、meta refresh - * 同样会走进这个回调 —— 不区分的话,用户只要打开一份生成物就会被脚本强制带出 Cindy - * 跳到任意网页(也是一条把页面内容带出去的信道)。 + * ── 为什么连「用户点击的 http(s) 外链」也不放(review P1,曾经放过) ────────── + * 本预览会把被控电脑上的**同目录资源内联成 `data:` URI 塞进这个页面**,而页面里的 + * JavaScript 是开启的(CSP 允许 `script-src 'unsafe-inline'`,不然自包含产物的交互全废)。 + * 于是作者脚本可以读到那些 `data:` URI 的字节,拼进一个真实的 + * ``(甚至铺一层全屏透明覆盖层),用户随手一点就命中 + * `navigationType === 'click'`——数据在用户看见浏览器之前就已经发出去了。 * - * Android 的取舍:RNW 的 Android 侧 `createWebViewEvent` 根本不设 `navigationType` - * (只有 url / title / loading 等),所以那边**无法确认**是否用户点击 → 一律按拒绝处理。 - * 代价是 Android 上生成物里的外链点不开(可用工具栏「分享」把文件送到别处打开); - * 方向与本文件其余判据一致:拿不准就不放行。 + * **CSP 挡不住这条**:它管子资源与表单(`connect-src` / `img-src` / `form-action`), + * 顶层导航不在其控制范围内(`navigate-to` 指令已从 CSP3 移除,两端都不实现)。所以 + * 「点击门」只能挡住程序化导航,挡不住脚本**构造出的、由用户点击触发**的 URL。 + * + * 两条候选补救都不划算: + * - **弹确认框**:要用户对着一条 2KB base64 的 URL 判断安全性,是安全剧场; + * - **静态 href 白名单**(只放原文里字面存在的 URL):挡得住,但要引入 URL 归一化 + * (HTML 实体、百分号编码、尾斜杠),归一化对不上就变成「合法外链静默点不开」。 + * 而这条能力**本来就只在 iOS 上存在** —— Android 侧 RNW 的 `createWebViewEvent` 根本不设 + * `navigationType`,那边一直拿不准、一直是拒绝。删掉它是把两端对齐,不是砍掉一个统一功能。 + * + * 与本 PR 已经接受的取舍也一致:CSP 让公网 https 图片 / 字体在预览里不加载,预览本就 + * 是离线的;留一条点击外送信道反而是这套设计里唯一的破口。外链的退路是工具栏「分享」把 + * 文件送到电脑或浏览器里打开,或切「源码」态自己看 URL。 */ function interceptHtmlNavigation(request: ShouldStartLoadRequest): boolean { const url = request.url ?? ''; if (url === 'about:blank' || url.startsWith('about:')) return true; - if (/^https?:\/\//i.test(url) && request.navigationType === 'click') { - void Linking.openURL(url).catch(() => undefined); - return false; - } return false; } diff --git a/apps/mobile/src/session/htmlLocalResources.ts b/apps/mobile/src/session/htmlLocalResources.ts index 857869e4d13..4e878b3862e 100644 --- a/apps/mobile/src/session/htmlLocalResources.ts +++ b/apps/mobile/src/session/htmlLocalResources.ts @@ -175,19 +175,97 @@ export function htmlBaseDirOf(htmlAbsPath: string): string { return htmlAbsPath.slice(0, lastSep); } +/** + * 把「浏览器不会当资源看」的惰性文本替换成**等长**空白,只用于扫描。 + * + * 为什么需要(review P1):HTML 注释、``,按未闭合处理会把文末之前的真资源全抹掉。 + const afterComments = out.join(''); + const afterCommentsLower = afterComments.toLowerCase(); + const scriptOpenRe = /]*>/gi; + let open: RegExpExecArray | null; + while ((open = scriptOpenRe.exec(afterComments)) !== null) { + const bodyStart = open.index + open[0].length; + const closeAt = afterCommentsLower.indexOf('` 体里的 CSS 注释。style 体本身要保留 —— 里面的 `url()` 是真资源。 + // 同理扫已抹掉注释与脚本体的副本。 + const masked = out.join(''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let style: RegExpExecArray | null; + while ((style = styleRe.exec(masked)) !== null) { + const bodyStart = style.index + style[0].indexOf(style[1], style[0].indexOf('>')); + const body = style[1]; + for (let at = body.indexOf('/*'); at >= 0; at = body.indexOf('/*', at + 1)) { + const close = body.indexOf('*/', at + 2); + const end = close < 0 ? body.length : close + 2; + blank(bodyStart + at, bodyStart + end); + if (close < 0) break; + at = end - 1; + } + } + return out.join(''); +} + /** * 扫出 HTML 里全部可改写的本地资源引用(按出现顺序)。 * * 标签属性(`` / `` / …)与 `'; - const refs = collectHtmlLocalResourceRefs(html, BASE); - expect(refs.map((r) => r.raw)).toEqual(['new.png']); - }); - - it('未闭合标记一律不掩码(掩错的代价比不掩大一个数量级)', () => { - // 早先按「真 parser 会吞到文末」掩到文末。那让 `
` - // 这类**属性值里的字面标签**把整页真资源掩掉(review P1)。改成配不上闭合就一个字符 - // 都不掩:代价只是伪引用占配额,不会让正常页面静默全缺。 - expect(collectHtmlLocalResourceRefs('', BASE); - expect(refs.map((r) => r.raw)).toEqual(['real.png']); - }); - - it('回填下标仍对齐原文(掩码只服务扫描)', () => { - const html = ''; - const refs = collectHtmlLocalResourceRefs(html, BASE); - expect(refs).toHaveLength(1); - expect(html.slice(refs[0].start, refs[0].end)).toBe('new.png'); - const out = applyHtmlResourceUrls(html, refs, new Map([[refs[0].absPath, 'data:image/png;base64,AAA']])); - expect(out).toContain('url(data:image/png;base64,AAA)'); - // 注释原文一字不动。 - expect(out).toContain(''); - }); -}); - -describe(' 会让 `` 被当成真资源。 - const html = ''; - expect(collectHtmlLocalResourceRefs(html, BASE).map((r) => r.raw)).toEqual(['real.png']); - }); - - it('未闭合模板不掩码;落单的闭合标记不影响后续', () => { - expect(collectHtmlLocalResourceRefs('', BASE).map((r) => r.raw)) - .toEqual(['a.png']); - }); -}); - describe('总量预算按回填后的实际增量计费', () => { it('同一资源被多处引用时按 refCount 倍计费', () => { // 去重后只有 1 个 target,但回填会插入 100 次 —— 只计一次就会放过 100 倍的内存。 @@ -547,30 +465,6 @@ describe('总量预算按回填后的实际增量计费', () => { }); }); -describe('属性值里的字面标签不得掩掉真资源(review P1)', () => { - it('data-* 属性里写着