Files
FamilyBook/assets/js/preview.js
T
laoyang e826540d2a 修复 PDF 预览被 Chromium 屏蔽(iframe sandbox 禁用内置阅读器) #14
现象:资源模块点开 PDF 附件,预览区只剩「此页面已被 Chromium 屏蔽」。

根因:preview.js 的 pdf 分支给 iframe 挂了 sandbox
(allow-same-origin allow-scripts,引入于 v24 8f80f3e)。
Chromium 只要看到 iframe 上有 sandbox 属性就禁用内置 PDF 阅读器插件,
表现即「被屏蔽」。五组同源对照实测(Chromium 153):
  · 无 sandbox                      → 正常渲染
  · allow-same-origin allow-scripts → 屏蔽
  · allow-scripts                   → 屏蔽
  · + allow-popups-to-escape-sandbox→ 仍然屏蔽(网上答案说能救,本版本不行)
  · + allow-forms allow-popups      → 仍然屏蔽
⇒ 加白名单无效,只能不加。

改动:删掉该 setAttribute,换成 17 行实测结论注释防止以后被"为安全"加回去。

安全性说明:PDF 由浏览器内置阅读器扩展在独立扩展进程渲染,不是普通页面
上下文,拿不到父页 window/DOM/cookie;PDF 是原生文档,没有能访问父页的
脚本环境。服务端另有 nosniff + MIME 内容嗅探(media.php),PDF 不会被当
HTML 执行。若将来要禁止 PDF 内嵌 JS,正确做法是换 pdf.js 自渲染。

