diff --git a/static/src/app/bootstrap.ts b/static/src/app/bootstrap.ts index a37eeed3..537df08b 100644 --- a/static/src/app/bootstrap.ts +++ b/static/src/app/bootstrap.ts @@ -3,6 +3,12 @@ import katex from 'katex'; import DOMPurify from 'dompurify'; import { createApp } from 'vue'; import ShowFileCard from '../components/chat/ShowFileCard.vue'; +import { buildShowHtmlIframeSrcdoc } from '../utils/showHtmlSandbox'; +import { + openShowHtmlFullscreen, + closeShowHtmlFullscreen, + type ShowHtmlFullscreenPayload +} from '../utils/showHtmlFullscreen'; let showImageRenderSeq = 0; let showImageDebugLogCount = 0; @@ -293,209 +299,6 @@ function sanitizeShowHtmlContent(rawHtml: string) { return doc.body.innerHTML; } -/** - * show_html js=on 沙箱 CSP(通过 srcdoc 内 交付,必须位于任何资源引用之前)。 - * - connect-src 'none':禁 fetch/XHR/WebSocket/EventSource,杜绝数据外发与内网探测 - * - script/style/img/font/media 允许 https::保留卡片引用 CDN 资源与外链图的能力 - * (残余风险:GET 类资源加载可作为信标外发少量数据,已与需求方确认接受) - * - frame/child/worker-src 'none':禁嵌套远程框架与 Worker;object/form/base 收紧 - * 注:iframe 的 csp 属性对 srcdoc 的支持仍是未决规范(w3c/webappsec-csp#492),故走 meta 交付。 - */ -const SHOW_HTML_IFRAME_CSP = [ - "default-src 'none'", - "script-src 'unsafe-inline' https:", - "style-src 'unsafe-inline' https:", - "img-src data: blob: https:", - "font-src data: https:", - "media-src data: blob: https:", - "connect-src 'none'", - "form-action 'none'", - "base-uri 'none'", - "frame-src 'none'", - "child-src 'none'", - "worker-src 'none'", - "object-src 'none'" -].join('; '); - -/** - * show_html js=on 沙箱守卫脚本(注入 srcdoc 最前,先于任何卡片脚本执行)。 - * 解决问题:iframe 内 scrollIntoView()/focus() 的滚动副作用会穿透 iframe 边界滚动宿主页面 - * (CSSOM View 规范行为,sandbox 属性与 CSS 均无法声明式禁止,见 w3c/csswg-drafts#7134)。 - * 策略:重写相关 API,使滚动只发生在本 iframe 文档内部;拦截同文档锚点导航的默认滚动; - * 对同源子 frame 递归修补以收窄“干净原型”绕过窗口。 - * 已知边界:不防“针对本守卫的确定性绕过”(威胁模型为提示注入生成的一般恶意内容)。 - */ -const SHOW_HTML_IFRAME_GUARD_SCRIPT = `(function () { - 'use strict'; - if (window.__astrionSandboxGuard) return; - window.__astrionSandboxGuard = true; - - function scrollingContainerList(el) { - var list = []; - var node = el.parentElement; - while (node && node !== document.body && node !== document.documentElement) { - var s = window.getComputedStyle(node); - if (/(auto|scroll|overlay)/.test(String(s.overflow) + String(s.overflowY) + String(s.overflowX))) { - list.push(node); - } - node = node.parentElement; - } - list.push(document.scrollingElement || document.documentElement); - return list; - } - - function scrollOneContainer(container, el, block) { - var isRoot = container === (document.scrollingElement || document.documentElement); - var eRect = el.getBoundingClientRect(); - var viewH = isRoot ? window.innerHeight : container.clientHeight; - var top = isRoot ? eRect.top : eRect.top - container.getBoundingClientRect().top; - var bottom = top + eRect.height; - var delta = 0; - if (block === 'start') delta = top; - else if (block === 'end') delta = bottom - viewH; - else if (block === 'center') delta = top - (viewH - eRect.height) / 2; - else if (top < 0) delta = top; - else if (bottom > viewH) delta = Math.min(bottom - viewH, top); - if (delta) container.scrollTop += delta; - } - - function localScrollIntoView(el, arg) { - var block = 'start'; - if (arg === false) block = 'end'; - else if (arg && typeof arg === 'object' && typeof arg.block === 'string') block = arg.block; - var containers = scrollingContainerList(el); - for (var i = 0; i < containers.length; i++) { - scrollOneContainer(containers[i], el, block); - } - } - - Element.prototype.scrollIntoView = function (arg) { localScrollIntoView(this, arg); }; - if ('scrollIntoViewIfNeeded' in Element.prototype) { - Element.prototype.scrollIntoViewIfNeeded = function () { localScrollIntoView(this, { block: 'nearest' }); }; - } - - var origFocus = HTMLElement.prototype.focus; - HTMLElement.prototype.focus = function (options) { - var opts = options && typeof options === 'object' - ? Object.assign({}, options, { preventScroll: true }) - : { preventScroll: true }; - origFocus.call(this, opts); - localScrollIntoView(this, { block: 'nearest' }); - }; - - document.addEventListener('click', function (ev) { - var t = ev.target; - var anchor = t && t.closest ? t.closest('a[href^="#"]') : null; - if (!anchor) return; - var id = (anchor.getAttribute('href') || '').slice(1); - ev.preventDefault(); - if (!id) return; - var target = document.getElementById(id); - if (target) { - // 先摘掉 id 再更新 hash,避免浏览器默认锚点滚动穿透到宿主页面 - target.removeAttribute('id'); - try { window.location.hash = id; } catch (err) { /* opaque origin 下忽略 */ } - target.setAttribute('id', id); - localScrollIntoView(target, { block: 'start' }); - } else { - try { window.location.hash = id; } catch (err) { /* ignore */ } - } - }, true); - - function patchFrame(win) { - try { - win.Element.prototype.scrollIntoView = Element.prototype.scrollIntoView; - win.HTMLElement.prototype.focus = HTMLElement.prototype.focus; - } catch (err) { /* 跨源子 frame 跳过 */ } - } - new MutationObserver(function (mutations) { - for (var i = 0; i < mutations.length; i++) { - var added = mutations[i].addedNodes; - for (var j = 0; j < added.length; j++) { - var n = added[j]; - if (!n || n.nodeType !== 1) continue; - var frames = []; - if (n.tagName === 'IFRAME' || n.tagName === 'FRAME') frames.push(n); - if (n.querySelectorAll) { - var found = n.querySelectorAll('iframe,frame'); - for (var k = 0; k < found.length; k++) frames.push(found[k]); - } - for (var m = 0; m < frames.length; m++) { - if (frames[m].contentWindow) patchFrame(frames[m].contentWindow); - } - } - } - }).observe(document.documentElement, { childList: true, subtree: true }); -})();`; - -/** 生成注入内容:CSP meta 必须早于任何资源引用,守卫脚本必须早于任何卡片脚本 */ -function buildShowHtmlSandboxInjection() { - return ``; -} - -/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 最前(无 head 则补一个) */ -function injectShowHtmlSandboxGuards(doc: string) { - const injection = buildShowHtmlSandboxInjection(); - const headMatch = /]*>/i.exec(doc); - if (headMatch) { - const idx = headMatch.index + headMatch[0].length; - return doc.slice(0, idx) + injection + doc.slice(idx); - } - const htmlMatch = /]*>/i.exec(doc); - if (htmlMatch) { - const idx = htmlMatch.index + htmlMatch[0].length; - return doc.slice(0, idx) + `${injection}` + doc.slice(idx); - } - return injection + doc; -} - -function buildShowHtmlIframeSrcdoc(rawHtml: string) { - const content = (rawHtml || '').trim(); - if (!content) { - return ''; - } - - // 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。 - if (/]/i.test(content)) { - return injectShowHtmlSandboxGuards(content); - } - - return ` - - - - - ${buildShowHtmlSandboxInjection()} - - - ${content} -`; -} - function escapeHtml(input: string) { return input .replace(/&/g, '&') @@ -505,16 +308,6 @@ function escapeHtml(input: string) { .replace(/'/g, '''); } -/** - * show_html 卡片全屏预览载荷。 - * - js="on" 卡片:直接复用已注入 CSP/守卫的 srcdoc,沙箱允许脚本 - * - js="off" 卡片:用 sanitize 后的 HTML 现场构建 srcdoc,沙箱禁脚本(与卡片内语义一致) - */ -interface ShowHtmlFullscreenPayload { - srcdoc: string; - allowScripts: boolean; -} - interface ShowHtmlCardControls { onRefresh: () => void; getFullscreenPayload: () => ShowHtmlFullscreenPayload | null; @@ -650,135 +443,6 @@ function openShowHtmlCardMenu(anchor: HTMLElement, wrapper: HTMLElement) { showHtmlCardMenuAnchor = anchor; } -// ===== show_html 全屏预览(应用内 overlay,复用沙箱 srcdoc,不走路由) ===== -interface ShowHtmlFullscreenState { - root: HTMLElement; - iframe: HTMLIFrameElement; - payload: ShowHtmlFullscreenPayload | null; -} - -const SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC = - ''; - -let showHtmlFullscreenState: ShowHtmlFullscreenState | null = null; -let showHtmlFullscreenEventsBound = false; - -function isShowHtmlFullscreenOpen(): boolean { - return !!showHtmlFullscreenState?.root.classList.contains('is-open'); -} - -function bindShowHtmlFullscreenGlobalEvents() { - if (showHtmlFullscreenEventsBound || typeof document === 'undefined') return; - showHtmlFullscreenEventsBound = true; - // 桌面端 Esc 退出;移动端没有 Esc,依赖顶栏 ✕ 按钮(触屏主路径) - document.addEventListener('keydown', (event) => { - if (event.key !== 'Escape' || !isShowHtmlFullscreenOpen()) return; - event.stopPropagation(); - closeShowHtmlFullscreen(); - }); -} - -function ensureShowHtmlFullscreenDom(): ShowHtmlFullscreenState { - if (showHtmlFullscreenState) return showHtmlFullscreenState; - bindShowHtmlFullscreenGlobalEvents(); - - const root = document.createElement('div'); - root.className = 'show-html-fullscreen'; - root.setAttribute('role', 'dialog'); - root.setAttribute('aria-modal', 'true'); - root.setAttribute('aria-label', '卡片全屏预览'); - - const bar = document.createElement('div'); - bar.className = 'show-html-fullscreen__bar'; - const title = document.createElement('span'); - title.className = 'show-html-fullscreen__title'; - title.textContent = '全屏预览'; - const actions = document.createElement('div'); - actions.className = 'show-html-fullscreen__actions'; - - const refreshBtn = document.createElement('button'); - refreshBtn.type = 'button'; - refreshBtn.className = 'show-html-fullscreen__btn'; - refreshBtn.title = '刷新'; - refreshBtn.setAttribute('aria-label', '刷新'); - refreshBtn.textContent = '↻'; - - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'show-html-fullscreen__btn show-html-fullscreen__btn--close'; - closeBtn.title = '关闭(Esc)'; - closeBtn.setAttribute('aria-label', '关闭全屏预览'); - closeBtn.textContent = '✕'; - - actions.appendChild(refreshBtn); - actions.appendChild(closeBtn); - bar.appendChild(title); - bar.appendChild(actions); - - const body = document.createElement('div'); - body.className = 'show-html-fullscreen__body'; - const iframe = document.createElement('iframe'); - iframe.className = 'show-html-fullscreen__iframe'; - iframe.setAttribute('referrerpolicy', 'no-referrer'); - // 与内联卡片一致:overlay 用 CSS fixed 占满视口,不需要浏览器 Fullscreen API - iframe.setAttribute('allow', "fullscreen 'none'"); - body.appendChild(iframe); - - root.appendChild(bar); - root.appendChild(body); - - refreshBtn.onclick = (event) => { - event.preventDefault(); - event.stopPropagation(); - const state = showHtmlFullscreenState; - if (!state?.payload) return; - // 与内联卡片刷新同策略:先挂空文档再重挂,强制 iframe 内页面重载 - const keep = state.payload.srcdoc; - state.iframe.srcdoc = SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC; - requestAnimationFrame(() => { - if (showHtmlFullscreenState?.payload) { - showHtmlFullscreenState.iframe.srcdoc = keep; - } - }); - debugShowHtmlLog('fullscreen:refresh', { srcdocLength: keep.length }); - }; - closeBtn.onclick = (event) => { - event.preventDefault(); - event.stopPropagation(); - closeShowHtmlFullscreen(); - }; - - document.body.appendChild(root); - showHtmlFullscreenState = { root, iframe, payload: null }; - return showHtmlFullscreenState; -} - -function openShowHtmlFullscreen(payload: ShowHtmlFullscreenPayload) { - if (typeof document === 'undefined') return; - const state = ensureShowHtmlFullscreenDom(); - state.payload = payload; - // js=off 卡片内容已 sanitize,全屏时连脚本执行权也不给,语义与卡片内一致 - state.iframe.setAttribute('sandbox', payload.allowScripts ? 'allow-scripts' : ''); - state.iframe.srcdoc = payload.srcdoc; - state.root.classList.add('is-open'); - document.documentElement.classList.add('show-html-fullscreen-open'); - debugShowHtmlLog('fullscreen:open', { - allowScripts: payload.allowScripts, - srcdocLength: payload.srcdoc.length - }); -} - -function closeShowHtmlFullscreen() { - const state = showHtmlFullscreenState; - if (!state || !isShowHtmlFullscreenOpen()) return; - state.root.classList.remove('is-open'); - document.documentElement.classList.remove('show-html-fullscreen-open'); - state.payload = null; - // 清空 srcdoc:停掉 iframe 内脚本/定时器并释放内存 - state.iframe.srcdoc = SHOW_HTML_FULLSCREEN_EMPTY_SRCDOC; - debugShowHtmlLog('fullscreen:close', {}); -} - /** * 绑定 show_html 卡片右上角工具栏(⋯ 菜单:刷新 / 全屏)。 * 渲染器每次重渲染都会调用:最新回调挂在 wrapper 上,按钮与菜单只创建一次。 diff --git a/static/src/components/chat/ShowFileCard.vue b/static/src/components/chat/ShowFileCard.vue index ba44907e..6b330be4 100644 --- a/static/src/components/chat/ShowFileCard.vue +++ b/static/src/components/chat/ShowFileCard.vue @@ -4,6 +4,15 @@ {{ displayName }}
+ @@ -69,6 +78,8 @@ import { ref, computed, onMounted } from 'vue'; import { highlightCode, prismLangForPath } from '@/utils/prismHighlight'; import { renderMarkdown } from '../../composables/useMarkdownRenderer'; +import { buildShowHtmlIframeSrcdoc } from '@/utils/showHtmlSandbox'; +import { openShowHtmlFullscreen } from '@/utils/showHtmlFullscreen'; import PdfPreview from './PdfPreview.vue'; // 文本类文件内容缓存,避免父组件反复重建卡片时重复 fetch 导致闪烁 @@ -105,6 +116,7 @@ const error = ref(''); const rawContent = ref(''); const copied = ref(false); const contentUrl = ref(''); +const previewLoading = ref(false); // ---- 计算属性 ---- const fileType = computed(() => props.type || inferShowFileType(props.path)); @@ -119,6 +131,9 @@ const canCopy = computed(() => ['text', 'code', 'json', 'csv', 'markdown'].includes(fileType.value) ); +// HTML 文件额外提供全屏预览入口(卡片内仍按代码高亮展示) +const isHtmlFile = computed(() => /\.html?$/i.test(props.path)); + const isAndroidApp = computed(() => { return ( typeof (window as any).AndroidDownloadBridge !== 'undefined' || @@ -251,6 +266,72 @@ function buildContentUrl() { return `/api/file/content?path=${encodeURIComponent(props.path)}`; } +/** + * 检测 HTML 中的外部资源引用(src/href)。 + * srcdoc iframe 无基础 URL,相对路径与根路径引用都会解析失败; + * 绝对 URL(含协议相对)与 data/blob/锚点不受影响。 + */ +function detectExternalResourceRefs(html: string): boolean { + const attrPattern = /\b(?:src|href)\s*=\s*["']([^"']*)["']/gi; + let match: RegExpExecArray | null; + while ((match = attrPattern.exec(html))) { + const url = (match[1] || '').trim(); + if (!url || url.startsWith('#')) continue; + if (/^(https?:)?\/\//i.test(url)) continue; + if (/^(data|blob|mailto|tel|javascript):/i.test(url)) continue; + return true; + } + return false; +} + +/** + * 全屏预览 HTML 文件(V1:面向自包含单文件)。 + * 与 show_html js=on 卡片同策略:注入 CSP/守卫脚本,沙箱 allow-scripts。 + * 检测到外部资源引用时在顶栏提示「可能显示不完整」。 + */ +async function openHtmlPreview() { + if (previewLoading.value) return; + previewLoading.value = true; + error.value = ''; + try { + let content = rawContent.value || getCachedShowFileContent(props.path) || ''; + if (!content) { + const resp = await fetch(buildContentUrl()); + if (!resp.ok) { + let msg = resp.statusText; + try { + const j = await resp.json(); + msg = j.error || msg; + } catch { + // 响应体非 JSON 时沿用 statusText + } + error.value = msg || '加载失败'; + return; + } + content = await resp.text(); + setCachedShowFileContent(props.path, content); + // 顺手填充卡片代码区内容,保持两处一致 + rawContent.value = content; + } + if (!content.trim()) { + error.value = '文件内容为空'; + return; + } + openShowHtmlFullscreen({ + srcdoc: buildShowHtmlIframeSrcdoc(content), + allowScripts: true, + title: displayName.value, + notice: detectExternalResourceRefs(content) + ? '含外部资源引用,预览可能不完整' + : undefined + }); + } catch (e) { + error.value = (e as Error).message || '网络错误'; + } finally { + previewLoading.value = false; + } +} + async function loadContent() { if (!canPreview.value) return; error.value = ''; @@ -428,6 +509,16 @@ onMounted(() => { background: var(--surface-muted); border-color: var(--border-strong); } + + &:disabled { + opacity: 0.6; + cursor: default; + + &:hover { + background: var(--surface-base); + border-color: var(--border-default); + } + } } .sfc-btn-download { diff --git a/static/src/styles/components/chat/_chat-area.scss b/static/src/styles/components/chat/_chat-area.scss index f3fa58c9..40f5a997 100644 --- a/static/src/styles/components/chat/_chat-area.scss +++ b/static/src/styles/components/chat/_chat-area.scss @@ -1901,6 +1901,15 @@ html.show-html-fullscreen-open body { border-bottom: 1px solid var(--border-default); } +/* 标题 + 提示小字容器:标题优先收缩省略,提示文字更弱一级 */ +.show-html-fullscreen__heading { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: baseline; + gap: 8px; +} + .show-html-fullscreen__title { font-size: 13px; color: var(--text-secondary); @@ -1908,6 +1917,17 @@ html.show-html-fullscreen-open body { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + min-width: 0; +} + +.show-html-fullscreen__notice { + flex: 0 1 auto; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .show-html-fullscreen__actions { diff --git a/static/src/utils/showHtmlFullscreen.ts b/static/src/utils/showHtmlFullscreen.ts new file mode 100644 index 00000000..260ce8a4 --- /dev/null +++ b/static/src/utils/showHtmlFullscreen.ts @@ -0,0 +1,185 @@ +/** + * show_html 全屏预览 overlay(应用内 fixed 层,不走浏览器 Fullscreen API)。 + * 从 app/bootstrap.ts 抽取为全局单例,供 HTML 卡片与文件卡片(ShowFileCard)共用: + * - js="on" 卡片:直接复用已注入 CSP/守卫的 srcdoc,沙箱允许脚本 + * - js="off" 卡片:用 sanitize 后的 HTML 现场构建 srcdoc,沙箱禁脚本(与卡片内语义一致) + * - HTML 文件预览:与 js="on" 卡片同策略(注入 CSP/守卫,allow-scripts) + */ +import { EMPTY_SHOW_HTML_SRCDOC } from './showHtmlSandbox'; + +export interface ShowHtmlFullscreenPayload { + srcdoc: string; + allowScripts: boolean; + /** 顶栏标题(如文件名),缺省显示「全屏预览」 */ + title?: string; + /** 顶栏标题旁的提示小字(如「含外部资源引用,预览可能不完整」),缺省隐藏 */ + notice?: string; +} + +interface ShowHtmlFullscreenState { + root: HTMLElement; + iframe: HTMLIFrameElement; + titleEl: HTMLElement; + noticeEl: HTMLElement; + payload: ShowHtmlFullscreenPayload | null; +} + +// 与 bootstrap.ts 的 showHtml 调试通道一致:window.__SHOW_HTML_DEBUG__ 或 +// localStorage.showHtmlDebug = '1' 开启,统一 [SHOW_HTML_DEBUG] 前缀便于一次性筛选 +const SHOW_HTML_DEBUG_MAX_LOGS = 500; +let debugLogCount = 0; + +function isDebugEnabled(): boolean { + if (typeof window === 'undefined') return false; + try { + const explicitFlag = (window as any).__SHOW_HTML_DEBUG__; + if (explicitFlag === true || explicitFlag === '1') return true; + if (explicitFlag === false || explicitFlag === '0') return false; + const localFlag = window.localStorage?.getItem('showHtmlDebug'); + return localFlag === '1' || localFlag === 'true'; + } catch { + return false; + } +} + +function debugLog(event: string, payload: Record = {}) { + if (!isDebugEnabled()) return; + if (debugLogCount >= SHOW_HTML_DEBUG_MAX_LOGS) return; + debugLogCount += 1; + if (debugLogCount === SHOW_HTML_DEBUG_MAX_LOGS) { + console.warn('[SHOW_HTML_DEBUG]', 'log-limit-reached', { max: SHOW_HTML_DEBUG_MAX_LOGS }); + return; + } + console.log('[SHOW_HTML_DEBUG]', event, payload); +} + +let showHtmlFullscreenState: ShowHtmlFullscreenState | null = null; +let showHtmlFullscreenEventsBound = false; + +export function isShowHtmlFullscreenOpen(): boolean { + return !!showHtmlFullscreenState?.root.classList.contains('is-open'); +} + +function bindShowHtmlFullscreenGlobalEvents() { + if (showHtmlFullscreenEventsBound || typeof document === 'undefined') return; + showHtmlFullscreenEventsBound = true; + // 桌面端 Esc 退出;移动端没有 Esc,依赖顶栏 ✕ 按钮(触屏主路径) + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || !isShowHtmlFullscreenOpen()) return; + event.stopPropagation(); + closeShowHtmlFullscreen(); + }); +} + +function ensureShowHtmlFullscreenDom(): ShowHtmlFullscreenState { + if (showHtmlFullscreenState) return showHtmlFullscreenState; + bindShowHtmlFullscreenGlobalEvents(); + + const root = document.createElement('div'); + root.className = 'show-html-fullscreen'; + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + root.setAttribute('aria-label', '全屏预览'); + + const bar = document.createElement('div'); + bar.className = 'show-html-fullscreen__bar'; + const heading = document.createElement('div'); + heading.className = 'show-html-fullscreen__heading'; + const title = document.createElement('span'); + title.className = 'show-html-fullscreen__title'; + title.textContent = '全屏预览'; + const notice = document.createElement('span'); + notice.className = 'show-html-fullscreen__notice'; + notice.style.display = 'none'; + heading.appendChild(title); + heading.appendChild(notice); + const actions = document.createElement('div'); + actions.className = 'show-html-fullscreen__actions'; + + const refreshBtn = document.createElement('button'); + refreshBtn.type = 'button'; + refreshBtn.className = 'show-html-fullscreen__btn'; + refreshBtn.title = '刷新'; + refreshBtn.setAttribute('aria-label', '刷新'); + refreshBtn.textContent = '↻'; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'show-html-fullscreen__btn show-html-fullscreen__btn--close'; + closeBtn.title = '关闭(Esc)'; + closeBtn.setAttribute('aria-label', '关闭全屏预览'); + closeBtn.textContent = '✕'; + + actions.appendChild(refreshBtn); + actions.appendChild(closeBtn); + bar.appendChild(heading); + bar.appendChild(actions); + + const body = document.createElement('div'); + body.className = 'show-html-fullscreen__body'; + const iframe = document.createElement('iframe'); + iframe.className = 'show-html-fullscreen__iframe'; + iframe.setAttribute('referrerpolicy', 'no-referrer'); + // 与内联卡片一致:overlay 用 CSS fixed 占满视口,不需要浏览器 Fullscreen API + iframe.setAttribute('allow', "fullscreen 'none'"); + body.appendChild(iframe); + + root.appendChild(bar); + root.appendChild(body); + + refreshBtn.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + const state = showHtmlFullscreenState; + if (!state?.payload) return; + // 与内联卡片刷新同策略:先挂空文档再重挂,强制 iframe 内页面重载 + const keep = state.payload.srcdoc; + state.iframe.srcdoc = EMPTY_SHOW_HTML_SRCDOC; + requestAnimationFrame(() => { + if (showHtmlFullscreenState?.payload) { + showHtmlFullscreenState.iframe.srcdoc = keep; + } + }); + debugLog('fullscreen:refresh', { srcdocLength: keep.length }); + }; + closeBtn.onclick = (event) => { + event.preventDefault(); + event.stopPropagation(); + closeShowHtmlFullscreen(); + }; + + document.body.appendChild(root); + showHtmlFullscreenState = { root, iframe, titleEl: title, noticeEl: notice, payload: null }; + return showHtmlFullscreenState; +} + +export function openShowHtmlFullscreen(payload: ShowHtmlFullscreenPayload) { + if (typeof document === 'undefined') return; + const state = ensureShowHtmlFullscreenDom(); + state.payload = payload; + state.titleEl.textContent = payload.title || '全屏预览'; + const noticeText = (payload.notice || '').trim(); + state.noticeEl.textContent = noticeText; + state.noticeEl.style.display = noticeText ? '' : 'none'; + // js=off 卡片内容已 sanitize,全屏时连脚本执行权也不给,语义与卡片内一致 + state.iframe.setAttribute('sandbox', payload.allowScripts ? 'allow-scripts' : ''); + state.iframe.srcdoc = payload.srcdoc; + state.root.classList.add('is-open'); + document.documentElement.classList.add('show-html-fullscreen-open'); + debugLog('fullscreen:open', { + allowScripts: payload.allowScripts, + srcdocLength: payload.srcdoc.length, + hasNotice: !!noticeText + }); +} + +export function closeShowHtmlFullscreen() { + const state = showHtmlFullscreenState; + if (!state || !isShowHtmlFullscreenOpen()) return; + state.root.classList.remove('is-open'); + document.documentElement.classList.remove('show-html-fullscreen-open'); + state.payload = null; + // 清空 srcdoc:停掉 iframe 内脚本/定时器并释放内存 + state.iframe.srcdoc = EMPTY_SHOW_HTML_SRCDOC; + debugLog('fullscreen:close', {}); +} diff --git a/static/src/utils/showHtmlSandbox.ts b/static/src/utils/showHtmlSandbox.ts new file mode 100644 index 00000000..0d1dea97 --- /dev/null +++ b/static/src/utils/showHtmlSandbox.ts @@ -0,0 +1,211 @@ +/** + * show_html 沙箱与 srcdoc 构建工具(纯函数,无 DOM 依赖)。 + * 从 app/bootstrap.ts 抽取,供 HTML 卡片渲染管线与文件卡片(ShowFileCard)全屏预览共用。 + */ + +/** + * show_html js=on 沙箱 CSP(通过 srcdoc 内 交付,必须位于任何资源引用之前)。 + * - connect-src 'none':禁 fetch/XHR/WebSocket/EventSource,杜绝数据外发与内网探测 + * - script/style/img/font/media 允许 https::保留卡片引用 CDN 资源与外链图的能力 + * (残余风险:GET 类资源加载可作为信标外发少量数据,已与需求方确认接受) + * - frame/child/worker-src 'none':禁嵌套远程框架与 Worker;object/form/base 收紧 + * 注:iframe 的 csp 属性对 srcdoc 的支持仍是未决规范(w3c/webappsec-csp#492),故走 meta 交付。 + */ +export const SHOW_HTML_IFRAME_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline' https:", + "style-src 'unsafe-inline' https:", + "img-src data: blob: https:", + "font-src data: https:", + "media-src data: blob: https:", + "connect-src 'none'", + "form-action 'none'", + "base-uri 'none'", + "frame-src 'none'", + "child-src 'none'", + "worker-src 'none'", + "object-src 'none'" +].join('; '); + +/** + * show_html js=on 沙箱守卫脚本(注入 srcdoc 最前,先于任何卡片脚本执行)。 + * 解决问题:iframe 内 scrollIntoView()/focus() 的滚动副作用会穿透 iframe 边界滚动宿主页面 + * (CSSOM View 规范行为,sandbox 属性与 CSS 均无法声明式禁止,见 w3c/csswg-drafts#7134)。 + * 策略:重写相关 API,使滚动只发生在本 iframe 文档内部;拦截同文档锚点导航的默认滚动; + * 对同源子 frame 递归修补以收窄“干净原型”绕过窗口。 + * 已知边界:不防“针对本守卫的确定性绕过”(威胁模型为提示注入生成的一般恶意内容)。 + */ +export const SHOW_HTML_IFRAME_GUARD_SCRIPT = `(function () { + 'use strict'; + if (window.__astrionSandboxGuard) return; + window.__astrionSandboxGuard = true; + + function scrollingContainerList(el) { + var list = []; + var node = el.parentElement; + while (node && node !== document.body && node !== document.documentElement) { + var s = window.getComputedStyle(node); + if (/(auto|scroll|overlay)/.test(String(s.overflow) + String(s.overflowY) + String(s.overflowX))) { + list.push(node); + } + node = node.parentElement; + } + list.push(document.scrollingElement || document.documentElement); + return list; + } + + function scrollOneContainer(container, el, block) { + var isRoot = container === (document.scrollingElement || document.documentElement); + var eRect = el.getBoundingClientRect(); + var viewH = isRoot ? window.innerHeight : container.clientHeight; + var top = isRoot ? eRect.top : eRect.top - container.getBoundingClientRect().top; + var bottom = top + eRect.height; + var delta = 0; + if (block === 'start') delta = top; + else if (block === 'end') delta = bottom - viewH; + else if (block === 'center') delta = top - (viewH - eRect.height) / 2; + else if (top < 0) delta = top; + else if (bottom > viewH) delta = Math.min(bottom - viewH, top); + if (delta) container.scrollTop += delta; + } + + function localScrollIntoView(el, arg) { + var block = 'start'; + if (arg === false) block = 'end'; + else if (arg && typeof arg === 'object' && typeof arg.block === 'string') block = arg.block; + var containers = scrollingContainerList(el); + for (var i = 0; i < containers.length; i++) { + scrollOneContainer(containers[i], el, block); + } + } + + Element.prototype.scrollIntoView = function (arg) { localScrollIntoView(this, arg); }; + if ('scrollIntoViewIfNeeded' in Element.prototype) { + Element.prototype.scrollIntoViewIfNeeded = function () { localScrollIntoView(this, { block: 'nearest' }); }; + } + + var origFocus = HTMLElement.prototype.focus; + HTMLElement.prototype.focus = function (options) { + var opts = options && typeof options === 'object' + ? Object.assign({}, options, { preventScroll: true }) + : { preventScroll: true }; + origFocus.call(this, opts); + localScrollIntoView(this, { block: 'nearest' }); + }; + + document.addEventListener('click', function (ev) { + var t = ev.target; + var anchor = t && t.closest ? t.closest('a[href^="#"]') : null; + if (!anchor) return; + var id = (anchor.getAttribute('href') || '').slice(1); + ev.preventDefault(); + if (!id) return; + var target = document.getElementById(id); + if (target) { + // 先摘掉 id 再更新 hash,避免浏览器默认锚点滚动穿透到宿主页面 + target.removeAttribute('id'); + try { window.location.hash = id; } catch (err) { /* opaque origin 下忽略 */ } + target.setAttribute('id', id); + localScrollIntoView(target, { block: 'start' }); + } else { + try { window.location.hash = id; } catch (err) { /* ignore */ } + } + }, true); + + function patchFrame(win) { + try { + win.Element.prototype.scrollIntoView = Element.prototype.scrollIntoView; + win.HTMLElement.prototype.focus = HTMLElement.prototype.focus; + } catch (err) { /* 跨源子 frame 跳过 */ } + } + new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var added = mutations[i].addedNodes; + for (var j = 0; j < added.length; j++) { + var n = added[j]; + if (!n || n.nodeType !== 1) continue; + var frames = []; + if (n.tagName === 'IFRAME' || n.tagName === 'FRAME') frames.push(n); + if (n.querySelectorAll) { + var found = n.querySelectorAll('iframe,frame'); + for (var k = 0; k < found.length; k++) frames.push(found[k]); + } + for (var m = 0; m < frames.length; m++) { + if (frames[m].contentWindow) patchFrame(frames[m].contentWindow); + } + } + } + }).observe(document.documentElement, { childList: true, subtree: true }); +})();`; + +/** 生成注入内容:CSP meta 必须早于任何资源引用,守卫脚本必须早于任何卡片脚本 */ +export function buildShowHtmlSandboxInjection(): string { + return ``; +} + +/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 最前(无 head 则补一个) */ +export function injectShowHtmlSandboxGuards(doc: string): string { + const injection = buildShowHtmlSandboxInjection(); + const headMatch = /]*>/i.exec(doc); + if (headMatch) { + const idx = headMatch.index + headMatch[0].length; + return doc.slice(0, idx) + injection + doc.slice(idx); + } + const htmlMatch = /]*>/i.exec(doc); + if (htmlMatch) { + const idx = htmlMatch.index + htmlMatch[0].length; + return doc.slice(0, idx) + `${injection}` + doc.slice(idx); + } + return injection + doc; +} + +/** 空 srcdoc:刷新/关闭全屏时挂载,强制 iframe 内页面重载并释放脚本与内存 */ +export const EMPTY_SHOW_HTML_SRCDOC = + ''; + +export function buildShowHtmlIframeSrcdoc(rawHtml: string): string { + const content = (rawHtml || '').trim(); + if (!content) { + return EMPTY_SHOW_HTML_SRCDOC; + } + + // 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。 + if (/]/i.test(content)) { + return injectShowHtmlSandboxGuards(content); + } + + return ` + + + + + ${buildShowHtmlSandboxInjection()} + + + ${content} +`; +}