From 328607671a3a757529ba5615aa50d326b7764f40 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 3 Aug 2026 11:16:40 +0800 Subject: [PATCH] feat(web): preview HTML and source files --- tests/frontend_preview.test.mjs | 40 +++++++++++ web/static/dev.html | 20 ++++++ web/static/js/preview.js | 111 ++++++++++++++++++++++--------- web/static/js/preview_content.js | 13 ++++ 4 files changed, 152 insertions(+), 32 deletions(-) create mode 100644 tests/frontend_preview.test.mjs create mode 100644 web/static/js/preview_content.js diff --git a/tests/frontend_preview.test.mjs b/tests/frontend_preview.test.mjs new file mode 100644 index 0000000..f3de931 --- /dev/null +++ b/tests/frontend_preview.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { htmlPreviewDocument } from "../web/static/js/preview_content.js"; + +// preview.js has browser-only top-level bindings, so exercise the integration contract +// through its shipped source and markup while keeping this test dependency-free. +const previewJs = readFileSync(new URL("../web/static/js/preview.js", import.meta.url), "utf8"); +const pageHtml = readFileSync(new URL("../web/static/dev.html", import.meta.url), "utf8"); + +test("HTML is a renderable category rather than plain text", () => { + assert.match(previewJs, /html:\s+new Set\(\["html","htm"\]\)/); + assert.match(previewJs, /frame\.setAttribute\("sandbox", ""\)/); + assert.match(previewJs, /frame\.srcdoc = htmlPreviewDocument\(text\)/); +}); + +test("HTML preview blocks scripts, navigation, and external resources", () => { + const document = htmlPreviewDocument("xx"); + assert.match(document, /")); + assert.doesNotMatch(previewJs, /allow-scripts/); +}); + +test("HTML fragments receive a restrictive head before their content", () => { + const document = htmlPreviewDocument("

报告

"); + assert.match(document, /^

