版本:1.0.0 |
编制: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(两个弹窗可能同时存在,外链确认框应在最上层)。