v3.2.13 图片选项全部自带默认图,新装站点不再出现空洞/破图

根本原因:kratos_option() 运行时只读数据库,`std` 仅在后台表单预填和
「恢复默认」时生效,前台根本读不到。因此新装站点所有图片项都是空值。
另外 isset() 挡不住「用户清空」——of_sanitize_upload() 对空输入返回 '',
键存在且值为空串,调用方传的兜底值被绕过。

同时不能把绝对 URL 写进 std:它会随保存一起写库,主题目录改名/换 CDN/
迁站后永久 404。本站 s_wechat_url / g_donate_alipay / g_donate_wechat
三项就指向已删除的 kratos-master 目录,一直在破图(这三项是 hidden 字段,
后台界面上看不到,所以坏了很久没人发现)。

做法:
- 新增 inc/theme-option-defaults.php,图片默认值注册表(11 项),
  默认图一律运行时给出,不写数据库。
- yang_option_image() 三层判断:空/纯空白 → 默认图;指向已不存在的主题
  目录 → 默认图;文件不存在则不输出地址(避免换来一个 404)。
- 陈旧判断用 get_template()(主题 slug)而非 basename(目录名),避免本地
  开发目录名与 slug 不一致时误伤当前主题自己的图。
- 模板统一走取值函数:header(logo/favicon/banner)、footer(微信码/国旗)、
  404、index、article 缩略图、widgets 头像、page-toolbar、theme-core 赞赏码。
- 国旗与 favicon 的渲染抽成 yang_flag_block() / yang_favicon_tag(),
  空值时不输出空 src / 空 href。
- 后台 9 处 std 改为 yang_options_default_img(),与前台默认图同源;
  该包装函数自带 function_exists 防护,单独加载 theme-options.php 不会 Fatal。

新增图片(本地生成,合计 291KB):default-404 / default-share /
default-empty / default-avatar / default-banner / default-flag / favicon.ico。
favicon 为 WordPress 风格自绘(品牌蓝圆底 + 白色 W),非站长个人专用那张。

