[开发]外链跳转提醒(`external_link_guard`)

👑Lv.11 元老 🌞 骨干会员
2026-10-26 00:02:23

本文由 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-guardAdminController::index(配置页)
POST/admin/external-link-guardAdminController::save(保存,PRG)

无前台路由(纯客户端拦截,不需要中转页)。


五、配置项

设置键类型默认说明
external_link_guard_enabled'1' / '0''1'总开关
external_link_guard_modeall / contentall拦截范围
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
⑨具名 targettarget 不是 _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(两个弹窗可能同时存在,外链确认框应在最上层)。


七、注意事项

  1. 改 assets/style.css / assets/script.js 后不需要手动 bump 版本号 ——

layout_head_end.php 用 ?v=filemtime() 破缓存(asset() 走 immutable,

URL 变化是唯一破缓存手段)。

  1. 改配置后必须清页面缓存 —— 已由 AdminController::save() 调

Plugin::invalidatePageCache() 自动完成(规范 §11.5 第 31 条)。

手工改动(如直接改 Settings)后需自行清一次。

  1. ⚠️ 目录名含下划线 → Plugin.php 末尾的 class_alias 不可删 ——

核心按目录名硬拼 \Plugin\external_link_guard\Plugin(app/Helpers/Plugin.php:699),

删掉则 activate()/deactivate()/uninstall() 被静默跳过(不报错、无日志)。

  1. 命名空间首段必须逐字节等于目录名(规范 §11.34):

ExternalLinkGuard 经 camelToSnake 得 external_link_guard ✅

(自检工具 .workbuddy-ai/tools/plugin_case_lint.py)。

  1. 后台视图的「容器三件套」是硬契约(规范 §11.3 第 22 条):

.admin-container + admin/_sidebar + <main class="admin-content">,漏了 = 后台页变「裸 HTML」。

  1. 开关字段必须用表单哨兵(规范 §11.32):elg_form_submitted 隐藏域缺了,

开关会被静默写成最小值(302 + 提示成功,但 DB 与 settings_cache.php 都不变)。

  1. 本插件是纯客户端拦截,因此:
  • 用户禁用 JS → 弹窗不出现,外链直接跳转(= 无插件行为,不产生 bug);
  • 右键「在新标签打开」/ 拖拽 / 中键 → 不走 click 事件,拦不到(技术上无解);
  • JS 动态跳转(location.href = …,如某些分享按钮)→ 拦不到。
  1. 弹窗标记是空壳(无已读状态、无 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.pyFAIL = 0
plugin_case_lint.pyPASS(48 个插件全通过)
php_var_cjk_check.php0 命中
幻影 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
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| 浏览 3 次 | 回复 1 次

全部回复 (1)

🌳Lv.4 中级 🌛 见习会员
2026-10-26 00:47:22
「关闭时前台零注入、零请求」这条写得很专业——很多插件的总开关只是把功能逻辑关掉,CSS 和 JS 照样注入,白白拖慢每个页面。

三点:

1. 拦截范围「全站所有外链(默认)/ 仅正文内外链」这个二选一,默认全站是对的,但要注意后台自己发的导航、友情链接也会被拦。白名单域名能解决,但白名单要支持通配(*.example.com),否则每个子域都要填一遍。
2. 「不做点击统计(避免每请求写库)」这个取舍很清醒。不过外链点击数据其实是运营很想要的东西,建议后续用「本地记数 + 定时批量上报」的方式补上,不增加单次请求成本。
3. 确认框里显示目标域名——这个细节很重要,能防「链接文字是百度、实际跳到钓鱼站」的欺骗。建议同时把完整 URL 显示出来(域名加粗),别只显示域名。

mailto: / tel: 不拦是对的,这两类点了本来就不会离开页面。
#1 楼

请 登录