/** * 附件预览地址解析 —— 解决「HTML 无法预览」。 * * ## 根因(2026-10-27 实测) * * Matrix 媒体接口给所有附件都加了强制的下载头: * * ``` * Content-Type: text/html * Content-Disposition: attachment; filename=todo.html ← 浏览器强制下载,不渲染 * ``` * * 于是: * · iframe 里显示空白(浏览器拒绝渲染带 attachment 的响应) * · 直接打开变成下载 * * ## 解决 * * 先 `fetch` 拿到内容,再用 `URL.createObjectURL` 建一个本地 `blob:` 地址, * iframe 加载 blob 不受 `Content-Disposition` 影响。 * * 限制:blob 地址只在**当前页面生命周期**内有效,关闭弹层要 `revokeObjectURL` 释放。 */ /** 这类附件需要转 blob 才能内联预览 */ const NEEDS_BLOB = new Set(['html', 'htm', 'svg', 'xml']); /** 判断某个文件是否需要 blob 转换(依据扩展名) */ export function needsBlobPreview(nameOrUrl: string): boolean { const ext = extOf(nameOrUrl); return NEEDS_BLOB.has(ext); } export function extOf(nameOrUrl: string): string { const clean = (nameOrUrl || '').split(/[?#]/)[0]; const base = clean.split('/').pop() ?? ''; const dot = base.lastIndexOf('.'); return dot >= 0 ? base.slice(dot + 1).toLowerCase() : ''; } export interface BlobResult { /** 可用于 iframe src 的地址 */ url: string; /** 需要调用方在关闭时释放 */ revoke: () => void; /** * true = 因 CORS 不可用而**直接用的原地址**(不是 blob)。 * * 调用方据此调整提示:这种情况安全加固(CSP 注入)**没有生效**, * 但不该显示「加载失败」—— 文件其实是能看的。 */ direct?: boolean; } /** * 把远程附件抓成 blob: 地址。 * * ## 为什么要能退回原地址 * * 带 CSP 加固的预览必须读文本 → 必须 `fetch` → **跨域需要对方配 CORS**。 * 实测 `s3.fmode.cn` **没有配 CORS 头**,所以云盘上的成果一律 fetch 失败 * (浏览器报 `blocked by CORS policy`,用户看到「文件加载失败」)。 * * 但这些对象是 `public-read`,**直接作为 iframe src 能正常显示** —— * iframe 不受 CORS 限制(只是偶尔无法读取内部内容)。 * * 所以:CORS 可用 → 走 blob(可注入 CSP,更安全); * CORS 不可用 → **退回原 URL**(能看,但这一个 iframe 无法注入 CSP)。 * * @param url 原始地址(Matrix 媒体地址 / 云盘地址) * @param mime 内容类型(用于构造 Blob,缺失时按 text/html 处理) * @throws 仅在**非 CORS 类**的失败(如 404)时抛出,调用方展示错误而不是空白 */ export async function toBlobUrl(url: string, mime = 'text/html'): Promise { let res: Response; try { res = await fetch(url); } catch { /* * fetch 抛错基本只有两种:网络断了 / CORS 被拦。 * 后者对 public-read 的对象是**假失败** —— 内容其实拿得到, * 所以退回原地址,让用户至少能看到文件。 */ return { url, revoke: () => undefined, direct: true }; } if (!res.ok) { throw new Error(`加载失败(HTTP ${res.status})`); } const text = await res.text(); // HTML 预览需要注入 CSP(见 injectHtmlSandboxGuard),其它类型原样 const body = mime.includes('html') ? injectHtmlSandboxGuard(text) : text; const blob = new Blob([body], { type: mime }); const objectUrl = URL.createObjectURL(blob); return { url: objectUrl, revoke: () => URL.revokeObjectURL(objectUrl), }; } /** * 给要放进 iframe 的 HTML 注入沙箱护栏。 * * ## 为什么需要(2026-11-04 实测) * * HTML 预览原本用 `sandbox="allow-scripts allow-popups allow-forms"`(**没有** * `allow-same-origin`)。这种组合下文档是 opaque origin,页面里任何 * `document.cookie` 读写都会抛: * * ``` * SecurityError: Failed to set the 'cookie' property on 'Document': * The document is sandboxed and lacks the 'allow-same-origin' flag. * ``` * * 用户交付的 HTML 里普遍带埋点/主题记忆脚本,于是控制台刷满报错; * 而且没有 same-origin 时连 `contentDocument` 都读不到,**缩放也失效**。 * * ## 为什么不直接加 allow-same-origin * * `allow-scripts` + `allow-same-origin` 同时存在时,页面脚本可以 * 通过 `parent`/`top` 访问宿主、甚至移除自己的 sandbox —— 等于没沙箱。 * * ## 做法 * * 保留沙箱、加回 `allow-same-origin`(换来可缩放),**同时注入 CSP** * 禁止脚本访问本应用的存储与 cookie。CSP 的 `sandbox` 指令不带 * `allow-same-origin` 时,文档即使从 blob 加载也是 opaque origin → * cookie/存储访问被浏览器直接拦下(不再是未捕获异常)。 * * 简单说:让「读 cookie」变成被策略拒绝的空操作,而不是抛异常。 */ export function injectHtmlSandboxGuard(html: string): string { /* * CSP: * · sandbox allow-scripts —— 允许页面自己的脚本跑(很多交付物靠它渲染) * · 不含 allow-same-origin —— 存储/cookie 一律不可用 * · img/style/font 允许任意来源 —— 交付物常引用外链图片与字体 */ /* * 🔴 不要再加 `sandbox` 指令:CSP 的 sandbox 通过 传递时 * 浏览器会直接忽略(实测控制台报 * "The Content Security Policy directive 'sandbox' is ignored when * delivered via a element.")。 * * 真正的存储隔离由 iframe 的 sandbox 属性负责(见 file-preview 模板); * 这里只保留资源加载策略,防止交付物里的脚本把整个页面拖垮。 */ const csp = [ "default-src 'none'", "img-src * data: blob:", "media-src * data: blob:", "style-src * 'unsafe-inline'", "font-src * data:", "script-src 'unsafe-inline' 'unsafe-eval' *", "connect-src *", ].join('; '); const meta = ``; // 已有 就插进去,否则补一个 if (/]*>/i.test(html)) { return html.replace(/]*>/i, (m) => `${m}${meta}`); } // 没有 head:直接在 后插入,或整体包一层 if (/]*>/i.test(html)) { return html.replace(/]*>/i, (m) => `${m}${meta}`); } return `${meta}${html}`; } /** * 触发浏览器下载。 * * ## 为什么不能直接用 `` * * 跨域地址上的 `download` 属性会被浏览器**忽略**(安全限制), * 点击只会在新标签打开。要可靠下载必须用 blob。 */ export async function downloadAs(url: string, filename: string): Promise { let blob: Blob; try { const res = await fetch(url); if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`); blob = await res.blob(); } catch { /* * 🔴 跨域被 CORS 拦时,fetch 必失败(实测 s3.fmode.cn 未配 CORS 头)。 * 但文件本身是 public-read,**直接打开链接能下载**。 * 所以降级为「新标签打开」,而不是让用户看到 ERR_FAILED。 */ openInNewTab(url); return; } const objectUrl = URL.createObjectURL(blob); try { const a = document.createElement('a'); a.href = objectUrl; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); } finally { // 立即释放可能让部分浏览器来不及开始下载,延后一拍 setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); } } /** 新标签打开(CORS 不可用时的降级路径,也可直接预览) */ export function openInNewTab(url: string): void { const a = document.createElement('a'); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); a.remove(); }