本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/plugins/external_link_guard/说明.md。
外链跳转提醒(external_link_guard)
版本:1.0.0 | 状态:已启用(
activated: true)| 编制:2026-09-30一句话:前台点击站外链接时先弹「即将离开本站」确认框,用户确认后才跳转 —— 防流失。
一、功能简介
| 能力 | 说明 |
|---|---|
| 外链拦截 | 前台任意页面点击站外链接时不立即跳转,先弹确认框 |
| 确认框 | 遮罩 + 居中卡片:「即将离开本站」+ 目标域名 + 「继续访问」/「留在本站」 |
| 拦截范围可配 | 全站所有外链(默认)/仅帖子·博客正文内的外链 |
| 白名单域名 | 配置的域名及其子域不弹确认框(友链、自家附属站点) |
| 总开关 | 后台一键关闭;关闭时前台零注入、零请求 |
不做:点击统计(避免每请求写库)、「不再提醒此站点」、mailto:/tel: 拦截、右键菜单拦截。
二、技术路线(为什么是纯客户端)
核心 43 个钩子(规范 §9.1)没有任何「重写 / 过滤已渲染 HTML」的能力:
controller_view_before(#6)只能改渲染前的$data,改不到渲染后的 HTML;- C 组内容钩子(#17~#25)全是锚点插入,不是过滤器;
- 插件自建
ob_start()会与核心PageCache/header_register_callback互相打架。
→ 所以本插件不改核心、不改正文 HTML,只在 layout_body_end 直出空壳弹窗标记,
由 assets/script.js 在浏览器里用事件委托拦 click。
收益:零额外网络请求、零服务端查询、零卡顿风险;不改 HTML 结构(SEO 与分享不受影响)。
三、目录结构
plugins/external_link_guard/
├── plugin.json # 清单(activated: true)
├── Plugin.php # 主类:设置读写 / 后台判定 / 弹窗渲染 / class_alias
├── AdminController.php # 后台配置页(继承 Admin\BaseController)
├── hook/
│ ├── layout_head_end.php # </head> 前:注入 CSS/JS(shouldInject 门控 + ?v=filemtime)
│ ├── layout_body_end.php # </body> 前:直出弹窗空壳标记
│ └── admin_route_register.php # 注册后台路由(不写 /admin 前缀)
├── views/
│ ├── popup.php # 前台弹窗标记片段(空壳)
│ └── admin.php # 后台配置视图(容器三件套)
├── assets/
│ ├── style.css # 弹窗样式(z-index 100020,只用核心 --mn-* 变量)
│ └── script.js # 拦截脚本(捕获阶段事件委托)
└── lang/
├── zh.php / zh_tw.php / en.php # 三语(各 23 键,集合零差异 + 键序一致)无 data/ 目录 —— 本插件零持久化数据(配置走 Settings),不需要建表,
故没有 init_after / ddlStatements() / ensureSchemaOnce()。
四、路由
| 方法 | 路径 | 处理 |
|---|---|---|
GET | /admin/external-link-guard | AdminController::index(配置页) |
POST | /admin/external-link-guard | AdminController::save(保存,PRG) |
无前台路由(纯客户端拦截,不需要中转页)。
五、配置项
| 设置键 | 类型 | 默认 | 说明 |
|---|---|---|---|
external_link_guard_enabled | '1' / '0' | '1' | 总开关 |
external_link_guard_mode | all / content | all | 拦截范围 |
external_link_guard_whitelist | 逗号分隔字符串 | '' | 白名单域名(已归一化) |
白名单归一化(写入期,Plugin::normWhitelist()):小写、去协议、去路径、去端口、
去通配前缀、去重;只保留形如域名的串(无点或非法字符一律丢弃)。
支持换行 / 半角逗号 / 全角逗号 / 分号混合分隔。
六、核心规则
6.1 外链判定(assets/script.js)
只能用 host 比对:
new URL(a.href, location.href).host !== location.host⚠️ 不能用
target="_blank"或rel判外链 ——核心
app/Helpers/Content.php:123-126给所有<a>(含站内链接)统一加了
target="_blank"+rel="noopener noreferrer nofollow"→ 靠它们判会误拦站内链接。
豁免清单(一律放行):
| # | 对象 | 判据 |
|---|---|---|
| ① | 锚点 / 空链接 | href 以 # 开头或为空 |
| ② | 伪协议 | javascript: / data: / vbscript: |
| ③ | 邮件 / 电话 | mailto: / tel: |
| ④ | 非 http(s) | 协议白名单 |
| ⑤ | 同域链接 | url.host === location.host(含端口) |
| ⑥ | 白名单 | 域名精确匹配 或 . + 域名 的子域匹配 |
| ⑦ | 下载链接 | 有 download 属性 |
| ⑧ | 修饰键 / 中键 | Ctrl/Cmd/Shift/Alt 或 button !== 0 |
| ⑨ | 具名 target | target 不是 _blank / _self |
| ⑩ | htmx 链接 | 有 hx-get / hx-post / … 属性(交给 htmx) |
6.2 白名单匹配(安全关键)
hostname === d || hostname.slice(-(d.length + 1)) === '.' + d⚠️ 必须带
'.'前缀,不能用hostname.endsWith(d)——
'evil-example.com'.endsWith('example.com')为true→ 会误放行同后缀的另一个域。
6.3 「继续访问」必须是 <a>(防弹窗拦截器)
弹窗里的「继续访问」是一个真实的 <a href target="_blank">,JS 只负责把目标 URL 填进 href。
⚠️ 不能用
window.open()—— 它在「用户点确认」的回调里执行时已脱离「用户手势」上下文→ 被浏览器弹窗拦截器静默拦掉(点了没反应)。且 localhost 开发常不拦、生产会拦
→ 属「本机测不出、线上才暴露」类故障。
配套:确认跳转时立即关弹窗 —— 跳转发生在新标签,本站页面从未离开;
不关则弹窗常驻 + body 锁滚动,用户回到原标签会以为页面卡死。
6.4 后台页不注入
layout_body_end 全站触发(含后台)且无参数 → 只能靠请求路径判后台:
Plugin::isBackend('') // 模板名 admin/… OR 请求路径以 /admin 开头后台(含本插件配置页)不输出弹窗标记、不引前台 CSS/JS。
6.5 z-index
.elg-root 取 100020 —— 高于核心浮层天花板 100005(.image-lightbox = 99999),
也高于 notice_center 的 100010(两个弹窗可能同时存在,外链确认框应在最上层)。
七、注意事项
- 改
assets/style.css/assets/script.js后不需要手动 bump 版本号 ——
layout_head_end.php 用 ?v=filemtime() 破缓存(asset() 走 immutable,
URL 变化是唯一破缓存手段)。
- 改配置后必须清页面缓存 —— 已由
AdminController::save()调
Plugin::invalidatePageCache() 自动完成(规范 §11.5 第 31 条)。
手工改动(如直接改 Settings)后需自行清一次。
- ⚠️ 目录名含下划线 →
Plugin.php末尾的class_alias不可删 ——
核心按目录名硬拼 \Plugin\external_link_guard\Plugin(app/Helpers/Plugin.php:699),
删掉则 activate()/deactivate()/uninstall() 被静默跳过(不报错、无日志)。
- 命名空间首段必须逐字节等于目录名(规范 §11.34):
ExternalLinkGuard 经 camelToSnake 得 external_link_guard ✅
(自检工具 .workbuddy-ai/tools/plugin_case_lint.py)。
- 后台视图的「容器三件套」是硬契约(规范 §11.3 第 22 条):
.admin-container + admin/_sidebar + <main class="admin-content">,漏了 = 后台页变「裸 HTML」。
- 开关字段必须用表单哨兵(规范 §11.32):
elg_form_submitted隐藏域缺了,
开关会被静默写成最小值(302 + 提示成功,但 DB 与 settings_cache.php 都不变)。
- 本插件是纯客户端拦截,因此:
- 用户禁用 JS → 弹窗不出现,外链直接跳转(= 无插件行为,不产生 bug);
- 右键「在新标签打开」/ 拖拽 / 中键 → 不走
click事件,拦不到(技术上无解); - JS 动态跳转(
location.href = …,如某些分享按钮)→ 拦不到。
- 弹窗标记是空壳(无已读状态、无 CSRF、无用户数据)→ 与用户无关,
可安全进游客整页缓存(PageCache 仅游客)。
八、验证记录(2026-09-30)
| 套件 | 结果 |
|---|---|
.workbuddy-ai/tmp/elg_probe.php(后端逻辑) | 53 PASS / 0 FAIL |
.workbuddy-ai/tmp/elg_js_probe.js(前端逻辑,jsdom + 真实脚本) | 43 PASS / 0 FAIL |
.workbuddy-ai/tmp/elg_view_probe.php(后台视图渲染) | 25 PASS / 0 FAIL |
| 合计 | 121 项断言全绿 |
plugin_hygiene.py | FAIL = 0 |
plugin_case_lint.py | PASS(48 个插件全通过) |
php_var_cjk_check.php | 0 命中 |
| 幻影 CSS 变量 | 0 个(9 个引用全部有定义) |
| 零内联扫描 | 0 命中 |
php -l 双版本(8.2 + 8.0) | 10/10 × 2 |
| i18n 三语 | 各 23 键,集合零差异 + 键序一致 |
HTTP 端到端(php -S + curl) | 前台含弹窗(在 </body> 前,仅 1 处)、CSS 在 </head> 内、后台不含 |
关键断言(防回归):
- 白名单
example.com不豁免evil-example.com(后缀陷阱); - 站内链接(相对 / 绝对)不被拦(核心给所有
<a>加target="_blank"的坑); - 渲染产物不含
window.open; - 后台路径下
renderPopup()返回空串; - 无哨兵时
saveSettings()一个键都不写。
九、相关文档
- 开发计划:
docs/外链跳转弹窗插件开发计划_2026-09-30.md(含方案选型反证与 6 个决策点) - 同类实现参照:
plugins/notice_center/(全站公告弹窗,同款双钩子范式) - 规范:
docs/插件开发规范.md§9.1(钩子全表)/ §7.3(零内联)/ §11.3 第 22 条 / §11.24 / §11.32 / §11.34