3.2.35 CDN 加速加启用开关 + 预制白/黑名单,勾了不填地址拒绝保存 #4

「启用 CDN 加速」复选框放在 CDN 段最前。不勾时下面填什么都不产生作用,
前台与没装该功能完全一致(三个入口 should_rewrite / rewrite_html /
buffer_start 都过 yang_cdn_active())。

预制名单写进输入框的 std,用户可直接增删改,保存后以用户内容为准:
  白名单 13 项(.avif .css .gif .jpeg .jpg .js .json .mp3 .mp4 .pdf
  .png .svg .webp);黑名单 13 项安全后缀(.php* .phtml .phar .env
  .sql .bak .log .ini .sh .htaccess)。同时 yang_cdn_defaults() 给同一份
  —— std 在运行时完全不生效,两处不同源就会出现「后台 13 项、前端 0 项」。

勾了开关但地址留空:保存被整单拒绝(validate_options 返回旧值),并
add_settings_error 提示。回调里绝不 update_option(会递归触发
sanitize_option_kratos,3.2.11 吃爆内存的路径)。另加 admin_init 进页面
提示,救「勾着但地址空」的老站点。

开关默认值按「地址是否非空」推算,老站点升级不丢加速;没填过地址的站点
默认不勾,避免第一次点保存就被「必须填地址」拦住。

顺带修掉 parse_url 对主机过于宽容导致的畸形地址(https://not a url at all
曾被当合法,会把全站静态资源拼成畸形地址)。