报告<\/h1>$/); +}); + +test("main and mini previews expose preview/source mode controls", () => { + for (const prefix of ["fp", "mp"]) { + assert.match(pageHtml, new RegExp(`id="${prefix}-mode-preview"`)); + assert.match(pageHtml, new RegExp(`id="${prefix}-mode-source"`)); + assert.match(previewJs, new RegExp(`_showRenderableText\\("${prefix}", cat, text\\)`)); + } +}); diff --git a/web/static/dev.html b/web/static/dev.html index 866f04c..820902c 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -1251,7 +1251,18 @@ flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .preview-modes { + display: inline-flex; align-items: center; gap: 2px; padding: 2px; + border: 1px solid var(--border); border-radius: var(--r-md); background: var(--panel-muted); + } + .preview-modes[hidden] { display: none; } + .preview-modes button { border: 0; background: transparent; } + .preview-modes button.active { + color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent); + } #file-preview-modal .body { flex: 1; overflow: auto; padding: 12px; position: relative; overscroll-behavior: contain; } + #file-preview-modal .body.html-preview, + #mini-preview-modal .body.html-preview { padding: 0; overflow: hidden; background: #fff; } /* 预览缩放比例徽标(挂在 .card 上,放大滚动时不跟随内容滚走) */ .zoom-badge { position: absolute; right: 14px; bottom: 12px; z-index: 3; @@ -1276,6 +1287,7 @@ max-width: 100%; max-height: 100%; display: block; margin: 0 auto; outline: none; } #file-preview-modal .body iframe.preview-frame { width: 100%; height: 100%; border: 0; } + iframe.preview-html-frame { background: #fff; } #file-preview-modal .body pre.preview-text { margin: 0; padding: 8px; background: var(--code-bg); border-radius: var(--r-md); white-space: pre-wrap; word-break: break-word; @@ -1971,6 +1983,10 @@
+
@@ -1984,6 +2000,10 @@
+
diff --git a/web/static/js/preview.js b/web/static/js/preview.js index 585c789..eb6106e 100644 --- a/web/static/js/preview.js +++ b/web/static/js/preview.js @@ -1,4 +1,4 @@ -// 文件预览:主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载)+ +// 文件预览:主弹框(图/视频/PDF/文本/markdown/html/docx/xlsx,大文件降级下载)+ // 同时再开一个的小窗预览(mini)。docx/xlsx 走 loadScript 懒加载 vendor。 // 导出 open*/close* 供 files / 媒体 chip / 粘贴文件 / main 的 Esc 关栈调用; // _categorize 也供 media 段判图/视频。反向依赖 downloadFile(media)、logout(auth)。 @@ -6,6 +6,7 @@ import { state } from "./state.js"; import { $ } from "./dom.js"; import { humanSize, escapeHtml } from "./format.js"; import { renderMd, highlightIn } from "./markdown.js"; +import { HTML_PREVIEW_CSP, htmlPreviewDocument } from "./preview_content.js"; import { logout } from "./auth.js"; import { downloadFile } from "./media.js"; @@ -44,10 +45,11 @@ const _EXT_GROUPS = { video: new Set(["mp4","webm","mov","mkv","m4v"]), pdf: new Set(["pdf"]), md: new Set(["md","markdown"]), + html: new Set(["html","htm"]), text: new Set([ "txt","log","json","jsonl","yaml","yml","toml","ini","csv","tsv", "py","js","mjs","ts","jsx","tsx","go","rs","java","c","cc","cpp","h","hpp", - "html","htm","xml","css","scss","sh","bash","zsh","sql","conf","env", + "xml","css","scss","sh","bash","zsh","sql","conf","env", ]), docx: new Set(["docx"]), xlsx: new Set(["xlsx","xls"]), @@ -62,6 +64,67 @@ export function _categorize(rel) { let _fpCurrentRel = null; +// Markdown / HTML 共用“预览 / 源文件”切换。HTML 放入无权限 sandbox iframe, +// 同时注入 CSP,阻止脚本和外部资源请求;保留内联样式与 data/blob 图片以便静态报告正常展示。 +const _textPreviewState = { fp: null, mp: null }; + +function _resetTextModes(prefix) { + _textPreviewState[prefix] = null; + const modes = $(`${prefix}-modes`); + if (modes) modes.hidden = true; +} + +function _showSource(body, text) { + body.className = "body"; + body.innerHTML = ""; + const pre = document.createElement("pre"); + pre.className = "preview-text"; + pre.textContent = text; + body.appendChild(pre); +} + +function _showHtml(body, text) { + body.className = "body html-preview"; + body.innerHTML = ""; + const frame = document.createElement("iframe"); + frame.className = "preview-frame preview-html-frame"; + frame.title = "HTML 文件预览"; + frame.setAttribute("sandbox", ""); + frame.setAttribute("csp", HTML_PREVIEW_CSP); + frame.referrerPolicy = "no-referrer"; + frame.srcdoc = htmlPreviewDocument(text); + body.appendChild(frame); +} + +function _renderTextMode(prefix, mode) { + const state = _textPreviewState[prefix]; + if (!state) return; + state.mode = mode; + const body = $(`${prefix}-body`); + const previewBtn = $(`${prefix}-mode-preview`); + const sourceBtn = $(`${prefix}-mode-source`); + const showingPreview = mode === "preview"; + previewBtn.classList.toggle("active", showingPreview); + sourceBtn.classList.toggle("active", !showingPreview); + previewBtn.setAttribute("aria-pressed", String(showingPreview)); + sourceBtn.setAttribute("aria-pressed", String(!showingPreview)); + if (!showingPreview) { + _showSource(body, state.text); + } else if (state.cat === "md") { + body.className = "body"; + body.innerHTML = `
${renderMd(state.text)}
`; + highlightIn(body); + } else { + _showHtml(body, state.text); + } +} + +function _showRenderableText(prefix, cat, text) { + _textPreviewState[prefix] = { cat, text, mode: "preview" }; + $(`${prefix}-modes`).hidden = false; + _renderTextMode(prefix, "preview"); +} + // ───── 滚动不穿透 + 图片 Ctrl+滚轮缩放 ───── // body 元素在多次预览间复用,故 wheel 监听只在 init 时挂一次(_bindBodyWheel), // 缩放目标用 _zoomState 记录,避免每次预览重复 addEventListener 泄漏。 @@ -181,6 +244,7 @@ export async function openFilePreview(rel) { $("fp-name").textContent = name; $("fp-meta").textContent = ""; const body = $("fp-body"); + _resetTextModes("fp"); _clearZoom(body); body.className = "body center"; body.innerHTML = `
加载中…
`; @@ -203,13 +267,13 @@ export async function openFilePreview(rel) { const blob = await r.blob(); $("fp-meta").textContent = humanSize(blob.size); - if (cat === "text" || cat === "md") { + if (cat === "text" || cat === "md" || cat === "html") { if (blob.size > PREVIEW_TEXT_MAX) { _showFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } const text = await blob.text(); - if (cat === "md") _showMarkdown(text); + if (cat === "md" || cat === "html") _showRenderableText("fp", cat, text); else _showText(text); return; } @@ -293,22 +357,7 @@ async function _showPptAsPdf(rel, body, metaEl, fallbackFn, trackFn = _trackBlob body.innerHTML = ``; } -function _showText(text) { - const body = $("fp-body"); - body.className = "body"; - body.innerHTML = ""; - const pre = document.createElement("pre"); - pre.className = "preview-text"; - pre.textContent = text; - body.appendChild(pre); -} - -function _showMarkdown(text) { - const body = $("fp-body"); - body.className = "body"; - body.innerHTML = `
${renderMd(text)}
`; - highlightIn(body); -} +function _showText(text) { _showSource($("fp-body"), text); } async function _showDocx(blob) { const body = $("fp-body"); @@ -404,6 +453,7 @@ export function closeFilePreview() { document.body.classList.remove("fp-open"); $("file-preview-modal").style.removeProperty("--preview-bottom-inset"); _clearZoom($("fp-body")); + _resetTextModes("fp"); $("fp-body").innerHTML = ""; _flushBlobUrls(); _fpCurrentRel = null; @@ -431,6 +481,7 @@ async function openMiniFilePreview(rel) { $("mp-name").textContent = name; $("mp-meta").textContent = ""; const body = $("mp-body"); + _resetTextModes("mp"); _clearZoom(body); body.className = "body center"; body.innerHTML = `
加载中…
`; @@ -449,23 +500,14 @@ async function openMiniFilePreview(rel) { if (!r.ok) throw new Error("HTTP " + r.status); const blob = await r.blob(); $("mp-meta").textContent = humanSize(blob.size); - if (cat === "text" || cat === "md") { + if (cat === "text" || cat === "md" || cat === "html") { if (blob.size > PREVIEW_TEXT_MAX) { _showMiniFallback(`文件过大 (${humanSize(blob.size)}),请下载查看`); return; } const text = await blob.text(); - body.className = "body"; - if (cat === "md") { - body.innerHTML = `
${renderMd(text)}
`; - highlightIn(body); - } else { - body.innerHTML = ""; - const pre = document.createElement("pre"); - pre.className = "preview-text"; - pre.textContent = text; - body.appendChild(pre); - } + if (cat === "md" || cat === "html") _showRenderableText("mp", cat, text); + else _showSource(body, text); return; } if (blob.size > PREVIEW_BIN_MAX) { @@ -518,6 +560,7 @@ function _showMiniFallback(msg) { export function closeMiniPreview() { $("mini-preview-modal").classList.remove("show"); _clearZoom($("mp-body")); + _resetTextModes("mp"); $("mp-body").innerHTML = ""; _flushMiniBlobUrls(); _mpCurrentRel = null; @@ -534,11 +577,15 @@ _bindBodyWheel($("mp-body")); $("fp-close").onclick = closeFilePreview; $("fp-download").onclick = () => { if (_fpCurrentRel) downloadFile(_fpCurrentRel); }; +$("fp-mode-preview").onclick = () => _renderTextMode("fp", "preview"); +$("fp-mode-source").onclick = () => _renderTextMode("fp", "source"); $("file-preview-modal").addEventListener("click", (e) => { if (e.target.id === "file-preview-modal") closeFilePreview(); }); $("mp-close").onclick = closeMiniPreview; $("mp-download").onclick = () => { if (_mpCurrentRel) downloadFile(_mpCurrentRel); }; +$("mp-mode-preview").onclick = () => _renderTextMode("mp", "preview"); +$("mp-mode-source").onclick = () => _renderTextMode("mp", "source"); $("mini-preview-modal").addEventListener("click", (e) => { if (e.target.id === "mini-preview-modal") closeMiniPreview(); }); diff --git a/web/static/js/preview_content.js b/web/static/js/preview_content.js new file mode 100644 index 0000000..8aecc61 --- /dev/null +++ b/web/static/js/preview_content.js @@ -0,0 +1,13 @@ +// 可独立测试的预览内容安全策略。HTML 预览只允许文件内的静态样式和内嵌媒体, +// 不允许脚本、表单提交、外部资源请求或 base URL 改写。 +export const HTML_PREVIEW_CSP = "default-src 'none'; img-src data: blob:; media-src data: blob:; " + + "font-src data:; style-src 'unsafe-inline'; form-action 'none'; base-uri 'none'"; + +export function htmlPreviewDocument(text) { + const meta = ``; + const source = String(text || ""); + if (/]*)?>/i.test(source)) { + return source.replace(/]*)?>/i, (head) => head + meta); + } + return `${meta}${source}`; +}