From 40e09b630958fdeb13b7015556fd9698ca8c52f6 Mon Sep 17 00:00:00 2001 From: laoyang Date: Sun, 25 Oct 2026 16:15:09 +0800 Subject: [PATCH] =?UTF-8?q?3.2.34=20=E6=96=B0=E5=A2=9E=E3=80=8CCDN=20?= =?UTF-8?q?=E5=8A=A0=E9=80=9F=E3=80=8D=EF=BC=9A=E5=9C=B0=E5=9D=80=20+=20?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=20+=20=E9=BB=91=E5=90=8D=E5=8D=95?= =?UTF-8?q?=EF=BC=8C=E9=BB=91=E5=90=8D=E5=8D=95=E4=BC=98=E5=85=88=20#3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后台「主题设置」新增第十一个标签页(邮件通知之后、自定义代码之前), 只有三个输入框:CDN 地址、白名单、黑名单。地址留空即关闭功能。 实现(inc/theme-cdn.php,新增): - 输出缓冲挂在 template_redirect 优先级 1,改整页 HTML 的资源地址, 覆盖主题 enqueue 之外的文章图片、插件输出、区块图片。 - 规则按字面形态自动识别七种写法:* / 扩展名 / 扩展名列表 / 路径前缀 / 通配符 / 路径子串 / ~正则~。 - 单个纯字母词(jpg 与 uploads 分不出谁是扩展名)同时按扩展名与 路径子串匹配,两个含义互斥,避免 uploads 规则静默失效。 - 黑名单先于白名单判定;硬性排除不受两套名单影响:以 / 结尾、 页面/动态后缀、无扩展名、wp-admin/wp-json/wp-cron/xmlrpc/feed、 非本站域名、非 http(s) 协议。 - 后台/REST/AJAX/feed/cron/定制器预览/CLI 一律不改写。 - 查询串与锚点原样保留(?ver= 丢了会一直吃旧缓存)。 - 子目录站:判规则用完整路径,拼 CDN 时剥掉站点子目录。 - 写错的正则编译时丢弃,避免每个资源抛一条 warning 刷满日志。 验证: - inc/theme-cdn.php 逻辑探针 92/0,反向验证 21/0(M1~M15 逐条注入, 含 M5 拆成三条覆盖协议相对/绝对/整页三条分支,M10/M13 组合注入)。 - 后台探针 56/0(真实渲染 + 几何 + 类型 + 说明块),反向验证 15/0。 - 包契约 69/0,反向验证 23/0(改 zip 内字节,不改源码)。 - 旧探针回归:toc330 101/0、admin330 88/0、admin328 13/0、 pkgcheck331/332/333 全绿、e2e322/323 通过。 - 线上仅在 theme-update/ 发 zip + readme + update.json,未解包覆盖服务器主题目录。 --- functions.php | 5 + inc/theme-cdn.php | 728 ++++++++++++++++++++++++++++++++++++++++++ inc/theme-options.php | 79 ++++- style.css | 2 +- 4 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 inc/theme-cdn.php diff --git a/functions.php b/functions.php index 3a0a487..ad70f6e 100644 --- a/functions.php +++ b/functions.php @@ -58,6 +58,11 @@ require get_template_directory() . '/inc/theme-navwalker.php'; // 对象存储配置 require get_template_directory() . '/inc/theme-dogecloud.php'; +// CDN 加速(地址改写:CDN 地址 + 白名单 + 黑名单)。 +// 必须排在 theme-core.php 之后:它要用 kratos_option 与 wp_parse_url。 +// 挂在 template_redirect 上开输出缓冲,所以本文件晚加载不影响生效。 +require get_template_directory() . '/inc/theme-cdn.php'; + // 邮件模板:必须先于 theme-mail.php 加载, // 因为后者在发信时会调用 yang_mail_render() require get_template_directory() . '/inc/theme-mail-template.php'; diff --git a/inc/theme-cdn.php b/inc/theme-cdn.php new file mode 100644 index 0000000..587bb3f --- /dev/null +++ b/inc/theme-cdn.php @@ -0,0 +1,728 @@ + + * @license MIT License + */ + +/** + * 运行时默认值 + * + * 与 3.2.32 的教训一致:`std` 只在后台预填和恢复默认时有用, + * **运行时取不到**。所以这里单独给一份,供 kratos_option() 回落。 + */ +function yang_cdn_defaults() +{ + return array( + 'cdn_url' => '', + 'cdn_include' => '', + 'cdn_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 : ''; +} + +/** + * 解析 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; + } + + $scheme = !empty($p['scheme']) ? strtolower($p['scheme']) : 'https'; + if ($scheme !== 'http' && $scheme !== 'https') { + return null; + } + + return array( + 'scheme' => $scheme, + 'host' => strtolower($p['host']), + 'path' => isset($p['path']) ? rtrim($p['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) +{ + $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=(响应式图片,一个属性里多组地址 + 宽度描述符) + * 内联样式与