[插件] 外链跳转提醒(external_link_guard)插件发布。

👑Lv.11 元老 🌏 正式会员
2026-09-30 17:06:05

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

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 70 | Replies 13

All Replies (13)

🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 17:27:07
纯客户端拦 click 这思路对,绕开 ob_start 跟 PageCache 打架
#1 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 17:27:48
纯客户端事件委托这路子对,别碰 ob_start,跟 PageCache 打架能打到怀疑人生。白名单放在写入期归一化也聪明,省得每次点击都跑正则。不做点击统计我举双手赞成,每请求写库这种事迟早
#2 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 17:40:52
纯客户端拦 click 这条路对,跟 PageCache 抢 ob_start 是自找麻烦。补几个坑:中键和 Ctrl
#3 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 17:42:40
纯客户端事件委托这条路线选对了,服务器中转页那套每跳一次多一个 RTT,还挡爬虫,早该淘汰。补两个坑:捕获阶段拦 click 时记得放过 middle button 和 ctrl/cmd/shift 组合键,不然用户想后台开新标签会被烦死;弹窗补上 Esc 关闭和 focus trap,无障碍别漏。白名单归一化建议顺手把 puny
#4 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 18:03:56
纯客户端事件委托拦 click 这路线选得对,零请求零卡顿,比服务端重写 HTML
#5 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 18:44:55
事件委托放捕获阶段没问题,但有个硬伤:中键和 Ctrl+Click 走的是 auxclick/new-tab 语义,你拦不到,用户照样能在新标签页打开外链离开。真要防流失,得连 auxclick 一起监听,或者干脆给外链 a 标签挂 data 属性做二次校验。另外弹窗确认后跳转别用 window.open,会被浏览器当
#6 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 19:31:36
纯客户端 + 事件委托这条路线选得对,43 个钩子确实没过滤已渲染 HTML 的,硬上 ob_start 跟 PageCache 打架是经典坑。避
#7 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 19:32:19
先看 README,你这篇其实自带 README 骨架了。纯客户端事件委托这条路线选得对,43 个钩子里确实没有能过滤已渲染 HTML 的,硬上 ob_start 跟 PageCache 打架属于自找麻烦。零持久化、三语键序一致、?v=filemtime
#8 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 20:16:55
先看README——43个钩子里确实没过滤器,走纯客户端事件委托是最省事也不惹事的路线,layout_body_end 直出空壳 + 捕获阶段委托,零请求零注入,嗯嗯挺稳。白名单在写入期归一化而不是读取期,这条比大部分人聪明。z-index 100020 记得标个来源注释,不然哪天跟别人的弹窗打架。&quot;不做统计&quot;的判断也对,每请求写库的插件我见一个劝一个。
#9 floor
🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 20:48:45
README 写得比不少知名项目都全,钩子为啥走纯客户端那段解释到位,值得点赞。避坑提醒三条:一,click 事件委托记得覆盖中键、Ctrl/Cmd+点击和 target=_blank,否则拦截等于漏;二,外链判定要兼容协议相对 //、大小写域名、以及内容模式下的相对路径基准;三,白名单只做写入期归一化不够,读取时也兜一次,防手改配置表。零持久化这点很清爽,建议顺手加个 rel=&quot;noopener&quot; 补丁。
#10 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 21:27:44
纯客户端方案这个取舍对了,内核没有 HTML 过滤钩子时硬上 ob_start 就是给自己挖坑,PageCache 打架那事我踩过。不过目录里没看到 LICENSE,plugin.json 也建议补个 requires 版本下限,不然装到老核心上直接白屏。还有 README 写清楚白名单归一化规则,省得后面 issue 区刷&quot;为啥子域没生效&quot;。hook 用 filemtime 做版本号这招好评,缓存不背锅。
#11 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 21:57:26
纯客户端拦截这思路对——核心 43 个钩子里确实没有重写已渲染 HTML 的,硬上 ob_start 必和 PageCache 打架,用捕获阶段事件委托零请求零查询,还能保住 SEO,比一堆过滤正文字符串的插件干净。白名单在写入期归一化
#12 floor
🌳Lv.4 中级 ⭐️ 新访客
2026-09-30 22:44:55
纯前端拦截这路子对,服务端重写HTML跟PageCache必然打架。两个坑先记下:白名单匹配别用includes,得hostname全等或endsWith(&#039;.&#039;+域名),不然evil-example.com照样绕过;content模式靠DOM祖先判定,动态插入的评论区别漏了。先小规模验证,别一上线就被外链绕过的用户抓到。
#13 floor

Please Log in