附件限制全部支持填 0 = 不限制 (#4)

用户要求:附件存储里所有限制项(几张图、几个视频、大小、总量、保留天数、
每小时次数)都能填 0,0 表示没有任何限制。

## 最危险的地方:填 0 原本是「全禁」不是「不限」

两处逻辑把 0 判成了禁止,语义正好相反:

1. `fb_rate_check()` —— `count < limit`,limit=0 时 `0 < 0` 为假
   ⇒ 每小时上传次数填 0 = 一次都不许传
2. 每帖数量校验 —— `count > max`,max=0 时传一个就超限
   ⇒ 每帖图片填 0 = 一张都不许传

都改成「limit <= 0 直接放行」。

## 校验区间与 input 下限全部放开到 0

| 字段 | 原下限 | 现下限 |
|---|---|---|
| image_max_per_post | 0(但界面上限 20) | 0,上限 1000 |
| video_max_per_post | 0(上限 10) | 0,上限 1000 |
| image_max_bytes | 0.2 MB | 0,上限 1024 MB |
| video_max_bytes | 1 MB | 0,上限 10240 MB |
| total_cap_bytes | 10 MB | 0,上限 1048576 MB |
| retention_days | 1 天 | 0,上限 36500 |
| upload_per_hour | 1 次 | 0,上限 100000 |
| image_max_side | 480 | **保持 480** —— 这是压缩目标边长,不是限制项 |

服务端 `max($range[0], ...)` 不再把 0 拉回下限;input 的 min 一并改 0,
否则浏览器直接拦住连提交都到不了后端。

## 语义冲突:0=不限与「0=关闭该类上传」冲突

原先 `fb_upload_kind_on()` 靠「数量上限是否为 0」判断开关,
0 现在表示不限个数,两者冲突。

按用户选择:**新增独立总开关** `upload.image_on` / `upload.video_on`(默认开),
`fb_upload_kind_on()` 改看开关,不再看数量。
两个新键已加入 `fb_setting_keys()` 白名单(这个坑踩过多次)。

## 前端同步

- `maxCount > 0` 才拦,0 不拦
- 提示文案:数量 0 显示「数量不限」、大小 0 显示「大小不限」,
  不再出现「最多 0 张」这种荒唐文案

## 风险提示(按用户选择用文字告知,不阻止保存)

单张图片/单个视频/全站总量填 0 时,字段下方提示「填 0 时注意:磁盘可能被写满」。

## 验证
0 专项 31/31,关键几条:
- 七项输入框 min 均为 0,image_max_side 保持 480
- 填 0 后数据库里就是 0,没被拉回下限
- **填 0 后行为是放行**:每小时放行、数量不限、总量放行、不清理
- 数量填 0 时上传仍是开的(靠独立开关)
- 取消勾选开关后确实关闭,且与数量填 0 互不干扰
- 非 0 值全部正确(4 张 / 2 个 / 5MB / 50MB / 1024MB / 30 天 / 60 次)

回归全绿:全新安装 33/33、附件 22/22、老基线 25/25、安装向导 60/60、约束检查 20/20。
This commit is contained in:
2026-11-02 08:55:23 +08:00
parent b0e6ac9cd8
commit 74beaf1163
5 changed files with 82 additions and 25 deletions
+14 -4
View File
@@ -201,10 +201,17 @@ function fb_attach_delete($key) {
return fb_oss_delete($key);
}
/** 该类型是否可用(数量上限为 0 或未配置则视为关闭) */
/**
* 该类型是否可用。
*
* ⚠️ 原先靠「数量上限是否为 0」判断开关 —— 但 0 现在表示「不限个数」,
* 两者语义冲突(一个说"关掉"、一个说"不限制")。改为看独立开关:
* upload.image_on / upload.video_on,默认都开。
*/
function fb_upload_kind_on($kind) {
if (!fb_store_enabled()) { return false; }
return (int)fb_upload_cfg($kind . '_max_per_post', 0) > 0;
$key = $kind . '_on';
return (bool)fb_upload_cfg($key, true);
}
/** 归一化扩展名 */
@@ -313,9 +320,12 @@ function fb_attach_parse($raw) {
}
$counts[$kind]++;
if ($counts[$kind] > (int)fb_upload_cfg($kind . '_max_per_post', 0)) {
// 0 = 不限个数。注意别写成 `count > max`,那样 max=0 时传一个就超限,
// 等于「填 0 反而一个都不让传」,与用户意图相反。
$maxPerPost = (int)fb_upload_cfg($kind . '_max_per_post', 0);
if ($maxPerPost > 0 && $counts[$kind] > $maxPerPost) {
return [false, [], ($kind === 'image' ? '图片最多上传 ' : '视频最多上传 ')
. (int)fb_upload_cfg($kind . '_max_per_post', 0) . ' 个。'];
. $maxPerPost . ' 个。'];
}
$seen[$key] = true;
+6 -3
View File
@@ -139,9 +139,12 @@ function fb_uploaders(array $existing = []) {
$maxB = (int)fb_upload_cfg($kind . '_max_bytes', 0);
$accept = implode(',', (array)fb_upload_cfg($kind . '_mimes', []));
$label = $isImg ? '图片' : '视频';
$desc = $isImg
? ('最多 ' . $maxN . ' 张,单张不超过 ' . fb_size_h($maxB) . ';大图会自动压缩后上传')
: ('最多 ' . $maxN . ' 个,单个不超过 ' . fb_size_h($maxB));
// 0 表示不限制,文案别写成「最多 0 张」
$cntTxt = $maxN > 0 ? ('最多 ' . $maxN . ($isImg ? ' 张' : ' 个')) : '数量不限';
$sizeTxt = $maxB > 0
? ('单' . ($isImg ? '张' : '个') . '不超过 ' . fb_size_h($maxB))
: '大小不限';
$desc = $cntTxt . ',' . $sizeTxt . ($isImg ? ';大图会自动压缩后上传' : '');
?>
<div class="uploader" data-uploader data-kind="<?php echo e($kind); ?>"
data-max-count="<?php echo $maxN; ?>"
+12 -1
View File
@@ -279,12 +279,22 @@ function fb_csrf_ok($t) {
}
/** 频率限制 */
/**
* 频率检查。
* @param int $limit 每小时的允许次数;**0 或负数表示不限制**
*
* ⚠️ 曾经写成 `count < limit` —— limit=0 时 `0 < 0` 为假,
* 用户把「每小时上传次数」设成 0(本意是不限制)会变成「一次都不许传」。
* 语义正好相反,属于"填 0 反而全禁掉"的陷阱。
*/
function fb_rate_check($action, $limit) {
$limit = (int)$limit;
if ($limit <= 0) { return true; } // 0 = 不限制,直接放行
$pdo = fb_db();
$since = time() - 3600;
$st = $pdo->prepare('SELECT COUNT(*) FROM rate_limit WHERE ip_hash=? AND action=? AND created_at>=?');
$st->execute([fb_ip_hash(), $action, $since]);
return ((int)$st->fetchColumn()) < (int)$limit;
return ((int)$st->fetchColumn()) < $limit;
}
function fb_rate_hit($action) {
$pdo = fb_db();
@@ -382,6 +392,7 @@ function fb_setting_keys() {
'footer.show_credit', 'footer.credit_url',
'installed_at',
'attachment.storage',
'upload.image_on', 'upload.video_on',
'upload.image_max_per_post', 'upload.image_max_bytes',
'upload.video_max_per_post', 'upload.video_max_bytes',
'upload.total_cap_bytes', 'upload.retention_days',