验证:run_tests 146 条(新增第 4 组图片默认值 22+ 条);falsify 35 条注入
全部能被抓住(新增 10 条);verify_all.sh 14 组全绿。
This commit is contained in:
2026-10-23 19:50:07 +08:00
parent 704a9346e1
commit ef9a58dc51
19 changed files with 410 additions and 49 deletions
+307
View File
@@ -0,0 +1,307 @@
<?php
/**
* 图片类选项的默认值
*
* ── 为什么需要这个文件 ───────────────────────────────────────────────
* 主题设置里有一批图片字段(Logo、favicon、国旗、404 配图、分享图、
* 默认特色图、无内容图、头像、背景图、二维码…)。新装一个站点时它们
* 全是空的,页面上就会出现:`<link href="">` 的空 favicon、
* `<img src="">` 的破图、一片空白的 404 配图。
*
* ── 原来的 std 为什么不够 ───────────────────────────────────────────
* 选项数组里的 `std` 只在两处起作用:
* ① 后台表单首次打开时的预填值;
* ② 点「恢复默认」按钮时写进库的那批值。
* 而运行时的 kratos_option() **完全不看 std** —— 它只查库,查不到才用
* 调用方传进来的第二个参数。所以:
* · 新装站点(库里没有该键)→ 模板若没写第二参数,就是空;
* · 用户保存过表单但把那格删空了 → 键存在且值是 '',
* kratos_option() 用 isset() 判断,'' 也算"已设置",
* 连调用方传的默认值都会被绕过。
* 两个口子合起来,才导致"明明填了 std 却还是空的"。
*
* ── 本文件的职责 ───────────────────────────────────────────────────
* 用一张集中的「字段 → 默认图」注册表 + 一个访问函数,把上面两个口子
* 一起堵住:
* · 值是空字符串 / 只有空白 → 用默认图;
* · 默认图本身若不存在(有人删了包内文件)→ 返回空,绝不输出假路径。
* 图片文件都在 assets/img/ 下,体积已按用途压到合计约 300KB。
*
* ── 有意不放默认图的字段 ───────────────────────────────────────────
* `g_logo`(图片 Logo)不做默认图。它空着时模板回落到站点名称文字,
* 那比给所有站点塞同一张通用 logo 图好看得多 —— 一张共用的占位 logo
* 会让每个站看起来都一样,反而更难看。
*
* @author Yongquan Yang <yangyq.net>
* @license MIT License
*/
if (!function_exists('yang_image_defaults')):
/**
* 字段 => 主题包内的相对路径
*
* 只登记**确实需要**默认图的字段。新增字段时加一行即可,
* 模板里用 yang_option_image('xxx') 读取。
*/
function yang_image_defaults()
{
return array(
// 站点图标。不用站点所有者自己的那枚(那是他专属的),
// 用一枚中性的 WordPress 风格图标(品牌蓝圆 + 白 W)。
'g_icon' => '/assets/img/favicon.ico',
// 页脚国旗。页面上固定 width:50px,100px 宽足够 2x 屏。
'g_flag_url' => '/assets/img/default-flag.png',
// 404 配图。整块背景,1000px 宽够用。
'g_404' => '/assets/img/default-404.jpg',
// 分享图与默认特色图:同一类用途(社交卡片 / 列表缩略),
// 共用一张,避免包里放两份几乎一样的图。
'seo_shareimg' => '/assets/img/default-share.jpg',
'g_postthumbnail' => '/assets/img/default-share.jpg',
// 搜不到内容时的插画。
'g_nothing' => '/assets/img/default-empty.jpg',
// 站长头像。
'a_gravatar' => '/assets/img/default-avatar.jpg',
// 顶部图片导航的背景。CSS 是 height:300px + cover。
'top_img' => '/assets/img/default-banner.jpg',
// 下面三个主题包里本来就有图,只是这里显式登记,
// 让"所有图片字段都有默认"这件事可被一处查全。
's_wechat_url' => '/assets/img/wechat.png',
'g_donate_alipay' => '/assets/img/donate.png',
'g_donate_wechat' => '/assets/img/donate.png',
);
}
endif;
if (!function_exists('yang_image_default')):
/**
* 取某个字段的默认图 URL;没有登记或文件不存在则返回 ''。
*
* 为什么要探文件存在:包被精简、或有人手工删了图片时,
* 返回一个 404 的地址比返回空更糟 —— 页面能打开但满屏破图。
* 探存在略有一点 IO 开销,但 WP 对象缓存 + 文件系统缓存下可忽略,
* 且本函数只在拼 HTML 时调用,不在循环里大批量跑。
*/
function yang_image_default($id)
{
$map = yang_image_defaults();
if (!isset($map[$id])) {
return '';
}
$rel = $map[$id];
// ASSET_PATH 由 theme-core.php 定义(可能是 CDN 前缀)。
// 它没定义时退回主题目录,避免在极早期调用时炸掉。
$base = defined('ASSET_PATH') ? ASSET_PATH : get_template_directory_uri();
// 文件存在性只能查本地目录;CDN 模式下本地也有一份,照查不误。
if (!file_exists(get_template_directory() . $rel)) {
return '';
}
return $base . $rel;
}
endif;
/* ── 给后台选项数组用的安全包装 ─────────────────────────────
*
* inc/theme-options.php 只在 admin_init 之后才被 require,正常时序下
* yang_image_default() 一定已存在。但那个文件在某些自定义场景
* (别处的脚本直接 include 它、或子主题换了加载顺序)可能先被读到,
* 那时调用未定义函数会 **Fatal**,整个后台白屏。
*
* 这个包装在函数缺失时返回空串,让后台退化成"没有预填默认值",
* 而不是白屏 —— 降级要降得能继续用,不能降成打不开。
*/
if (!function_exists('yang_options_default_img')):
function yang_options_default_img($id)
{
return function_exists('yang_image_default')
? yang_image_default($id)
: '';
}
endif;
if (!function_exists('yang_image_url_is_stale')):
/**
* 判断库里存的图片地址是不是"指向已不存在的主题目录"的陈旧地址。
*
* 为什么需要这个:
* options 表里可能躺着 `https://站点/wp-content/themes/<旧目录名>/assets/img/x.png`
* 这样的绝对地址(曾经的 std 被保存进库,或用户手动填过)。主题目录
* 一旦改名(kratos-master → kratos_yang),这些地址全部 404,页面上
* 就是破图。它们是"非空值",会绕过一切"空则用默认"的兜底,
* 所以必须单独识别。
*
* 判据刻意保守:只有当路径里出现 /wp-content/themes/<某目录>/ 且
* 那个目录**不是**当前主题目录时,才认定陈旧。用户把图传到
* uploads 目录、或填外链,都不受影响。
*/
function yang_image_url_is_stale($url)
{
if (!is_string($url) || $url === '') {
return false;
}
// 只关心主题目录下的资源;uploads / 外链一律不碰
if (strpos($url, '/wp-content/themes/') === false) {
return false;
}
/* 当前主题的目录名(slug)。
*
* 用 get_template() 而不是 basename(get_stylesheet_directory()):
* 后者取的是**文件系统目录名**,而 URL 里的那段是**主题 slug**。
* 正常安装时两者相同,但本地开发(仓库目录叫 wordpress-kratos-yang)
* 或符号链接部署时就会不一致,导致把"当前主题自己的地址"
* 误判成陈旧地址 ⇒ 用户明明填对了图却被换成默认图。
* get_template() 返回的就是 WP 认定的主题 slug,与 URL 同源。 */
$current = function_exists('get_template') ? get_template() : '';
if ($current === '') {
$current = basename(get_stylesheet_directory());
}
// 取出 /wp-content/themes/ 之后的第一段
$tail = substr($url, strpos($url, '/wp-content/themes/') + strlen('/wp-content/themes/'));
$slash = strpos($tail, '/');
$dir = $slash === false ? $tail : substr($tail, 0, $slash);
$dir = trim($dir);
if ($dir === '' || $dir === $current) {
return false;
}
return true;
}
endif;
if (!function_exists('yang_option_image')):
/**
* 读一个图片类选项,空值自动落到默认图。
*
* 这是模板里应该用的函数,取代直接调 kratos_option() ——
* 后者对空字符串不做兜底。
*
* @param string $id 选项 id
* @return string 图片 URL,可能为空(表示"确实没有可用图")
*/
function yang_option_image($id)
{
$val = kratos_option($id, '');
if (is_string($val)) {
$val = trim($val);
}
// 空值 → 用默认图
if ($val === '' || $val === false || $val === null) {
return yang_image_default($id);
}
/* 非空但指向已删除的主题目录(例如早先被写进库的
* .../themes/kratos-master/...)→ 同样落到默认图。
* 这种值不识别的话会一直是破图,而且比"空着"更难发现:
* 后台看着是填了图的。 */
if (yang_image_url_is_stale($val)) {
return yang_image_default($id);
}
return $val;
}
endif;
if (!function_exists('yang_flag_block')):
/**
* 页脚国旗那一块 HTML。
*
* ── 为什么抽成函数而不是写在 footer.php 里 ──────────────────
* 原来这段是内联的 if/else。内联写法有个测试上的致命问题:
* 断言只能"照着这段逻辑再抄一遍"来测,于是测的是抄件、
* 不是模板本身 —— 模板里真改坏了,测试照样全绿(自己测自己)。
* 抽成函数后,测试直接调它,调的就是线上跑的那份代码。
*
* ── 三种情况 ────────────────────────────────────────────
* 1. 没勾「是否添加国旗」 → 输出空(用户就是不想显示)
* 2. 勾了、但拿不到任何图片地址 → 输出空(**绝不输出 <img src="">**)
* 3. 勾了、有地址 → 输出国旗
*
* 第 2 种是老代码漏掉的:src 直接取选项,空值就成了破图图标。
* 破图比不显示更难看,也更容易被误以为"站坏了"。
*/
function yang_flag_block()
{
if (!kratos_option('g_flag')) {
return '';
}
$url = yang_option_image('g_flag_url');
if ($url === '') {
return '';
}
return '<div id="flag"><img style="width:50px" src="'
. esc_url($url) . '" alt=""><div>';
}
endif;
if (!function_exists('yang_favicon_tag')):
/**
* favicon 的 <link> 标签;拿不到地址时返回空(不输出空 href)。
*
* 空 href 的害处:浏览器会退而请求当前页面当作图标,
* 白跑一次请求、图标还是错的(显示成一个空白方块)。
*/
function yang_favicon_tag()
{
$url = yang_option_image('g_icon');
if ($url === '') {
return '';
}
return '<link rel="shortcut icon" href="' . esc_url($url) . '">';
}
endif;
if (!function_exists('yang_option_image_or')):
/**
* 同 yang_option_image(),但允许调用方指定一个"就地兜底"的回退值。
*
* 用在语义上不该共用默认图的场合:例如 Logo 空着时该显示站点名称,
* 而不是某张图。
*
* @param string $id 选项 id
* @param string $fallback 连默认图都没有时用的值
* @return string
*/
function yang_option_image_or($id, $fallback = '')
{
$img = yang_option_image($id);
return $img !== '' ? $img : $fallback;
}
endif;