验证:
  · 新增 _tooling/_pdf_e2e.py,11 条断言全通过 0 失败,已登记进流水线
    (挂「全路由」标签)。判据落在渲染层:frame 列表无 chrome-error、
    有内置阅读器扩展 frame、iframe 内文字不含「屏蔽」。
  · 反向验证:注入 sandbox 回去 → 4 条转红;恢复后 11/0。红绿闭环成立。
  · 全量流水线 2h21m 跑完,EXIT=0,已部署。
  · 线上 md5 与本地一致(988b804f),setAttribute('sandbox' 已不存在。
2026-10-27 01:00:12 +08:00

184 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 全局附件预览(v24 用户第 3 条;第 19 批 第 6 条加左右翻页)
*
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
* · 图片 → <img>
* · 视频 → <video controls autoplay>(media.php 支持 Range,进度条能拖)
* · PDF → <iframe>(用浏览器自带的阅读器)
* · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。
*
* 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。
*
* 触发方式是数据属性,不用给每个地方写一遍初始化:
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
*
* ────────────────────────────────────────────────────────────────
* v24 第 19 批 用户第 6 条:「行程中的图片和视频,预览的时候,左右有箭头,
* 可以直接查看下一张。」
*
* 做法:翻页的候选 = **同一页里所有可预览的附件**(`[data-prev]`),按文档顺序。
* 为什么不做"只在本段行程内翻页":
* · 这个预览层是**全站一个**(图片/视频/PDF 共用),而 attachments 墙是按
* 宿主分块渲染的;要"按块"就得给每块再编一次组号,收益很小、复杂度不小;
* · 用户要的行为是"看完这张接着看下一张",同页顺序翻页完全满足,
* 并且跨段也不会跳去别的页面 —— 翻到下一个旅程节点时用户能立刻看出来。
* ⚠️ 只有**一个**可预览项时箭头不显示(点了没反应的元素比没有更糟)。
* ⚠️ 键盘 ← → 也翻页;视频/PDF 里方向键有自己的含义,所以**只在图片**上接管方向键。
*/
(function () {
'use strict';
var box = document.getElementById('pv');
if (!box) { return; }
var stage = box.querySelector('.pv-stage');
var titleEl = box.querySelector('.pv-name');
var metaEl = box.querySelector('.pv-meta');
var dlEl = box.querySelector('.pv-dl');
var closeEl = box.querySelector('.pv-close');
var prevEl = box.querySelector('.pv-prev');
var nextEl = box.querySelector('.pv-next');
var opener = null;
/** 当前这一层里能看到的所有可预览项(文档顺序),以及"正在看第几个" */
var items = [];
var cur = -1;
function clear() {
// ⚠️ 换内容要**整个重建**节点,不能只改 src:
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
stage.innerHTML = '';
}
/** 收一份当前页面里所有可预览的附件(每次打开都重收,页面内容可能被改过) */
function collect(trigger) {
items = Array.prototype.slice.call(document.querySelectorAll('[data-prev]'));
cur = items.indexOf(trigger);
if (cur < 0) { items = [trigger]; cur = 0; }
}
/** 第一张 / 最后一张时箭头要禁用(循环翻页会让人不知道自己在哪儿) */
function syncArrows() {
if (!prevEl || !nextEl) { return; }
var many = items.length > 1;
prevEl.hidden = !many;
nextEl.hidden = !many;
prevEl.disabled = !many || cur <= 0;
nextEl.disabled = !many || cur >= items.length - 1;
}
function paint(trigger) {
var kind = trigger.getAttribute('data-prev') || 'image';
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
var dl = trigger.getAttribute('data-dl') || src;
if (!src) { return false; }
opener = trigger;
clear();
var el;
if (kind === 'video') {
el = document.createElement('video');
el.className = 'pv-video';
el.src = src;
el.controls = true;
el.autoplay = true;
el.playsInline = true;
} else if (kind === 'pdf') {
el = document.createElement('iframe');
el.className = 'pv-pdf';
el.src = src;
// ⚠️ 这里**绝对不能**加 sandbox 属性。加上去 PDF 会直接打不开:
// 页面只剩一行「此页面已被 Chromium 屏蔽」,阅读器根本不起。
//
// 实测(Chromium 153,五组同源对照):
// · 无 sandbox → 正常渲染(多挂一个内置阅读器扩展 frame)
// · allow-same-origin allow-scripts → 屏蔽
// · allow-scripts → 屏蔽
// · + allow-popups-to-escape-sandbox → **仍然屏蔽**(网上很多答案说这个能救,本版本不行)
// · + allow-forms allow-popups → 仍然屏蔽
// ⇒ 只要属性存在就不给 PDF 插件权限。加白名单无效,只能不加。
//
// 不加也安全:PDF 由浏览器内置阅读器(mhjfbmdgcfjbbpaeojofohoefgiehjai)
// 在**独立扩展进程**里渲染,不是普通页面上下文,拿不到父页的 window / DOM / cookie。
// PDF 是原生文档、不是 HTML,本就没有能访问父页面的脚本环境。
// 服务端另有 `X-Content-Type-Options: nosniff` + MIME 按内容嗅探(media.php),
// PDF 不会被当 HTML 执行。若将来真需要禁止 PDF 内嵌 JS,正确做法是换 pdf.js 自渲染,
// 而不是给 iframe 挂 sandbox——那会把整个 PDF 预览一起废掉。
} else {
el = document.createElement('img');
el.className = 'pv-img';
el.src = src;
el.alt = '';
}
stage.appendChild(el);
titleEl.textContent = trigger.getAttribute('data-name') || '';
var sz = trigger.getAttribute('data-size') || '';
var counter = items.length > 1 ? (' ' + (cur + 1) + '/' + items.length) : '';
metaEl.textContent = sz + counter;
dlEl.setAttribute('href', dl);
// 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险
var nm = trigger.getAttribute('data-name') || '';
if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); }
syncArrows();
return true;
}
function open(trigger) {
collect(trigger);
if (!paint(trigger)) { return; }
box.hidden = false;
box.setAttribute('aria-hidden', 'false');
closeEl.focus();
}
/**
* 翻到上一张 / 下一张。
* @param {number} dir -1 = 上一张,+1 = 下一张
*/
function step(dir) {
if (items.length <= 1) { return; }
var n = cur + dir;
if (n < 0 || n >= items.length) { return; }
cur = n;
paint(items[cur]);
}
function close() {
box.hidden = true;
box.setAttribute('aria-hidden', 'true');
clear(); // 停掉视频 / 卸掉 PDF
if (opener && opener.focus) { opener.focus(); }
opener = null;
items = [];
cur = -1;
}
/** 正在看的是不是图片(视频/PDF 里方向键另有含义,不能抢) */
function isImage() {
return items[cur] && (items[cur].getAttribute('data-prev') || '') === 'image';
}
document.addEventListener('click', function (e) {
var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null;
if (t) {
// ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev,
// 浏览器照常走 href(就是下载地址),不会被这里挡住。
e.preventDefault();
open(t);
return;
}
// 点遮罩(stage 本身或弹层空白处)关闭;点内容不关
if (!box.hidden && (e.target === box || e.target === stage)) { close(); }
});
if (prevEl) { prevEl.addEventListener('click', function () { step(-1); }); }
if (nextEl) { nextEl.addEventListener('click', function () { step(1); }); }
closeEl.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (box.hidden) { return; }
if (e.key === 'Escape') { close(); return; }
if (e.key === 'ArrowLeft' && isImage()) { e.preventDefault(); step(-1); return; }
if (e.key === 'ArrowRight' && isImage()) { e.preventDefault(); step(1); }
});
})();