Files

990 lines
38 KiB
PHP
Raw Permalink Normal View History

<?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;
}