验证:_cdn335_logic.php 116/0 · _cdn335_falsify.php 22/0 ·
_admin335_cdn_check.py 74/0 · _admin335_falsify.py 23/0 ·
_pkgcheck335.py 117/0 · _pkgcheck335_falsify.py 27/0 ·
_cdn334_logic.php 92/0(向后兼容)· _cdn334_falsify.php 21/0
This commit is contained in:
2026-10-25 17:10:06 +08:00
parent 40e09b6309
commit adcf80af62
4 changed files with 427 additions and 21 deletions
+270 -8
View File
@@ -26,18 +26,102 @@
* @license MIT License
*/
/**
* 预制白名单(写死在主题代码里,同时作为后台输入框的默认内容)
*
* ── 为什么是这些扩展名 ──────────────────────────────────────
* 只收「内容不变、可以放心放到第三方节点上」的静态资源。
* 判据是三条:
* ① 按扩展名就能确定是静态文件,不需要服务端参与
* ② 不含用户私有内容(所以没有 .xml —— 那可能是 sitemap,
* 也可能是 RSS 导出,还可能含未公开数据)
* ③ 同一个文件在 CDN 上被读一千次也不会产生任何副作用
*
* ── 为什么带点写 ────────────────────────────────────────────
* 规则引擎里单个纯字母词会**同时**按「扩展名」和「路径子串」匹配
* (这是为了救 `uploads` 那条目录规则,见 yang_cdn_compile_rule)。
* 不带点的 `avif` 会命中 `/wp-content/uploads/avif-optimized/a.jpg`
* 这种路径;带点之后子串是 `.avif`,不会误伤。
*
* 这份清单会**预填进后台的白名单输入框**,用户可以随意增删改;
* 保存之后以用户的内容为准,主题不会再去覆盖它。
* 想恢复成这份默认内容,点后台的「恢复默认」即可。
*/
function yang_cdn_preset_include()
{
return array(
'.avif', '.css', '.gif', '.jpeg', '.jpg', '.js', '.json',
'.mp3', '.mp4', '.pdf', '.png', '.svg', '.webp',
);
}
/**
* 预制黑名单
*
* ── 为什么这份清单有必要 ────────────────────────────────────
* 硬性排除(yang_cdn_should_rewrite 的 ① )已经挡住了 .php / .html
* 一类会直接改坏站点的后缀。但那只是「改了站点会坏」的下限,不是
* 「改了会出事」的全部:`.env`、`.sql`、`.bak`、`.log` 这些一旦
* 被推到第三方 CDN 节点上,等于把配置、数据库备份、错误日志公开了。
* 它们不在硬性排除里(因为主题没法预知用户装了哪些插件、
* 备份文件放在哪个目录),所以必须在默认配置里先拦住。
*
* 同样预填进后台的黑名单输入框,用户可以改。黑名单优先于白名单,
* 所以即使把白名单写成 `*`,这份黑名单里的后缀也不会走 CDN。
*/
function yang_cdn_preset_exclude()
{
return array(
'.php', '.php3', '.php4', '.php5', '.phtml', '.phar',
'.env', '.sql', '.bak', '.log', '.ini', '.sh', '.htaccess',
);
}
/** 预制清单 → 一行一条的文本(输入框与默认值都用这个形态) */
function yang_cdn_preset_text($list)
{
return implode("\n", $list);
}
/**
* 「启用」复选框的默认值
*
* ── 默认值为什么这么算 ──────────────────────────────────────
* 3.2.34 的语义是「填了 CDN 地址就生效」,没有开关。本版加了开关,
* 如果把它默认成「不勾」,那么升级上来的站点(地址早就填好、正在
* 正常工作)会在升级那一刻**静默失去加速**,而且用户不会去查
* 一个从没动过的复选框。
*
* 所以默认值取「地址非空就当作已启用」:
* · 老站点填过地址 → 默认勾选 → 升级后行为不变
* · 老站点没填地址 → 默认不勾 → 点保存不会被「必须填地址」拦住
* · 用户亲手保存过一次之后,库里就有明确的值,按用户的来
*
* ⚠️ 这里**不能**走 yang_cdn_opt():那个函数会先取 yang_cdn_defaults(),
* 而 defaults 又调本函数,会无限递归。必须直接问 kratos_option。
*/
function yang_cdn_enable_default()
{
$u = kratos_option('cdn_url', '');
return is_string($u) && trim($u) !== '';
}
/**
* 运行时默认值
*
* 与 3.2.32 的教训一致:`std` 只在后台预填和恢复默认时有用,
* **运行时取不到**。所以这里单独给一份,供 kratos_option() 回落。
*
* 两个输入框的默认值就是预制清单 —— 这样「从未保存过配置」的站点
* 装好主题就有一套能用的规则(而不是"白名单为空 = 全部加速")。
*/
function yang_cdn_defaults()
{
return array(
'cdn_enable' => yang_cdn_enable_default(),
'cdn_url' => '',
'cdn_include' => '',
'cdn_exclude' => '',
'cdn_include' => yang_cdn_preset_text(yang_cdn_preset_include()),
'cdn_exclude' => yang_cdn_preset_text(yang_cdn_preset_exclude()),
);
}
@@ -55,6 +139,104 @@ function yang_cdn_opt($key)
return is_string($v) ? $v : '';
}
/**
* 把一个「复选框的值」判成真/假
*
* 抽出来是因为有三个地方要判:运行时(yang_cdn_enabled)、
* 保存校验(yang_cdn_check_config)、以及后台提示。
* 三处各写一份 `=== '1'` 的话,只要有一处写窄了就会表现成
* "勾了但功能不开",而且极难查。
*/
function yang_cdn_truthy($v)
{
if (is_string($v)) {
$v = trim($v);
return $v !== '' && $v !== '0';
}
return (bool) $v;
}
/**
* 「启用 CDN 加速」复选框当前是不是勾着的
*
* ── 为什么真值判断要写得这么宽 ──────────────────────────────
* 这个值经过 Options Framework 的 of_sanitize_checkbox(),勾了存字符串
* '1'、没勾存 false。但历史数据里可能是 1 / '1' / true / 'on',
* 也可能压根没有这个键(3.2.34 之前的站点)。所以一律按「真值」判,
* 不做 `=== '1'` 这种字面量比较 —— 那会让老站点全部变成"未启用"。
*/
function yang_cdn_enabled()
{
$d = yang_cdn_defaults();
return yang_cdn_truthy(kratos_option('cdn_enable', $d['cdn_enable']));
}
/**
* 功能此刻到底生不生效
*
* 三个条件同时满足才算生效:勾了开关、地址非空、地址能解析。
* 任何一条不满足都直接返回 false —— 这时前台行为与没装这个功能完全一致
* (不是"少改一点",是**一个字节都不改**)。
*
* 把这四个判断收在一个函数里,是为了让「哪里算关闭」只有一处定义:
* 输出缓冲(yang_cdn_buffer_start)、整页改写(yang_cdn_rewrite_html)、
* 后台诊断(yang_cdn_test_url)三处都调它,不会出现
* "缓冲没接管但后台说已命中"这种自相矛盾。
*/
function yang_cdn_active()
{
if (!yang_cdn_enabled()) {
return false;
}
return yang_cdn_parse_url(yang_cdn_opt('cdn_url')) !== null;
}
/**
* 保存前的配置校验(★ 只在后台保存链路里用)
*
* ── 为什么参数要显式传进来,而不是直接读库 ──────────────────
* 本函数是在 register_setting 的 sanitize 回调里跑的。那一刻用户
* 刚提交的新值**还没写库**,直接调 yang_cdn_opt() / yang_cdn_enabled()
* 读到的是**上一次**的配置 —— 于是「勾了但没填地址」永远判不出来
* (上一次可能是合法的),而「上次勾了、这次取消勾选」会被误判成
* 非法并拒绝保存。这类"校验的是旧数据"的 bug 极难自查:表现成
* "提示时有时无",取决于上一次存的是什么。
*
* 所以这里只接受调用方传进来的、**这一次提交的** $enable 与 $url。
*
* 返回 '' 表示没问题;返回非空字符串是给用户看的原因。
*/
function yang_cdn_check_config($enable, $url)
{
if (!yang_cdn_truthy($enable)) {
return '';
}
$url = is_string($url) ? trim($url) : '';
if ($url === '') {
return '「启用 CDN 加速」已勾选,但 CDN 地址是空的。请填写 CDN 地址,或者取消勾选这个开关。';
}
if (yang_cdn_parse_url($url) === null) {
return 'CDN 地址「' . $url . '」无法识别,请填写形如 https://cdn.example.com 的地址。';
}
return '';
}
/**
* 「勾了开关却没填地址」这种非法状态 —— 供后台提示用
*
* ── 为什么不在运行时判断,也不影响前台 ──────────────────────
* 这个状态在前台**等同于关闭**(yang_cdn_active() 会返回 false),
* 所以前台不需要它。它只是后台的一个提示条件:
* 用户下次进入设置页时,如果看到自己勾了但没填地址,应该被提醒。
*
* 判据必须与 yang_cdn_check_config() 的口径完全一致(勾了 + 空地址),
* 否则会出现"保存时拦住、进页面却不提示"这种前后矛盾。
*/
function yang_cdn_missing_url()
{
return yang_cdn_enabled() && trim(yang_cdn_opt('cdn_url')) === '';
}
/**
* 解析 CDN 地址
*
@@ -83,15 +265,66 @@ function yang_cdn_parse_url($raw)
return null;
}
/*
* ★ 主机名必须自己校验一遍 —— parse_url 对主机极其宽容。
*
* 实测(PHP 7.4 / 8.4 都一样):
* parse_url('https://not a url at all') → host = 'not a url at all'
* parse_url('https://exa mple.com/x') → host = 'exa mple.com'
* parse_url('https://cdn..example.com') → host = 'cdn..example.com'
*
* 只判 `empty($p['host'])` 挡不住这些。后果不是"少加速一点",而是
* **全站静态资源一起变成畸形地址**:用户把地址栏里的整串复制进来、
* 或者手打了空格,页面上每个 css/js/图都会拼成
* `https://not a url at all/wp-content/...` ⇒ 全部加载失败,
* 站点直接没有样式。而用户在后台看到的只是一个"填了地址"的框。
*
* 所以这里显式拒掉:空白、控制字符、分隔符、首尾点、连续点,
* 以及不属于「字母/数字/点/横线/下划线」的字符。
* 国际化域名(中文域名)放行 —— \p{L} 覆盖各类文字。
*/
$host = $p['host'];
$is_ipv6 = (substr($host, 0, 1) === '[' && substr($host, -1) === ']');
if (!$is_ipv6) {
/*
* ⚠️ 这里**只留两条**检查,刻意不写「空白字符单独判一条」。
*
* 空白(含 \t \n \r 与控制字符)以及 / ? # @ : \ 这些分隔符,
* 全都不在下面的字符集里 ⇒ 已经被字符集检查挡住了。
* 单独再写一条 `preg_match('/[\s\x00-\x1f\x7f]/')` 看着更"严密",
* 实际是**恒被下一条覆盖的空转断言** —— 反向验证时拆掉它
* 探针照样全绿,谁也不知道它其实不起作用。
* 宁可少一条真的,不要多一条假的(判据要能证明自己有用)。
*/
if (substr($host, 0, 1) === '.' || substr($host, -1) === '.'
|| strpos($host, '..') !== false) {
return null;
}
if (!preg_match('/^(?:[A-Za-z0-9._\-]|\p{L})+\z/u', $host)) {
return null;
}
}
$scheme = !empty($p['scheme']) ? strtolower($p['scheme']) : 'https';
if ($scheme !== 'http' && $scheme !== 'https') {
return null;
}
/*
* 子路径(CDN 地址可以是 `cdn.example.com/static`)同理:
* 路径里出现空白或控制字符,拼出来的资源地址就是非法的
* (`https://cdn.example.com/sta tic/a.css` 浏览器会当断点处理)。
* 合法的编码形式 `%20` 不受影响 —— 它不含真正的空白字符。
*/
$path = isset($p['path']) ? rtrim($p['path'], '/') : '';
if (preg_match('/[\s\x00-\x1f\x7f]/', $path)) {
return null;
}
return array(
'scheme' => $scheme,
'host' => strtolower($p['host']),
'path' => isset($p['path']) ? rtrim($p['path'], '/') : '',
'host' => strtolower($host),
'path' => $path,
);
}
@@ -284,6 +517,18 @@ function yang_cdn_rules()
*/
function yang_cdn_should_rewrite($url, $path)
{
/*
* 功能没开 ⇒ 任何地址都不走 CDN。
*
* 这一条必须放在最前面,且不能只放在调用方(buffer_start / rewrite_html):
* 本函数是「规则判定」的唯一出口,后台诊断、自测脚本、以及将来可能
* 新增的调用方都从它拿结论。只在入口挡的话,只要有人新加一个调用点
* 就会漏判,而漏判的表现是"关掉了还在加速"。
*/
if (!yang_cdn_active()) {
return false;
}
$rules = yang_cdn_rules();
$include_cache = $rules['include'];
$exclude_cache = $rules['exclude'];
@@ -481,10 +726,17 @@ function yang_cdn_site_base()
*/
function yang_cdn_rewrite_html($html)
{
$cdn = yang_cdn_parse_url(yang_cdn_opt('cdn_url'));
if ($cdn === null) {
/*
* ⚠️ 这里必须**先查开关再查地址**,不能只靠 yang_cdn_parse_url() 返回 null。
*
* 3.2.34 只有「地址空了 = 关闭」一个条件;本版加了显式开关,
* 如果这里还只看地址,那么用户取消勾选后地址仍然躺在库里,
* 前台会继续改写 —— 开关等于没用,而且现象是"我关了它还在动"。
*/
if (!yang_cdn_active()) {
return $html;
}
$cdn = yang_cdn_parse_url(yang_cdn_opt('cdn_url'));
if (strpos($html, 'http') === false && strpos($html, 'src=') === false
&& strpos($html, 'href=') === false && strpos($html, 'url(') === false) {
return $html;
@@ -651,8 +903,8 @@ function yang_cdn_buffer_start()
if (isset($_GET['customize_changeset_uuid']) || isset($_GET['customize_preview'])) {
return;
}
// 站点还没配 CDN 地址 —— 直接不接管,零开销
if (yang_cdn_parse_url(yang_cdn_opt('cdn_url')) === null) {
// 功能没开(开关没勾 / 地址没填 / 地址解析不了)—— 直接不接管,零开销
if (!yang_cdn_active()) {
return;
}
@@ -697,6 +949,16 @@ function yang_cdn_test_url($url)
'reason' => '',
);
/*
* 诊断入口要**说清楚是哪个条件没满足**,而不是笼统说"被排除"。
* 后台用户看到「地址未填写」和「未命中白名单」是两种完全不同的
* 修法,混成一句话就等于没提示。
*/
if (!yang_cdn_enabled()) {
$r['reason'] = '「启用 CDN 加速」没有勾选,功能已关闭';
return $r;
}
if ($cdn === null) {
$r['reason'] = 'CDN 地址未填写或无法解析';
return $r;