Files
wordpress-kratos-yang/inc/theme-cdn.php
T
laoyang adcf80af62 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
2026-10-25 17:10:06 +08:00

990 lines
38 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* CDN 加速
*
* ── 做什么 ────────────────────────────────────────────────────
* 把页面里指向本站的静态资源地址,前缀换成用户配置的 CDN 地址。
* 参考 CDN Enabler 插件的思路,但只保留三件事:CDN 地址、白名单、黑名单。
*
* ── 为什么在输出缓冲层做,而不是改 enqueue ────────────────────
* 主题自己 enqueue 的资源只有十来个,可以精确改;
* 但**文章正文里的图片、插件输出的样式、Gutenberg 区块的图片**都绕过
* enqueue,直接写死在 HTML 里。要覆盖这些,只能在最终 HTML 上做。
* 而且这样做完,主题自身资源也自动被覆盖,不必两套逻辑。
*
* ── 安全底线(硬编码,不受规则影响)──────────────────────────
* 站点被 CDN 改坏最常见的原因是「把页面地址也改了」:
* 导航链接变成 CDN 域名 → 用户点一下就离开本站 → 全站 404。
* 所以下面这些一律不改写,且**不能**通过白名单放开:
* · 以 / 结尾的地址(页面、栏目页)
* · .php / .html / .htm 等页面或动态后缀
* · wp-admin / wp-login / wp-json / wp-cron 等后台与接口路径
* · 非本站域名(外链、别人家的 CDN)
* · data: / mailto: / tel: / javascript: 等非 http(s) 协议
*
* @author Yongquan Yang <yangyq.net>
* @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' => yang_cdn_preset_text(yang_cdn_preset_include()),
'cdn_exclude' => yang_cdn_preset_text(yang_cdn_preset_exclude()),
);
}
/**
* 读一个 CDN 配置
*
* 注意空字符串的处理:用户可能明确把 CDN 地址清空(想关掉功能),
* 那时库里存的是 '',键存在。`isset()` 挡不住这种情况 ——
* 它只判"键在不在"。所以直接返回,由调用方判空。
*/
function yang_cdn_opt($key)
{
$d = yang_cdn_defaults();
$v = kratos_option($key, isset($d[$key]) ? $d[$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 地址
*
* 返回 array(scheme, host, base_path),无法解析时返回 null。
* 允许用户只填 `cdn.example.com`(不带协议)—— 这时按当前站点协议补全,
* 因为在 HTTPS 站点上引用 http 资源会被浏览器拦成混合内容。
*/
function yang_cdn_parse_url($raw)
{
$raw = trim($raw);
if ($raw === '') {
return null;
}
// 去掉尾部斜杠(后面拼路径时会自己加),但保留根路径语义
$raw = rtrim($raw, '/');
if (strpos($raw, '//') === 0) {
$raw = 'https:' . $raw;
} elseif (!preg_match('#^[a-z][a-z0-9+.\-]*://#i', $raw)) {
$raw = 'https://' . $raw;
}
$p = wp_parse_url($raw);
if (empty($p['host'])) {
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($host),
'path' => $path,
);
}
/**
* 把一行规则编译成一个判断函数
*
* ── 规则语法(一行一条)──────────────────────────────────────
* `*` 全部
* `jpg` / `.jpg` / `*.jpg` 扩展名
* `jpg,png` / `.jpg .png` 扩展名列表(逗号或空格分隔)
* `/wp-content/uploads/` 路径前缀(以 / 开头且不含通配符)
* 两端带 `*` 的写法 shell 通配,`*` 可跨 `/`
* (例:星号 + `/uploads/` + 星号)
* `uploads` 路径子串
* `~regex~` 正则,对完整 URL 做 preg_match
* `#` `;` 开头 注释
*
* ── 为什么把「前缀 / 子串 / 通配」混在一个字段里 ──────────────
* 用户明确要求「规则的制定要非常的灵活」,而分成多个字段会让后台变复杂
* (这正是他说"不要弄得太复杂"的地方)。所以按**字面形态**判断,
* 让一行文字自己说明它是什么规则。
*
* 返回 null 表示这行不是有效规则(空行、注释、写错的括号)。
*/
function yang_cdn_compile_rule($line)
{
$line = trim($line);
if ($line === '') {
return null;
}
if ($line[0] === '#' || $line[0] === ';') {
return null;
}
// 正则:用 ~...~ 包裹。必须是整行包裹,避免把路径里的 ~ 误当正则。
if (strlen($line) > 2 && $line[0] === '~' && substr($line, -1) === '~') {
$pattern = $line;
// 先校验一次,写错的正则不能留着 —— 它会在每个资源上抛 warning,
// 一个页面几十个资源就是几十条日志,把日志刷满还托慢页面。
if (@preg_match($pattern, '') === false) {
return null;
}
return function ($url, $path) use ($pattern) {
return preg_match($pattern, $url) === 1;
};
}
// 全部
if ($line === '*') {
return function ($url, $path) {
return true;
};
}
// 扩展名列表:`jpg,png,webp` / `.jpg .png` / `*.jpg,*.png`
// 判据 = 每一段都长得像扩展名(字母数字开头,不含 / 和 *)
$parts = preg_split('/[\s,]+/', $line, -1, PREG_SPLIT_NO_EMPTY);
if (count($parts) > 0) {
$exts = array();
$all_ext = true;
foreach ($parts as $p) {
$p = ltrim($p, '*');
$p = ltrim($p, '.');
if ($p === '' || !preg_match('/^[A-Za-z0-9]+$/', $p)) {
$all_ext = false;
break;
}
$exts[] = strtolower($p);
}
if ($all_ext && !empty($exts)) {
// ── 单个纯字母词的两义并存 ──────────────────────────
//
// `jpg` 和 `uploads` 在字面上分不出谁是扩展名、谁是路径片段:
// 两个都是纯字母,都过得了上面的检查。若一律当扩展名,
// 用户写 `uploads` 想按目录筛就会完全失效(而且没有任何提示,
// 只是"规则没反应",最难查)。
//
// 所以单个词同时按**扩展名**和**路径子串**匹配:
// `jpg` → 扩展名命中 .jpg 文件(路径里也极少含 jpg)
// `uploads` → 子串命中 /wp-content/uploads/...(几乎没有
// 扩展名叫 uploads 的文件,不会误伤)
// 两个含义天然互斥,同时挂上不会造成歧义,反而省掉一次猜。
//
// 多个词(`jpg,png`)**只**按扩展名列表处理:那串文字整体
// 不可能出现在路径里,按子串匹配恒假,留着只会让人误解。
$one_word = (count($exts) === 1);
$needle = $one_word ? $exts[0] : '';
return function ($url, $path) use ($exts, $one_word, $needle) {
$e = strtolower(pathinfo($path, PATHINFO_EXTENSION));
if ($e !== '' && in_array($e, $exts, true)) {
return true;
}
if ($one_word && strlen($needle) > 1) {
return strpos(strtolower($path), $needle) !== false;
}
return false;
};
}
}
// 含通配符 → shell 通配(`*` 跨 `/`)
if (strpos($line, '*') !== false || strpos($line, '?') !== false) {
// 用 preg_quote 转义其余字符,再把占位符换回通配语义
$re = preg_quote($line, '#');
$re = str_replace(array('\*', '\?'), array('.*', '.'), $re);
$pattern = '#^' . $re . '$#i';
return function ($url, $path) use ($pattern) {
return preg_match($pattern, $path) === 1
|| preg_match($pattern, $url) === 1;
};
}
// 以 / 开头 → 路径前缀(含自身)
if ($line[0] === '/') {
$prefix = strtolower($line);
return function ($url, $path) use ($prefix) {
$p = strtolower($path);
return $p === rtrim($prefix, '/') || stripos($p, $prefix) === 0;
};
}
// 其余 → 路径子串(不区分大小写)
$needle = strtolower($line);
return function ($url, $path) use ($needle) {
return strpos(strtolower($path), $needle) !== false;
};
}
/**
* 把整段文本编译成一串规则
*/
function yang_cdn_compile_list($raw)
{
$out = array();
foreach (preg_split('/\r\n|\r|\n/', (string) $raw) as $line) {
$fn = yang_cdn_compile_rule($line);
if ($fn !== null) {
$out[] = $fn;
}
}
return $out;
}
/**
* 规则编译缓存的登记处
*
* ── 为什么需要手动清 ────────────────────────────────────────
* 规则编译是有成本的(每行都要判形态、正则还要先试编译),所以
* yang_cdn_should_rewrite() 里用 static 缓存一份。但 static 一旦写进去
* 就跟着进程走,任何"改了配置马上要看到新结果"的场合都会拿到旧规则:
* · 后台的「测试这条地址」按钮 —— 用户刚在输入框里改完规则,还没保存
* · 自测脚本 —— 一个进程里要试几十种配置
* 所以把缓存句柄放在全局里,提供这个函数显式清掉。
*
* 后台测试用的是"临时覆盖层"(改 $GLOBALS['__yang_cdn_override']),
* 不写库、不触发 sanitize,所以不会像 3.2.11 的 SMTP 那样递归。
*/
function yang_cdn_reset_cache()
{
$GLOBALS['__yang_cdn_rules'] = null;
$GLOBALS['__yang_cdn_hosts'] = null;
$GLOBALS['__yang_cdn_base'] = null;
}
/**
* 读一份(编译好的)规则,带缓存
*/
function yang_cdn_rules()
{
if (isset($GLOBALS['__yang_cdn_rules']) && is_array($GLOBALS['__yang_cdn_rules'])) {
return $GLOBALS['__yang_cdn_rules'];
}
$GLOBALS['__yang_cdn_rules'] = array(
'include' => yang_cdn_compile_list(yang_cdn_opt('cdn_include')),
'exclude' => yang_cdn_compile_list(yang_cdn_opt('cdn_exclude')),
);
return $GLOBALS['__yang_cdn_rules'];
}
/**
* 判断某个地址是否该走 CDN
*
* $url 完整地址(可能是绝对地址,也可能是 /path 这种相对地址)
* $path 已经剥掉域名与查询串的纯路径
*
* ── 顺序很重要 ───────────────────────────────────────────────
* ① 硬性排除(页面/动态/后台/非本站)→ 无论规则怎么写都不改
* ② 黑名单 → 命中就退出
* ③ 白名单为空 = 全通过;否则必须命中至少一条
*/
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'];
// ① 硬性排除 ----------------------------------------------------
$lower_path = strtolower($path);
// 以 / 结尾 = 页面/栏目页。改了会让导航跳到 CDN 域名,全站 404。
if ($lower_path === '' || substr($lower_path, -1) === '/') {
return false;
}
// 页面与动态后缀
$ext = strtolower(pathinfo($lower_path, PATHINFO_EXTENSION));
$never = array('php', 'php3', 'php4', 'php5', 'phtml', 'html', 'htm', 'shtml',
'asp', 'aspx', 'jsp', 'cgi', 'pl', 'do', 'action');
if ($ext !== '' && in_array($ext, $never, true)) {
return false;
}
// 没有扩展名也不改:通常是接口、短链、伪静态页
if ($ext === '' && strpos(basename($lower_path), '.') === false) {
return false;
}
// 后台与接口路径
foreach (array('/wp-admin/', '/wp-login.php', '/wp-json/', '/wp-cron.php',
'/xmlrpc.php', '/wp-comments-post.php', '/feed/') as $bad) {
if (strpos($lower_path, $bad) !== false) {
return false;
}
}
// ② 黑名单 ------------------------------------------------------
foreach ($exclude_cache as $fn) {
if ($fn($url, $path)) {
return false;
}
}
// ③ 白名单(空 = 全通过)----------------------------------------
if (empty($include_cache)) {
return true;
}
foreach ($include_cache as $fn) {
if ($fn($url, $path)) {
return true;
}
}
return false;
}
/**
* 把一个地址换成 CDN 地址
*
* 返回 false 表示不改。返回字符串表示换好的地址。
*/
function yang_cdn_rewrite_one($url, $site_hosts, $cdn)
{
$raw = $url;
// 非 http(s) 一律不动:data:、mailto:、tel:、javascript:、about:blank …
if (preg_match('#^(data|mailto|tel|javascript|about|blob|ftp):#i', $raw)) {
return false;
}
// 协议相对地址 `//host/path` —— 必须按绝对地址处理,
// 否则下面按「相对路径」拼 base 会得到 `https://cdn.com//host/path`。
if (strpos($raw, '//') === 0) {
$abs = 'https:' . $raw;
$p = wp_parse_url($abs);
$host = isset($p['host']) ? strtolower($p['host']) : '';
if ($host === '' || !in_array($host, $site_hosts, true)) {
return false;
}
return yang_cdn_build($p, $cdn);
}
// 绝对地址:只有本站域名才改
if (preg_match('#^https?://#i', $raw)) {
$p = wp_parse_url($raw);
if (empty($p['host'])) {
return false;
}
$host = strtolower($p['host']);
if (!in_array($host, $site_hosts, true)) {
return false;
}
return yang_cdn_build($p, $cdn);
}
// 相对地址(以 / 开头,或者纯相对路径)
$p = wp_parse_url($raw);
if (empty($p['path'])) {
return false;
}
$p['host'] = '';
return yang_cdn_build($p, $cdn);
}
/**
* 用解析好的 URL 分量拼出 CDN 地址
*
* 查询串与锚点原样保留 —— 版本号参数(`?ver=3.2.33`)丢了会让浏览器
* 一直用 CDN 上的旧缓存,是这类功能最常见的事故。
*/
function yang_cdn_build($p, $cdn)
{
$path = isset($p['path']) ? $p['path'] : '';
if ($path === '' || $path[0] !== '/') {
$path = '/' . $path;
}
if (!yang_cdn_should_rewrite(
(isset($p['host']) && $p['host'] !== '' ? 'https://' . $p['host'] : '') . $path,
$path
)) {
return false;
}
$out = $cdn['scheme'] . '://' . $cdn['host'] . $cdn['path'] . $path;
if (isset($p['query']) && $p['query'] !== '') {
$out .= '?' . $p['query'];
}
if (isset($p['fragment']) && $p['fragment'] !== '') {
$out .= '#' . $p['fragment'];
}
return $out;
}
/**
* 站点自己的域名列表(要改写哪些域名)
*
* home_url / site_url 都算 —— WordPress 允许把站点装在子目录里,
* 也允许 home 与 site 不同域(前台与后台分开)。漏掉一个就会有一半
* 资源不走 CDN,而且很难发现是哪个域名漏了。
*/
function yang_cdn_site_hosts()
{
if (isset($GLOBALS['__yang_cdn_hosts']) && is_array($GLOBALS['__yang_cdn_hosts'])) {
return $GLOBALS['__yang_cdn_hosts'];
}
$hosts = array();
foreach (array(home_url('/'), site_url('/')) as $u) {
$p = wp_parse_url($u);
if (!empty($p['host'])) {
$hosts[] = strtolower($p['host']);
}
}
// 带 www 与不带 www 都要认:站点地址填了 www,而正文里可能写裸域名
foreach ($hosts as $h) {
if (strpos($h, 'www.') === 0) {
$hosts[] = substr($h, 4);
} else {
$hosts[] = 'www.' . $h;
}
}
$hosts = array_values(array_unique($hosts));
$GLOBALS['__yang_cdn_hosts'] = $hosts;
return $hosts;
}
/**
* 主题站点的根路径
*
* 站点装在子目录(`example.com/blog`)时,资源地址是
* `/blog/wp-content/...`。拼 CDN 地址时要把这段剥掉,否则会变成
* `cdn.com/blog/wp-content/...`,而 CDN 回源根目录下并没有 blog。
* 但用户的规则是按完整路径写的,所以**判规则时用原路径,拼接时才剥**。
*/
function yang_cdn_site_base()
{
if (isset($GLOBALS['__yang_cdn_base'])) {
return $GLOBALS['__yang_cdn_base'];
}
$p = wp_parse_url(home_url('/'));
$GLOBALS['__yang_cdn_base'] = isset($p['path']) ? rtrim($p['path'], '/') : '';
return $GLOBALS['__yang_cdn_base'];
}
/**
* 改写一整段 HTML
*
* 只碰「资源地址可能出现的位置」,不去正则所有 http 字符串 ——
* 后者会把正文里提到的网址、分享链接、评论内容一起改掉,
* 而且这类误改极难发现(读者点文章里的引用链接会跑到 CDN)。
*
* 覆盖:
* src= / href= 的静态资源(脚本、样式、图片、视频、字体)
* srcset=(响应式图片,一个属性里多组地址 + 宽度描述符)
* 内联样式与 <style> 里的 url(...)
*
* 不改 href 里指向页面/文档的链接 —— 由 yang_cdn_should_rewrite 的
* 硬性排除兜住(页面与 .html/.php 都不改)。
*/
function yang_cdn_rewrite_html($html)
{
/*
* ⚠️ 这里必须**先查开关再查地址**,不能只靠 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;
}
$hosts = yang_cdn_site_hosts();
$base = yang_cdn_site_base();
// 包一层:把站点子目录剥掉再拼 CDN
$build = function ($p) use ($cdn, $base) {
if ($base !== '' && !empty($p['path'])) {
$pp = $p['path'];
if ($pp === $base || strpos($pp, $base . '/') === 0) {
$stripped = substr($pp, strlen($base));
$p['path'] = ($stripped === '' ? '/' : $stripped);
}
}
return yang_cdn_build($p, $cdn);
};
// ── srcset:先处理,因为它的格式(多组 + 描述符)不通用 ──
$html = preg_replace_callback(
'#(\ssrcset\s*=\s*)(["\'])(.*?)\2#is',
function ($m) use ($hosts, $build) {
$items = array();
$changed = false;
foreach (explode(',', $m[3]) as $item) {
$item = trim($item);
if ($item === '') {
continue;
}
// 每组 = 地址 + 可选描述符(`2x`、`480w`)
$sp = preg_split('/\s+/', $item, 2);
$u = $sp[0];
$desc = isset($sp[1]) ? ' ' . $sp[1] : '';
if (preg_match('#^(https?:)?//#i', $u)) {
$p = wp_parse_url(preg_match('#^//#', $u) ? 'https:' . $u : $u);
$host = isset($p['host']) ? strtolower($p['host']) : '';
if ($host !== '' && in_array($host, $hosts, true)) {
$new = $build($p);
if ($new !== false) {
$u = $new;
$changed = true;
}
}
} elseif ($u !== '' && $u[0] === '/' && substr($u, 0, 2) !== '//') {
$p = wp_parse_url($u);
$p['host'] = '';
$new = $build($p);
if ($new !== false) {
$u = $new;
$changed = true;
}
}
$items[] = $u . $desc;
}
if (!$changed) {
return $m[0];
}
return $m[1] . $m[2] . implode(', ', $items) . $m[2];
},
$html
);
// ── src= / href= ──
// 值里可能带查询串(`?ver=`),用 [^"']* 而不是「到 ? 为止」,
// 否则会截断成半截地址。
$html = preg_replace_callback(
'#(\s(?:src|href)\s*=\s*)(["\'])((?:(?:https?:)?//|/)[^"\']*)\2#is',
function ($m) use ($hosts, $build) {
$new = yang_cdn_rewrite_abs($m[3], $hosts, $build);
if ($new === false) {
return $m[0];
}
return $m[1] . $m[2] . $new . $m[2];
},
$html
);
// ── 内联样式与 <style> 里的 url() ──
$html = preg_replace_callback(
'#url\(\s*(["\']?)((?:(?:https?:)?//|/)[^)"\']*)\1\s*\)#i',
function ($m) use ($hosts, $build) {
$new = yang_cdn_rewrite_abs($m[2], $hosts, $build);
if ($new === false) {
return $m[0];
}
return 'url(' . $m[1] . $new . $m[1] . ')';
},
$html
);
return $html;
}
/**
* 改写一个「属性值里的地址」
*
* 与 yang_cdn_rewrite_one 的区别:这个函数只负责认形态(绝对/协议相对/相对),
* 真正的拼接交给外部传进来的 $build(它负责剥站点子目录)。
*/
function yang_cdn_rewrite_abs($u, $hosts, $build)
{
if ($u === '') {
return false;
}
if (preg_match('#^//#', $u)) {
$p = wp_parse_url('https:' . $u);
$host = isset($p['host']) ? strtolower($p['host']) : '';
if ($host === '' || !in_array($host, $hosts, true)) {
return false;
}
return $build($p);
}
if (preg_match('#^https?://#i', $u)) {
$p = wp_parse_url($u);
$host = isset($p['host']) ? strtolower($p['host']) : '';
if ($host === '' || !in_array($host, $hosts, true)) {
return false;
}
return $build($p);
}
if ($u[0] === '/') {
$p = wp_parse_url($u);
if (empty($p['path'])) {
return false;
}
$p['host'] = '';
return $build($p);
}
return false;
}
/**
* 输出缓冲:整页改写
*
* ── 为什么用 ob_start 而不是各种 the_content 过滤器 ──────────
* 资源地址散落在主题模板、插件输出、区块渲染、小工具里,
* 逐个挂钩子必然漏。整页缓冲是唯一能一次覆盖全的地方。
*
* ── 什么时候不改 ────────────────────────────────────────────
* 后台、REST、AJAX、feed、cron、预览、定制器:这些地方的地址带 CDN
* 会妨碍编辑与调试(编辑器里看不到真实图片、REST 返回的地址对不上库)。
*/
function yang_cdn_buffer_start()
{
if (is_admin() || wp_doing_ajax() || wp_doing_cron()) {
return;
}
if (defined('REST_REQUEST') && REST_REQUEST) {
return;
}
if (defined('WP_CLI') && WP_CLI) {
return;
}
if (function_exists('is_feed') && is_feed()) {
return;
}
// 定制器预览:用户正在改主题设置,看到的应该是真实地址
if (isset($_GET['customize_changeset_uuid']) || isset($_GET['customize_preview'])) {
return;
}
// 功能没开(开关没勾 / 地址没填 / 地址解析不了)—— 直接不接管,零开销
if (!yang_cdn_active()) {
return;
}
ob_start('yang_cdn_buffer_callback');
}
add_action('template_redirect', 'yang_cdn_buffer_start', 1);
/**
* 输出缓冲回调
*
* 只处理 HTML。图片、CSS、JS、XML 直接原样返回 ——
* 对二进制内容调 preg_replace 既没用又可能损坏数据。
*/
function yang_cdn_buffer_callback($html)
{
if (!is_string($html) || $html === '') {
return $html;
}
// 只看开头一小段判断类型,别对整页做正则
$head = substr($html, 0, 200);
if (stripos($head, '<!doctype') === false && stripos($head, '<html') === false
&& stripos($head, '<head') === false) {
return $html;
}
return yang_cdn_rewrite_html($html);
}
/**
* 后台「测试这条地址走不走 CDN」
*
* 给后台一个只读的诊断入口:用户填了规则,不用去前台翻源码就能确认
* 「我这个图片到底走不走 CDN」。返回数组,供 AJAX 与自测脚本共用。
*/
function yang_cdn_test_url($url)
{
$cdn = yang_cdn_parse_url(yang_cdn_opt('cdn_url'));
$r = array(
'input' => $url,
'cdn' => $cdn === null ? '' : ($cdn['scheme'] . '://' . $cdn['host'] . $cdn['path']),
'matched' => false,
'output' => $url,
'reason' => '',
);
/*
* 诊断入口要**说清楚是哪个条件没满足**,而不是笼统说"被排除"。
* 后台用户看到「地址未填写」和「未命中白名单」是两种完全不同的
* 修法,混成一句话就等于没提示。
*/
if (!yang_cdn_enabled()) {
$r['reason'] = '「启用 CDN 加速」没有勾选,功能已关闭';
return $r;
}
if ($cdn === null) {
$r['reason'] = 'CDN 地址未填写或无法解析';
return $r;
}
$p = wp_parse_url($url);
if (empty($p['path'])) {
$r['reason'] = '这个地址没有路径部分';
return $r;
}
$path = $p['path'];
$r['path'] = $path;
if (!yang_cdn_should_rewrite($url, $path)) {
$r['reason'] = '被排除(硬性规则或黑名单),或未命中白名单';
return $r;
}
$r['matched'] = true;
$r['output'] = yang_cdn_rewrite_one($url, yang_cdn_site_hosts(), $cdn);
if ($r['output'] === false) {
$r['matched'] = false;
$r['output'] = $url;
$r['reason'] = '域名不是本站(或地址形态无法识别)';
} else {
$r['reason'] = '已命中,将改写到 CDN';
}
return $r;
}