| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- /**
- * 附件预览地址解析 —— 解决「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<BlobResult> {
- 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 通过 <meta> 传递时
- * 浏览器会直接忽略(实测控制台报
- * "The Content Security Policy directive 'sandbox' is ignored when
- * delivered via a <meta> 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 = `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
- // 已有 <head> 就插进去,否则补一个
- if (/<head[^>]*>/i.test(html)) {
- return html.replace(/<head[^>]*>/i, (m) => `${m}${meta}`);
- }
- // 没有 head:直接在 <html> 后插入,或整体包一层
- if (/<html[^>]*>/i.test(html)) {
- return html.replace(/<html[^>]*>/i, (m) => `${m}<head>${meta}</head>`);
- }
- return `<!DOCTYPE html><html><head>${meta}</head><body>${html}</body></html>`;
- }
- /**
- * 触发浏览器下载。
- *
- * ## 为什么不能直接用 `<a download href=...>`
- *
- * 跨域地址上的 `download` 属性会被浏览器**忽略**(安全限制),
- * 点击只会在新标签打开。要可靠下载必须用 blob。
- */
- export async function downloadAs(url: string, filename: string): Promise<void> {
- 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();
- }
|