attachment-fetch.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. /**
  2. * 附件预览地址解析 —— 解决「HTML 无法预览」。
  3. *
  4. * ## 根因(2026-10-27 实测)
  5. *
  6. * Matrix 媒体接口给所有附件都加了强制的下载头:
  7. *
  8. * ```
  9. * Content-Type: text/html
  10. * Content-Disposition: attachment; filename=todo.html ← 浏览器强制下载,不渲染
  11. * ```
  12. *
  13. * 于是:
  14. * · iframe 里显示空白(浏览器拒绝渲染带 attachment 的响应)
  15. * · 直接打开变成下载
  16. *
  17. * ## 解决
  18. *
  19. * 先 `fetch` 拿到内容,再用 `URL.createObjectURL` 建一个本地 `blob:` 地址,
  20. * iframe 加载 blob 不受 `Content-Disposition` 影响。
  21. *
  22. * 限制:blob 地址只在**当前页面生命周期**内有效,关闭弹层要 `revokeObjectURL` 释放。
  23. */
  24. /** 这类附件需要转 blob 才能内联预览 */
  25. const NEEDS_BLOB = new Set(['html', 'htm', 'svg', 'xml']);
  26. /** 判断某个文件是否需要 blob 转换(依据扩展名) */
  27. export function needsBlobPreview(nameOrUrl: string): boolean {
  28. const ext = extOf(nameOrUrl);
  29. return NEEDS_BLOB.has(ext);
  30. }
  31. export function extOf(nameOrUrl: string): string {
  32. const clean = (nameOrUrl || '').split(/[?#]/)[0];
  33. const base = clean.split('/').pop() ?? '';
  34. const dot = base.lastIndexOf('.');
  35. return dot >= 0 ? base.slice(dot + 1).toLowerCase() : '';
  36. }
  37. export interface BlobResult {
  38. /** 可用于 iframe src 的地址 */
  39. url: string;
  40. /** 需要调用方在关闭时释放 */
  41. revoke: () => void;
  42. /**
  43. * true = 因 CORS 不可用而**直接用的原地址**(不是 blob)。
  44. *
  45. * 调用方据此调整提示:这种情况安全加固(CSP 注入)**没有生效**,
  46. * 但不该显示「加载失败」—— 文件其实是能看的。
  47. */
  48. direct?: boolean;
  49. }
  50. /**
  51. * 把远程附件抓成 blob: 地址。
  52. *
  53. * ## 为什么要能退回原地址
  54. *
  55. * 带 CSP 加固的预览必须读文本 → 必须 `fetch` → **跨域需要对方配 CORS**。
  56. * 实测 `s3.fmode.cn` **没有配 CORS 头**,所以云盘上的成果一律 fetch 失败
  57. * (浏览器报 `blocked by CORS policy`,用户看到「文件加载失败」)。
  58. *
  59. * 但这些对象是 `public-read`,**直接作为 iframe src 能正常显示** ——
  60. * iframe 不受 CORS 限制(只是偶尔无法读取内部内容)。
  61. *
  62. * 所以:CORS 可用 → 走 blob(可注入 CSP,更安全);
  63. * CORS 不可用 → **退回原 URL**(能看,但这一个 iframe 无法注入 CSP)。
  64. *
  65. * @param url 原始地址(Matrix 媒体地址 / 云盘地址)
  66. * @param mime 内容类型(用于构造 Blob,缺失时按 text/html 处理)
  67. * @throws 仅在**非 CORS 类**的失败(如 404)时抛出,调用方展示错误而不是空白
  68. */
  69. export async function toBlobUrl(url: string, mime = 'text/html'): Promise<BlobResult> {
  70. let res: Response;
  71. try {
  72. res = await fetch(url);
  73. } catch {
  74. /*
  75. * fetch 抛错基本只有两种:网络断了 / CORS 被拦。
  76. * 后者对 public-read 的对象是**假失败** —— 内容其实拿得到,
  77. * 所以退回原地址,让用户至少能看到文件。
  78. */
  79. return { url, revoke: () => undefined, direct: true };
  80. }
  81. if (!res.ok) {
  82. throw new Error(`加载失败(HTTP ${res.status})`);
  83. }
  84. const text = await res.text();
  85. // HTML 预览需要注入 CSP(见 injectHtmlSandboxGuard),其它类型原样
  86. const body = mime.includes('html') ? injectHtmlSandboxGuard(text) : text;
  87. const blob = new Blob([body], { type: mime });
  88. const objectUrl = URL.createObjectURL(blob);
  89. return {
  90. url: objectUrl,
  91. revoke: () => URL.revokeObjectURL(objectUrl),
  92. };
  93. }
  94. /**
  95. * 给要放进 iframe 的 HTML 注入沙箱护栏。
  96. *
  97. * ## 为什么需要(2026-11-04 实测)
  98. *
  99. * HTML 预览原本用 `sandbox="allow-scripts allow-popups allow-forms"`(**没有**
  100. * `allow-same-origin`)。这种组合下文档是 opaque origin,页面里任何
  101. * `document.cookie` 读写都会抛:
  102. *
  103. * ```
  104. * SecurityError: Failed to set the 'cookie' property on 'Document':
  105. * The document is sandboxed and lacks the 'allow-same-origin' flag.
  106. * ```
  107. *
  108. * 用户交付的 HTML 里普遍带埋点/主题记忆脚本,于是控制台刷满报错;
  109. * 而且没有 same-origin 时连 `contentDocument` 都读不到,**缩放也失效**。
  110. *
  111. * ## 为什么不直接加 allow-same-origin
  112. *
  113. * `allow-scripts` + `allow-same-origin` 同时存在时,页面脚本可以
  114. * 通过 `parent`/`top` 访问宿主、甚至移除自己的 sandbox —— 等于没沙箱。
  115. *
  116. * ## 做法
  117. *
  118. * 保留沙箱、加回 `allow-same-origin`(换来可缩放),**同时注入 CSP**
  119. * 禁止脚本访问本应用的存储与 cookie。CSP 的 `sandbox` 指令不带
  120. * `allow-same-origin` 时,文档即使从 blob 加载也是 opaque origin →
  121. * cookie/存储访问被浏览器直接拦下(不再是未捕获异常)。
  122. *
  123. * 简单说:让「读 cookie」变成被策略拒绝的空操作,而不是抛异常。
  124. */
  125. export function injectHtmlSandboxGuard(html: string): string {
  126. /*
  127. * CSP:
  128. * · sandbox allow-scripts —— 允许页面自己的脚本跑(很多交付物靠它渲染)
  129. * · 不含 allow-same-origin —— 存储/cookie 一律不可用
  130. * · img/style/font 允许任意来源 —— 交付物常引用外链图片与字体
  131. */
  132. /*
  133. * 🔴 不要再加 `sandbox` 指令:CSP 的 sandbox 通过 <meta> 传递时
  134. * 浏览器会直接忽略(实测控制台报
  135. * "The Content Security Policy directive 'sandbox' is ignored when
  136. * delivered via a <meta> element.")。
  137. *
  138. * 真正的存储隔离由 iframe 的 sandbox 属性负责(见 file-preview 模板);
  139. * 这里只保留资源加载策略,防止交付物里的脚本把整个页面拖垮。
  140. */
  141. const csp = [
  142. "default-src 'none'",
  143. "img-src * data: blob:",
  144. "media-src * data: blob:",
  145. "style-src * 'unsafe-inline'",
  146. "font-src * data:",
  147. "script-src 'unsafe-inline' 'unsafe-eval' *",
  148. "connect-src *",
  149. ].join('; ');
  150. const meta = `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
  151. // 已有 <head> 就插进去,否则补一个
  152. if (/<head[^>]*>/i.test(html)) {
  153. return html.replace(/<head[^>]*>/i, (m) => `${m}${meta}`);
  154. }
  155. // 没有 head:直接在 <html> 后插入,或整体包一层
  156. if (/<html[^>]*>/i.test(html)) {
  157. return html.replace(/<html[^>]*>/i, (m) => `${m}<head>${meta}</head>`);
  158. }
  159. return `<!DOCTYPE html><html><head>${meta}</head><body>${html}</body></html>`;
  160. }
  161. /**
  162. * 触发浏览器下载。
  163. *
  164. * ## 为什么不能直接用 `<a download href=...>`
  165. *
  166. * 跨域地址上的 `download` 属性会被浏览器**忽略**(安全限制),
  167. * 点击只会在新标签打开。要可靠下载必须用 blob。
  168. */
  169. export async function downloadAs(url: string, filename: string): Promise<void> {
  170. let blob: Blob;
  171. try {
  172. const res = await fetch(url);
  173. if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`);
  174. blob = await res.blob();
  175. } catch {
  176. /*
  177. * 🔴 跨域被 CORS 拦时,fetch 必失败(实测 s3.fmode.cn 未配 CORS 头)。
  178. * 但文件本身是 public-read,**直接打开链接能下载**。
  179. * 所以降级为「新标签打开」,而不是让用户看到 ERR_FAILED。
  180. */
  181. openInNewTab(url);
  182. return;
  183. }
  184. const objectUrl = URL.createObjectURL(blob);
  185. try {
  186. const a = document.createElement('a');
  187. a.href = objectUrl;
  188. a.download = filename;
  189. document.body.appendChild(a);
  190. a.click();
  191. a.remove();
  192. } finally {
  193. // 立即释放可能让部分浏览器来不及开始下载,延后一拍
  194. setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
  195. }
  196. }
  197. /** 新标签打开(CORS 不可用时的降级路径,也可直接预览) */
  198. export function openInNewTab(url: string): void {
  199. const a = document.createElement('a');
  200. a.href = url;
  201. a.target = '_blank';
  202. a.rel = 'noopener noreferrer';
  203. document.body.appendChild(a);
  204. a.click();
  205. a.remove();
  206. }