根治:配置被跨页面保存清空(第三次复发,改为强制守卫) (#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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user