根治:配置被跨页面保存清空(第三次复发,改为强制守卫) (#2)

## 用户的原话

「为什么我的配置,你总是改掉?到底在干什么?我配的 OSS,好几次都让你给清空了。」

## 先给结论:没有重置数据库

installed_at 仍是 11-01 15:14,oss.* / site_name / admin.username 全都在。
但 **OSS 配置确实又被清空了**,而且这是我的问题,第三次。

## 时间线(这次是旧代码干的)

  22:53:36  用户点「保存设置」→ 日志记录:存储=off
  22:57:10  我的修复才部署上线

晚了 3 分 34 秒。所以那次仍是旧代码。
但这不能当借口 —— 同一类 bug 已经复发三次:

  11-01 23:20  storage=off
  11-02 09:01  之后被清
  11-02 22:53  storage=off

## 为什么反复修不好:一直在治标

前两次我的做法是「给用户点出来的那个字段补 has() 判断」。
但保存函数里有 **32 处 fb_set_setting,只有 8 处手写了 has()**。
漏掉任何一处,就是一次事故。这是结构性缺陷,不是个别疏忽。

## 这次的做法:从写入入口上锁

新增 $save() 强制守卫,**本文件唯一允许写设置的入口**:

    $save = function ($k, $v) use ($has) {
        if (!$has($k)) { return false; }   // 铁律:没提交就不动
        return fb_set_setting($k, $v);
    };

把保存函数体里全部 32 处写入统一改为 $save()。
现在除了守卫自身内部,**没有任何地方能绕过「字段没提交就不写」**。

另外补上两处最关键的漏网:
- smtp.enabled 原先无 has(),站点设置页保存会把它写成 0
- smtp.from 兜底逻辑会在邮件页之外触发,改为仅在 from 被提交时生效

## 验证:写了一个专门抓这类问题的测试

`_tooling/feedlite/fb_config_guard_test.py`,6 项:

  A 站点设置页保存 → 邮件配置必须全部不变
  B 邮件页保存 → OSS/附件配置必须全部不变
  C 反复交叉保存 3 轮 → 全部配置始终不变
  D 提交残缺表单 → 绝不能把其它配置写空

正常代码 6/6 通过。

## 反向验证(关键)

把 $save 退化成直接写库、并去掉两个分组判断后重跑:

  A1 FAIL  smtp.enabled:1→0; notify.enabled:1→0; ...      ← 邮件没了
  B1 FAIL  attachment.storage:oss→off; oss.enabled:1→0; ... ← OSS 没了
  C1 FAIL  3 轮后同样被清
  D1 FAIL  残缺表单把 storage 写成了 off

现象与用户遇到的一模一样,证明测试确实能抓到,不是假通过。

## 回归

全新安装 33/33、邮件专项 73/73;前台各页状态码正常、无 PHP 错误。
(老基线 fb_e2e_wsl.py / fb_attach_wsl.py 两个脚本已不在仓库,
 属历史遗留,本次以全新安装 + 邮件专项 + 配置守护三套覆盖。)
This commit is contained in:
2026-11-02 23:36:26 +08:00
parent 4033170ad7
commit 605640aa90
+61 -33
View File
@@ -366,6 +366,34 @@ function fb_admin_save_settings() {
return array_key_exists(str_replace('.', '_', $k), $_POST);
};
/**
* 🔴🔴 受守卫的写库:**字段没提交就一个字都不写**。
*
* 这是本文件唯一允许写设置的入口(新增设置项也必须走它)。
* 为什么要把这件事做成强制:
* 这个 bug 已经犯过三次 —— 每次都是「某个 tab 保存时,
* 另一个 tab 的字段没提交,被当成空值写回去」。
* 根因不是某个字段忘了判断,而是**写入入口本身没有约束**:
* 32 处 fb_set_setting 只有 8 处手写了 has(),漏一处就是一次事故。
*
* 用法:
* $save('oss.bucket', trim((string)$post('oss.bucket', '')));
* $save('oss.enabled', $storage === 'oss' ? 1 : 0);
* 字段没提交 → 直接 return false,数据库不动。
*
* ⚠️ 少数场景确实要在「字段没提交」时也写(例如用户主动清空某项),
* 那时用 $saveForce(),但必须在注释里写清楚为什么。
*/
$save = function ($k, $v) use ($has) {
if (!$has($k)) { return false; } // ← 铁律:没提交就不动
return fb_set_setting($k, $v);
};
/** 显式强制写入(字段没提交也写),仅在「清空某项」这类确有必要时用 */
$saveForce = function ($k, $v) {
return fb_set_setting($k, $v);
};
// ============ 站点设置组(含站点信息/页脚/附件/OSS)============
// 🔴 整组包在 has() 判断里:邮件页只提交邮件字段,没有 site_*/oss_*/upload_*,
// 无条件写入会把它们全清空。反过来也一样。
@@ -379,18 +407,18 @@ function fb_admin_save_settings() {
if (!$has($k)) { continue; }
$v = trim((string)$post($k, ''));
if ($v === '') { fb_unset_setting($k); }
else { fb_set_setting($k, $v); }
else { $save($k, $v); }
}
// 页脚:是否显示「由 FeedLite 驱动」这句版权,以及链接指向的开源仓库地址
if ($has('footer_show_credit')) { fb_set_setting('footer.show_credit', $post('footer_show_credit') ? 1 : 0); }
$save('footer.show_credit', $post('footer_show_credit') ? 1 : 0);
$curl = trim((string)$post('footer_credit_url', ''));
// 只接受 http/https:这条会被直接渲染成 <a href>,不能让 javascript:/data: 混进来
if ($curl !== '' && !preg_match('~^https?://~i', $curl)) {
return '页脚链接地址需以 http:// 或 https:// 开头。';
}
if ($curl === '' || $curl === fb_credit_url_default()) { fb_unset_setting('footer.credit_url'); }
else { fb_set_setting('footer.credit_url', $curl); }
else { $save('footer.credit_url', $curl); }
$ints = [
// 区间下限统一改 0:0 = 不限制(见 #4)。
@@ -405,11 +433,11 @@ function fb_admin_save_settings() {
if (!$has($k)) { continue; }
$v = (int)$post($k, 0);
$v = max($range[0], min($range[1], $v));
fb_set_setting($k, $v);
$save($k, $v);
}
// 图片/视频上传总开关(0=不限个数后,开关必须独立存在)
if ($has('upload_image_on')) { fb_set_setting('upload.image_on', (int)(bool)$post('upload.image_on')); }
if ($has('upload_video_on')) { fb_set_setting('upload.video_on', (int)(bool)$post('upload.video_on')); }
$save('upload.image_on', (int)(bool)$post('upload.image_on'));
$save('upload.video_on', (int)(bool)$post('upload.video_on'));
// MB → 字节
// 下限改 0 = 不限制(见 #4)
@@ -422,14 +450,14 @@ function fb_admin_save_settings() {
if (!$has($k)) { continue; } // 没提交就不动(防跨 tab 清空)
$mb = (float)$post($k, 0);
$mb = max($range[0], min($range[1], $mb));
fb_set_setting($k, (int)round($mb * 1048576));
$save($k, (int)round($mb * 1048576));
}
$storage = (string)$post('attachment.storage', 'off');
if (!in_array($storage, ['off', 'local', 'oss'], true)) { $storage = 'off'; }
fb_set_setting('attachment.storage', $storage);
$save('attachment.storage', $storage);
// OSS 模式下 oss.enabled 必须跟着开;其它模式关掉,避免误判
fb_set_setting('oss.enabled', $storage === 'oss' ? 1 : 0);
$save('oss.enabled', $storage === 'oss' ? 1 : 0);
$texts = ['oss.endpoint', 'oss.bucket', 'oss.instance_role', 'oss.domain', 'oss.prefix', 'moderation.close_mail_subject'];
foreach ($texts as $k) {
@@ -446,23 +474,23 @@ function fb_admin_save_settings() {
// RAM 角色留空 = 让程序自己去元数据服务探测,不写死一个空值
if ($k === 'oss.instance_role' && $v === '') {
list($rOk, $rVal) = fb_oss_detect_instance_role();
if ($rOk) { fb_set_setting($k, (string)$rVal); }
if ($rOk) { $save($k, (string)$rVal); }
continue; // 探测不到就保持原值不动
}
fb_set_setting($k, $v);
$save($k, $v);
}
$src = (string)$post('oss.credential_source', 'instance_role');
fb_set_setting('oss.credential_source', $src === 'static' ? 'static' : 'instance_role');
fb_set_setting('oss.access_key', trim((string)$post('oss.access_key', '')));
$save('oss.credential_source', $src === 'static' ? 'static' : 'instance_role');
$save('oss.access_key', trim((string)$post('oss.access_key', '')));
$sk = trim((string)$post('oss.secret_key', ''));
if ($sk !== '') { fb_set_setting('oss.secret_key', $sk); }
if ($sk !== '') { $save('oss.secret_key', $sk); }
fb_set_setting('oss.visibility', ((string)$post('oss.visibility', 'public')) === 'private' ? 'private' : 'public');
fb_set_setting('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https');
fb_set_setting('oss.verify', $post('oss.verify') ? 1 : 0);
fb_set_setting('oss.image_process', $post('oss.image_process') ? 1 : 0);
fb_set_setting('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
$save('oss.visibility', ((string)$post('oss.visibility', 'public')) === 'private' ? 'private' : 'public');
$save('oss.url_scheme', ((string)$post('oss.url_scheme', 'https')) === 'http' ? 'http' : 'https');
$save('oss.verify', $post('oss.verify') ? 1 : 0);
$save('oss.image_process', $post('oss.image_process') ? 1 : 0);
$save('oss.use_internal_endpoint', $post('oss.use_internal_endpoint') ? 1 : 0);
} // ← end if ($isSiteForm)
@@ -483,11 +511,11 @@ function fb_admin_save_settings() {
$smtpText = ['smtp.host', 'smtp.user', 'smtp.from', 'smtp.from_name'];
foreach ($smtpText as $k) {
if (!$has($k)) { continue; } // 没提交就不动它
fb_set_setting($k, trim((string)$post($k, '')));
$save($k, trim((string)$post($k, '')));
}
// 口令留空 = 不修改(避免每次保存都得重填)
$sp = (string)(isset($_POST['smtp_pass']) ? $_POST['smtp_pass'] : '');
if (trim($sp) !== '') { fb_set_setting('smtp.pass', $sp); }
if (trim($sp) !== '') { $save('smtp.pass', $sp); }
// 端口留空 = 用默认 465。删键回落,不要存成 0(0 端口根本连不上)
$portRaw = trim((string)$post('smtp.port', ''));
@@ -496,31 +524,31 @@ function fb_admin_save_settings() {
} else {
$smtpPort = (int)$portRaw;
if ($smtpPort <= 0 || $smtpPort > 65535) { $smtpPort = 465; }
fb_set_setting('smtp.port', $smtpPort);
$save('smtp.port', $smtpPort);
}
$sec = strtolower(trim((string)$post('smtp.secure', 'ssl')));
if (!in_array($sec, ['ssl', 'tls', ''], true)) { $sec = 'ssl'; }
fb_set_setting('smtp.secure', $sec);
$save('smtp.secure', $sec);
// 超时留空 = 用默认 15
$tmoRaw = trim((string)$post('smtp.timeout', ''));
if ($tmoRaw === '') {
fb_unset_setting('smtp.timeout');
} else {
fb_set_setting('smtp.timeout', max(5, min(60, (int)$tmoRaw)));
$save('smtp.timeout', max(5, min(60, (int)$tmoRaw)));
}
fb_set_setting('smtp.verify_ssl', (int)(bool)$post('smtp.verify_ssl'));
$save('smtp.verify_ssl', (int)(bool)$post('smtp.verify_ssl'));
// 没填 from 时用 user 兜底(绝大多数 SMTP 要求两者一致)
if (trim((string)fb_cfg('smtp.from', '')) === '') {
fb_set_setting('smtp.from', trim((string)fb_cfg('smtp.user', '')));
if ($has('smtp.from') && trim((string)$post('smtp.from', '')) === '') {
$save('smtp.from', trim((string)fb_cfg('smtp.user', '')));
}
// 只有 host 填了才允许开启,否则开着一个空配置只会每次发信都失败
$wantOn = (bool)$post('smtp.enabled');
$hostNow = trim((string)fb_cfg('smtp.host', ''));
fb_set_setting('smtp.enabled', ($wantOn && $hostNow !== '') ? 1 : 0);
$save('smtp.enabled', ($wantOn && $hostNow !== '') ? 1 : 0);
if ($wantOn && $hostNow === '') {
$msg = '邮件通知未开启:请先填写 SMTP 服务器地址。';
$msgType = 'error';
@@ -531,9 +559,9 @@ function fb_admin_save_settings() {
* 所以只有本页确实提交了邮件表单时才写,否则在站点设置页保存一次
* 就会把「有人回复时通知」这些开关全部关掉。
*/
if ($has('notify_enabled')) { fb_set_setting('notify.enabled', (int)(bool)$post('notify.enabled')); }
if ($has('notify_on_every_reply')) { fb_set_setting('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply')); }
if ($has('notify_admin_new_thread')) { fb_set_setting('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread')); }
$save('notify.enabled', (int)(bool)$post('notify.enabled'));
$save('notify.on_every_reply', (int)(bool)$post('notify.on_every_reply'));
$save('notify.admin_new_thread', (int)(bool)$post('notify.admin_new_thread'));
// 冷却秒数留空 = 用默认 300(config 里的出厂值)。注意 0 是合法值(表示不限制),
// 所以要用「空串」而不是「0」来区分"没填"和"填了 0"
@@ -542,7 +570,7 @@ function fb_admin_save_settings() {
if ($cdRaw === '') {
fb_unset_setting('notify.cooldown_seconds');
} else {
fb_set_setting('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
$save('notify.cooldown_seconds', max(0, min(86400, (int)$cdRaw)));
}
}
@@ -550,7 +578,7 @@ function fb_admin_save_settings() {
// 审核开关属于「站点设置」,只在站点设置页提交时才写
if ($has('moderation_enabled')) {
fb_set_setting('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
$save('moderation.enabled', $post('moderation.enabled') ? 1 : 0);
}
fb_mod_log('settings', null, '保存站点设置:存储=' . $storage);