feat(chat): 文件卡片新增 HTML 全屏预览
- 抽取 showHtmlSandbox/showHtmlFullscreen 共享模块(CSP/守卫/srcdoc/overlay) - ShowFileCard 对 .html/.htm 增加预览按钮,复用卡片全屏 overlay - 全屏 payload 支持 title/notice,检测到外部资源引用时顶栏提示
This commit is contained in:
parent
ea232d6ac6
commit
d2e4422c80
@ -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 内 <meta> 交付,必须位于任何资源引用之前)。
|
||||
* - 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 `<meta http-equiv="Content-Security-Policy" content="${SHOW_HTML_IFRAME_CSP}" /><script>${SHOW_HTML_IFRAME_GUARD_SCRIPT}</script>`;
|
||||
}
|
||||
|
||||
/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 <head> 最前(无 head 则补一个) */
|
||||
function injectShowHtmlSandboxGuards(doc: string) {
|
||||
const injection = buildShowHtmlSandboxInjection();
|
||||
const headMatch = /<head\b[^>]*>/i.exec(doc);
|
||||
if (headMatch) {
|
||||
const idx = headMatch.index + headMatch[0].length;
|
||||
return doc.slice(0, idx) + injection + doc.slice(idx);
|
||||
}
|
||||
const htmlMatch = /<html\b[^>]*>/i.exec(doc);
|
||||
if (htmlMatch) {
|
||||
const idx = htmlMatch.index + htmlMatch[0].length;
|
||||
return doc.slice(0, idx) + `<head>${injection}</head>` + doc.slice(idx);
|
||||
}
|
||||
return injection + doc;
|
||||
}
|
||||
|
||||
function buildShowHtmlIframeSrcdoc(rawHtml: string) {
|
||||
const content = (rawHtml || '').trim();
|
||||
if (!content) {
|
||||
return '<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
||||
}
|
||||
|
||||
// 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。
|
||||
if (/<html[\s>]/i.test(content)) {
|
||||
return injectShowHtmlSandboxGuards(content);
|
||||
}
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
${buildShowHtmlSandboxInjection()}
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
|
||||
}
|
||||
html::-webkit-scrollbar, body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
html::-webkit-scrollbar-track, body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb {
|
||||
background: rgba(121, 109, 94, 0.48);
|
||||
border-radius: 8px;
|
||||
}
|
||||
html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(121, 109, 94, 0.62);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${content}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
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 =
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
||||
|
||||
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 上,按钮与菜单只创建一次。
|
||||
|
||||
@ -4,6 +4,15 @@
|
||||
<span class="sfc-file-icon" aria-hidden="true"></span>
|
||||
<span class="sfc-name" :title="displayName">{{ displayName }}</span>
|
||||
<div class="sfc-actions">
|
||||
<button
|
||||
v-if="isHtmlFile"
|
||||
type="button"
|
||||
class="sfc-btn"
|
||||
:disabled="previewLoading"
|
||||
@click="openHtmlPreview"
|
||||
>
|
||||
{{ previewLoading ? '加载中…' : '预览' }}
|
||||
</button>
|
||||
<button v-if="canCopy" type="button" class="sfc-btn" @click="copyContent">
|
||||
{{ copied ? '已复制' : '复制' }}
|
||||
</button>
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
185
static/src/utils/showHtmlFullscreen.ts
Normal file
185
static/src/utils/showHtmlFullscreen.ts
Normal file
@ -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<string, unknown> = {}) {
|
||||
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', {});
|
||||
}
|
||||
211
static/src/utils/showHtmlSandbox.ts
Normal file
211
static/src/utils/showHtmlSandbox.ts
Normal file
@ -0,0 +1,211 @@
|
||||
/**
|
||||
* show_html 沙箱与 srcdoc 构建工具(纯函数,无 DOM 依赖)。
|
||||
* 从 app/bootstrap.ts 抽取,供 HTML 卡片渲染管线与文件卡片(ShowFileCard)全屏预览共用。
|
||||
*/
|
||||
|
||||
/**
|
||||
* show_html js=on 沙箱 CSP(通过 srcdoc 内 <meta> 交付,必须位于任何资源引用之前)。
|
||||
* - 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 `<meta http-equiv="Content-Security-Policy" content="${SHOW_HTML_IFRAME_CSP}" /><script>${SHOW_HTML_IFRAME_GUARD_SCRIPT}</script>`;
|
||||
}
|
||||
|
||||
/** 模型自带完整 HTML 文档时,强制把沙箱注入插到 <head> 最前(无 head 则补一个) */
|
||||
export function injectShowHtmlSandboxGuards(doc: string): string {
|
||||
const injection = buildShowHtmlSandboxInjection();
|
||||
const headMatch = /<head\b[^>]*>/i.exec(doc);
|
||||
if (headMatch) {
|
||||
const idx = headMatch.index + headMatch[0].length;
|
||||
return doc.slice(0, idx) + injection + doc.slice(idx);
|
||||
}
|
||||
const htmlMatch = /<html\b[^>]*>/i.exec(doc);
|
||||
if (htmlMatch) {
|
||||
const idx = htmlMatch.index + htmlMatch[0].length;
|
||||
return doc.slice(0, idx) + `<head>${injection}</head>` + doc.slice(idx);
|
||||
}
|
||||
return injection + doc;
|
||||
}
|
||||
|
||||
/** 空 srcdoc:刷新/关闭全屏时挂载,强制 iframe 内页面重载并释放脚本与内存 */
|
||||
export const EMPTY_SHOW_HTML_SRCDOC =
|
||||
'<!doctype html><html><head><meta charset="utf-8"></head><body></body></html>';
|
||||
|
||||
export function buildShowHtmlIframeSrcdoc(rawHtml: string): string {
|
||||
const content = (rawHtml || '').trim();
|
||||
if (!content) {
|
||||
return EMPTY_SHOW_HTML_SRCDOC;
|
||||
}
|
||||
|
||||
// 若模型已经输出完整 HTML 文档,强制注入沙箱 CSP 与守卫脚本后使用。
|
||||
if (/<html[\s>]/i.test(content)) {
|
||||
return injectShowHtmlSandboxGuards(content);
|
||||
}
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
${buildShowHtmlSandboxInjection()}
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
background: transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(121, 109, 94, 0.48) transparent;
|
||||
}
|
||||
html::-webkit-scrollbar, body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
html::-webkit-scrollbar-track, body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-thumb {
|
||||
background: rgba(121, 109, 94, 0.48);
|
||||
border-radius: 8px;
|
||||
}
|
||||
html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(121, 109, 94, 0.62);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${content}</body>
|
||||
</html>`;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user