Commit Graph
53 Commits
Author SHA1 Message Date
laoyang 27614d8ca8 统一全部表单控件尺寸与外观;删除下载地址字段
用户:「输入框、按钮、下拉框特别丑,一点设计感都没有,高度太随意,
      全面检查一下。还有那个下载链接要它干什么?」

一、表单控件(这是主要工作量)
  原来样式散在 6 处(.field / .filter-row / .filters / .mail-test / .admin 各一套),
  padding 有 8/9/12px 三种,select 与 input 高度不等,并排放参差不齐。

  改成一套体系:
  - CSS 变量统一尺度:--ctl-h 38px / --ctl-h-sm 32px / --ctl-h-lg 42px,
    统一圆角 9px、统一左右内边距 12px、统一边框与阴影
  - 高度用「固定 height + line-height 居中」而不是 padding 撑
  - 🔴 选择器必须**宽松**(按 type 通吃,不用 .field 之类限定)。
     第一版限制了容器,导致后台零散布局里的裸 select/input 只有 19/21px,
     和旁边 38px 按钮并排,正是用户说的高度随意。
     放开后实测:19px → 38px,全部一致
  - 下拉框自绘箭头(内联 SVG),不再用各平台不一的原生箭头,右侧留 34px
  - 搜索框加左侧放大镜图标,并去掉 Chrome 自带清除按钮
  - 聚焦统一 brand 光晕,悬停边框加深
  - 按钮也改固定 height(原来 padding 撑出 38.3px,与输入框差 0.3px)
  - .btn.sm 必须显式改 height,否则 padding 被 height 吃掉、小按钮还是 38px

  文件选择按钮:原生控件重做成真按钮
    <label class=file-btn> 包住 input[type=file]
    input 用 clip 视觉隐藏(不能用 display:none,那样点不到也聚焦不了)
    按钮内显示「选择图片」+ 图标 + 选中后的文件名

二、删除「下载地址」字段
  它唯一用途:状态改为「已解决」时把邮件按钮换成「下载最新版本」。
  收益小、维护成本高(每个软件都要填一条 URL)。
  - 后台表单项删除
  - mailer 里对应的判断分支也删掉,按钮统一「查看详情」
  - ⚠️ 数据库 download_url 列**保留不删**:删列不可逆,万一要恢复还得迁一次

验证(用控件总览测试页 + 真实页面实测):
  10 类控件高度全部 38px(文本/数字/下拉/搜索/文件按钮/主按钮/小按钮…),
  文本域 110px,同一行垂直中心完全一致(935.3)
  裸 select/input 从 19/21px 修正到 38px
  下拉箭头 SVG 生效、右内边距 34px;搜索框放大镜生效、左内边距 36px
  真实三个页面(提交页/列表页/详情页)逐一检查:
    没有 checkbox/radio/file 被误改,所有可见控件高度收敛为 38(文本域 110)
  下载地址字段:admin.php 输出与源码中均无 download_url 表单项
  5 个路由 200
2026-11-04 19:17:20 +08:00
laoyang a2a3306169 修色板被裁切 + 让人看得出能选全色域
用户两个反馈:
① 色板直接被挡住了一半
② 它不是完全的颜色,好像只有蓝色

① 根因:色板用 position:absolute,而它在
   <details class="tax-form-box"> 里面,那个容器有 overflow:hidden。
   色相条正好在面板最底部 —— 被裁掉了,所以看不见。
   改 position:fixed,直接相对视口定位,任何祖先 overflow 都管不到。
   位置改由 JS 按触发按钮坐标算,并处理三种情况:
     右边超出→左移;下方不够→翻上方;都放不下→贴视口底边
   另加 resize/scroll 监听,避免滚动后色板漂走。

② 不是 bug,是 ① 的连带后果:
   HSV 色板本来就只显示当前色相的深浅变化,换颜色靠色相条。
   色相条被裁掉后,用户只看到一片蓝,自然以为只有蓝色。
   修好裁切后已实测 6 个色相各不相同:
     0°红 60°黄 120°绿 180°青 240°蓝 300°品红
   同时把色相条从 14px 加高到 18px 并加内描边,
   让它一眼就能认出是光谱条,而不是一条灰线。

验证(用真实后台容器结构复刻测试页,details 带 overflow:hidden):
  面板四边 elementFromPoint 都能命中自己 → 未被任何容器裁切
  色相条可见、高 18px;面板总高 367px
  滚到页面底部再开面板仍完整可见
  6 个色相取样互不相同,色板能覆盖全色域
  实际取色 #0ce779 正常
  4 个路由 200
2026-11-04 19:01:04 +08:00
laoyang 338a35cacd 主题色改为完整色板:画布 342x200,加明度条,删除右侧预设圆点
用户:「还是不对呀,需要一个完整的色板,右侧那些默认颜色就不要了。」

两个改动:
1. 删掉右侧那排 12 个预设圆点($presets 变量、CSS、JS 同步逻辑一并删)。
   色板本身能取任意颜色,再摆一排样例是重复,还挤占横向空间。
2. 色板做成「完整」的:
   面板 248×246 → 372×357
   画布 226×150 → 342×200(太小拖着不好定位颜色)
   新增明度条,底色随当前色相实时刷新 ——
   否则永远是灰阶,用户看不出拖到那里会得到什么颜色
   面板加标题「选择颜色」,按钮文案「确定」→「完成」

顺带简化光标定位:
原来 cursor 挂在 .cp 里,要靠两个 getBoundingClientRect 相减算偏移;
现在改挂到 .cp-sv-wrap(与 canvas 同级),坐标直接相对 canvas 算,
少一层偏移、少一个坑。

验证(浏览器实测):
  .color-preset 残留 0 个、.color-presets 容器 0 个
  面板 372×357、画布 342×200,完整在视口内
  色相 200° → 明度条背景 linear-gradient(#000, rgb(0,170,255)),确实跟着变
  画布 80%/20% 处取色 → #2996cc,光标落在画布范围内
  明度拖到 50% → #1a5e80(同色相压暗,符合预期)
  手输 #2E7D5B → 隐藏字段 #2e7d5b,点完成关闭
  4 个路由 200
2026-11-04 18:57:18 +08:00
laoyang ad1b0eedf4 主题色改为自绘取色面板(任意取色);图标上传加即时预览与状态提示
一、颜色不够丰富 → 点击弹取色面板
  用户:「还是不行,颜色还是不够丰富。让用户选择吧:点击之后弹出一个颜色选择器。」
  原来只有一个原生 <input type=color> 小方块,用户不知道能点,
  而且系统面板各浏览器样式不一、只能取纯色。
  改成自绘面板:
    色相/饱和度渐变板(Canvas)+ 明度滑块 + 十六进制输入 + 确定
    12 个预设圆点降级为快捷方式,真正的能力是任意取色
  交互:点色块弹出(下方空间不够自动翻上方)、板内拖动实时更新、
        十六进制可手输、点确定或面板外关闭、Esc 关闭

  ⚠️ 光标定位踩了两个坑,都写进注释了:
    ① 不能用 canvas.offsetLeft(相对 offsetParent,受父级 padding 影响,
       实测算出 236px 超出画布宽度 226px);要用两个 getBoundingClientRect 相减
    ② canvas 显示尺寸受 CSS width:100% 影响,≠ 内部像素 220×150,
       必须用 rect.width 而不是 canvas.width

二、图标上传没反馈
  用户:「上传的图标没有显示,也没有上传成功、失败之类的提示信息。」
  后端逻辑完整(格式/大小/尺寸/存储模式都校验了),问题在**前端零反馈**:
  裸 <input type=file>,选完文件什么都不发生,而提示条在页面顶部容易被错过。
  加了 bindIconUpload():
    ① 选中文件立刻 FileReader 本地预览,不等提交
    ② 显示文件名 + 尺寸 + 大小,让用户确认选对了
    ③ 选之前就拦掉明显不合格的(格式/超 2MB/小于 32×32/空文件)
    ④ 非正方形给出警告(显示时会被裁切)
  ⚠️ 前端校验只是体验优化,绝不能当安全边界 ——
     真正的校验仍在 PHP 的 fb_icon_store_upload() 里。

验证(用独立测试页 + 浏览器实测,不登录后台、不动数据):
  面板初始隐藏 → 点击后 248×246 出现
  Canvas 四角像素分别为灰白/蓝/黑 → 渐变确实画出来了
  板上取色 → 隐藏字段 #2178d9 同步到按钮文字/十六进制框/预览色
  点预设 → #2B822E;手输 → #a020f0;色相滑条 120° → #20f020
  光标中心 (238,54) 落在画布区域 (11..237, 11..161) 内
  4 个路由 200
2026-11-04 18:52:45 +08:00
laoyang a00e2221c4 调色板从 8 色扩到 12 色(按色相环重排);后台预设改为共用同一份
用户:「不同的软件要有不同的颜色,现在颜色有点少,快不够分了,
      至少应该有 10 个左右的颜色。」

调研发现的问题比「数量少」更严重:
🔴 调色板有两份独立硬编码 —— util.php 一份、admin_taxonomy.php 又一份,
   两边的色值还不一样,早就不同步了。用户在后台只能从 8 个里选,
   而实际分配用的却是另一批色值。

改法:
- 新增 fb_color_palette() 作为唯一出口,12 色
- fb_color_default_for() 改为调用它
- 后台  也改为调用它 —— 从此只有一处需要维护

12 色设计(360° 切 12 段,逐色验证,不是随手挑的):
  色相:2/32/62/92/122/152/178/200/232/266/293/330,**最小相邻间距 22°**
  对比度:白底全部落在 4.76~6.19,既都够看清(WCAG AA 需 ≥4.5),
          又不会某个颜色特别深显得突兀
  压制项:黄/黄绿饱和 50%(再高就荧光)、蓝/靛蓝压到 62%/50%
  避开项:纯红纯绿会与「待处理/已解决」状态色撞,所以红取 2°、绿取 122°

中途踩过的坑(记录下来避免重犯):
  第一版直接往原 8 色后面追加 6 个,实测发现 8 对颜色几乎同色 ——
  因为原调色板里 185°/212°/217° 三个蓝本就挤在一起。
  「塞进缝隙」这个思路是错的,必须重排整个色相环。

现有 5 个软件颜色**未改动**(实测仍是 #0057b8/#b4553c/#6a4c93/#0e7c86/#3a5a8c):
它们的色相分布本就合理(2°12°185°212°265°),没必要让已发布链接的配色突变。
新调色板只影响「新添加且未指定颜色」的软件。

验证:12 色无重复、对比度全 ≥4.5、最小色相间距 22°、
后台已改为调用 fb_color_palette() 且旧硬编码数组已消失、6 个路由 200。
2026-11-04 17:54:39 +08:00
laoyang 26d00ea981 后台顶栏去掉「我要反馈」「三横线选软件」「问题列表」
用户问:「管理后台还需要我要反馈这个按钮吗?还需要右上角那三个横线的设置按钮吗?
还需要拉下来后选择不同的软件吗?这些是不是都不需要了呀?」

确认都不需要,而且原来确实都在:
后台调 fb_layout_head() 时没传 software,走「无软件」分支,
结果顶栏照样渲染了问题列表 / 我要反馈 / 三横线,三横线里还列着
「轻反馈、同步日历、密码生成器、反馈测试」四个软件 —— 在后台点它毫无意义。

理由:
① 「我要反馈」是给访客的入口,管理员不该从后台跳去前台表单;
② 后台是跨软件视图(待审核/全部问题本身就带软件筛选器),
   再放切软件菜单是重复的,切了也不会改变后台视图;
③ 「问题列表」依赖 ?sw= 当前软件上下文,后台没有这个上下文,
   点了只会跳到前台某个软件,属于误导。

改法:fb_layout_head() 新增 [admin] 开关,admin.php 两处
(管理登录、管理后台主界面)都传 admin=true。
后台顶栏只留:站点标识(回首页)+「去前台看看」。
唯一的后台出口是「去前台看看」,管理员偶尔需要核对前台实际效果。

验证:
  后台顶栏链接只剩 brand-mini(href=./) 和 navlink(href=index.php, 去前台看看);
  sw-toggle / sw-panel 关键字均已不存在。
  前台顶栏三样仍在(问题列表 / 我要反馈 / 三横线),未被波及。
  6 个路由全 200。
2026-11-03 21:11:51 +08:00
laoyang 974dd52367 去掉软件标签的描边;修「全部问题」列表分类标签渲染成空
两个问题:

一、两个标签中间那道「线」
  不是 CSS 分隔符 —— 是 .badge.sw(软件徽章)带 border:1px solid var(--swc-line),
  而 .badge.cat / .badge.st 都没有边框。并排显示时那道描边看起来
  就像凭空插了一条分隔线。去掉边框,三个标签统一为无边框胶囊。

二、反馈类型(Bug / 功能建议)在后台「全部问题」列表里不显示
  🔴 根因:fb_category_label() 参数是**数字 id**,内部会 (int) 化。
     而全部问题列表传的是 $t["category"](老 slug 字段,值为 bug),
     (int)bug === 0 → 函数返回空串 → 渲染出一个**空的** badge:
     标签框在、里面什么都没有,看起来就像「类型没了」。
  修法:
    - 新增 fb_category_label_by_slug($softwareId, $slug) 给老数据兜底
      (slug 在不同软件下可能重名,必须带 software_id)
    - 全部问题列表改用 category_id 优先、slug 兜底
    - 前台列表同样处理,并且**取不到名字就不渲染这个标签**,
      绝不再输出空胶囊
    - 在 fb_category_label() 的注释里写明「必须传数字 id」,
      避免下次再踩

验证:
  函数对照跑通:#4 category_id=6 → Bug;#5 category_id=7 → 功能建议;
  旧写法 fb_category_label("bug") → (空),确认这就是根因。
  浏览器实测详情页三个标签 border-top-width 全部 0px / style none,
  圆角都是 999px,描边已消除。
  前台列表两个条目分类标签分别渲染出 Bug / 功能建议,不再是空。
  7 个路由全 200。
2026-11-03 20:50:00 +08:00
laoyang dcf510ef28 后台三处列表/详情:标题顶头独占一行,标签下移(与前台统一)
用户:「管理后台的待审核和全部问题那个列表里面也是一样的。
      标题应该顶头,然后在标题下面另起一行,写上来自于哪一个软件、
      它的状态是什么、它是什么分类。点到一个具体的反馈里面也是一样的,
      标题顶头,然后把同步日历、bug、待处理往下一行。管理后台也是全部按照这种方式去处理。」

改三处:
1. fb_admin_pending_row(待审核列表)
   prow-top(标签在前+标题)→ prow-head(标题独占一行)+ prow-tags(软件/分类/状态)
2. 全部问题列表
   item-top → item-head + item-tags
   🔴 同时补上软件名(fb_software_chip)—— 后台是跨软件视图,
      前台某个软件内部不需要显示软件名(满页同一个),后台必须显示。
3. 后台详情页头部
   item-top → item-head + item-tags,标题字号 19px 略大于列表

样式新增 .prow-head / .prow-tags(.prow-top 保留给操作日志,
日志条目只有「动作+编号」,单行反而合适)。

验证(真实渲染 + 浏览器测量,不登录后台、不动数据):
  待审核行  标题 top=87 left=222 / 标签 top=119 left=222 → 标签在下方、左对齐 0px 偏差
  全部问题  标题 top=318 left=223 / 标签 top=351 left=223 → 同上
  详情头    标题 top=541 left=228 / 标签 top=575 left=228 → 同上
  另外静态检查:三处的 head 段内均无状态标签,tags 段内均含软件/分类/状态。
  前台 5 个路由 200,未受影响。
2026-11-03 18:58:04 +08:00
laoyang 82f4fa3c49 顶栏图标改为与「标题+简介」整体垂直居中
用户:「图标应该在标题和它的简介的正中间…现在图标和上面的标题是对齐的,
      把图标往下放一放,让标题和它下面的简介合起来和图标高度一样,
      两个对齐上下。」

根因是结构问题,不是调 padding 能解决的:
以前图标和标题被 fb_software_badge() 包在同一个 .sw-badge 里(单行结构),
note 挂在它外面下面一层 ⇒ 图标天然只能对齐第一行文字,永远做不到两行居中。

改结构为「左图标 + 右文字列」两栏:
  <a class="brand-mini">
    <span class="brand-ic">图标</span>        ← flex:none 30×30
    <span class="brand-txt">                  ← flex column
      <span class="brand-title">标题</span>
      <span class="brand-mini-note">简介</span>
    </span>
  </a>
外层 .brand-mini 用 align-items:center ⇒ 图标中线对齐文字列中线。

图标不再走 fb_software_badge(),改用 fb_software_icon() / icon_key 自己拼,
因为 badge 那个函数是把图标和名字绑死在一行的。

实测中线偏差:
  桌面 1280:图标 18→48(中线 33.0),文字列 13.8→52.2(中线 33.0)→ 偏差 0.0px
  手机 390:图标 17→43(中线 30.0),文字列 12.3→47.7(中线 30.0)→ 偏差 0.0px
首页(无软件时)仍是原来的 logo-mark + brand-text,未受影响;6 个路由全 200。
2026-11-03 15:21:41 +08:00
laoyang 0ebf250c76 去掉内容区与顶栏重复的标题行和我要反馈按钮;简介移到顶栏标题下灰色小字
用户:上面已有标题和「我要反馈」,下面又出现一次,完全重复。
「下面这一行不要了…把那个软件简介放在最上面『同步日历反馈系统』下面那个位置,
  用个灰色的。我要反馈按钮也不要了,其他内容开始往前提,更简洁。」

改法:
- index.php 删掉整个 page-head 行(软件图标+名字 / 简介 / 我要反馈大按钮)
  → 列表内容直接从筛选器开始,上提一整行(实测内容起始 y=109)
- 顶栏品牌位改成两行:
    行1 图标 + 「XX 反馈系统」(软件主题色 17px)
    行2 一句话简介(software.note,灰色 #6b7280,12.5px,移动端 11.5px)
- 简介左对齐到标题文字(padding-left 41px = 图标 32 + gap 9),不是贴图标左边
- 顶栏高度从固定 66px 改为 min-height + padding,两行也放得下(实测 67px)

⚠️ 「我要反馈」按钮去掉了 hide-sm:
   列表页那个大按钮删掉后,顶栏这个是全站唯一入口,
   如果手机上隐藏掉,手机端就没有提交入口了。

清理 .page-sw-id 死样式。
验证:桌面/手机下简介都在标题下方、颜色灰色、内容区无重复、我要反馈只 1 个且两宽度都可见;
8 个路由全部 200;详情页顶栏同样显示简介。
2026-11-03 15:05:37 +08:00
laoyang c09a9875a3 标题收到最上顶头,标签下移一行;顶栏标题回当前软件;去掉跨软件引导
用户三个问题:

一、软件名/分类/状态三个标签挤在标题前面,把标题推到右边很难看
  列表页:item-top 里 badges 在前、标题在后 → 标题被挤到右边
  详情页:同一个毛病
  改法(两处一致):
    行1 标题(顶头、独占一行)
    行2 标签(分类/状态;详情页还有软件名)
    行3 摘要 / 作者信息
  列表页新增 .item-head + .item-tags;详情页新增 .subject-head + .subject-tags。
  ⚠️ .item-top 保留 —— admin.php 有三处内部列表还在用,删了后台会塌。

二、顶栏「XX 反馈系统」点完跳首页
  用户:「点完之后应该还是进入这个反馈系统,而不是进到首页。首页没有太大作用,
        只是演示说明用的。正常使用都是每个软件独立,要让用户觉得
        这套反馈系统就是专门为这个软件做的。」
  改法:brand-mini 的 href 从 "./" 改为 fb_software_list_url()。

三、软件内部不该引导去其他软件
  用户:「在一个反馈系统里面,我要反馈、问题列表、左上角那个链接,
        全部都是这个软件相关的内容,跟其他软件没关系。」
  - 提交成功页删掉整个「也可以去其他软件反馈」区块及其 CSS
  - 删掉成功页的「返回首页」按钮(已有「看该软件其他反馈」,重复且无意义)
  - 跨软件只剩顶栏三横线菜单一个入口

验证:列表页/详情页 DOM 顺序均为 标题→标签→作者(实测 item-title 58 < item-tags 171;
subject-title 49 < subject-tags 151 < item-meta 592);
浏览器实测标题 top=282 标签 top=315(列表)、155→194(详情),标签确实在下方;
标题左边缘距卡片边 18/23px,就是卡片自身 padding,没有额外缩进;
顶栏标题 href=index.php?sw=synccalendar,不再指向根路径;全站 8 个路由 200。
2026-11-03 13:43:15 +08:00
laoyang bec5b09bd6 提交成功后改为原地显示回执页(不再跳转);修提交页右侧缺块
用户两个问题:

一、「我要反馈」页面比正常页面窄很多、右侧缺一块
  根因:.narrow 只有 max-width:640px,**没有居中**(缺 margin auto)。
  在 880px 的内容区里左对齐,右侧空出 240px 白,看起来像页面被抠掉一块。
  改法:max-width 放到 720px + margin-left/right auto 居中。
  实测:容器 880px,表单 720px,左右间隙各 80px,完全对称。

二、提交完直接跳转太突兀
  用户:「应该给它一个提示,说反馈我们已经收到…里面有一个标志码,
        是你以后跟踪它的唯一凭据…下面再列出其他功能(返回首页、继续提交、
        其他软件链接、开源链接),不能直接跳转,这给人感觉很突兀。」
  改法:不再 302 跳转,改为原地渲染成功页 fb_render_submitted():
    ✓ 您的反馈我们已经收到
    → 详细信息已发送到 xxx@xxx(并提示看垃圾邮件)
    → 醒目的标志码区块(等宽字 + 虚线框 + 可整体选中)
      并说明:凭它可查看进度,未通过审核/已关闭也能看;每封邮件也会带
    → 勿外传提醒(等同于身份凭证)
    → 四个去处:查看这条反馈 / 继续提交 / 看该软件其他反馈 / 返回首页
    → 其他软件入口(排除当前软件,避免重复)
    → 开源仓库链接
  为什么要把 token 当场亮出来而不只是说已发到邮箱:
    邮件可能进垃圾箱或被误删,码亮出来用户至少能复制存档;
    这是唯一凭据,丢了找不回来(我们不做登录,不认人)。

验证(只在 feedlitetest 软件里做,不碰其他正式软件):
  真实提交一条 → 返回 200 且 URL 仍在 new.php(没跳走);
  成功页含回执/邮箱/唯一凭据/勿外传/四个按钮/其他软件/开源链接;
  提取到 32 位 hex 标志码,带它访问详情页 200,不带则 404。
  测试数据已清理,其他软件数据一条未动。
2026-11-03 13:19:14 +08:00
laoyang 0dc8033c6b 本人回复自己的反馈不发通知信
用户:「他自己回复他自己的反馈,不要再给他发邮件了 ——
      他自己说的,他发给他自己,不是很奇怪吗?但如果是别人回复他的,就可以发。」

原逻辑在 mailer 里有个「自己通知自己」判断,但写成了:
  !empty($extra["is_admin"]) === false
双重否定绕,读起来要停一下才知道在判什么,而且没有覆盖空格差异。

改法:
- 改成直接判 $isAdminNotice,条件意图一眼可见
- 邮箱比较前 trim + strcasecmp,覆盖 大小写不同 / 前后空格 两种写法
- 拦下时写 notify_log 并给出明确原因「本人回复自己的反馈,不发信」,
  以后排查「为什么没收到信」能直接看到

验证(5 种情形 + 完整 DB 路径):
  同邮箱 → 拦下;大小写不同 → 拦下;带空格 → 拦下;
  别人邮箱 → 正常发;管理员邮箱 → 正常通知提交人。
  另在库里真插一条本人回复走 fb_notify_event,同样被拦下。

关于「带 token 能否修改原反馈」:确认后决定不做。
理由:反馈一旦公开,别人已看过/回复过,改了会让讨论对不上号;
补充信息用回复完全够用。现状只能看和回复。
2026-11-03 10:48:07 +08:00
laoyang 8b3e49e091 邮件:带私人链接时统一加「这个链接只有您能打开,请不要转发」提醒
用户要求:每次发这种链接,都要在链接上/下给他一个提示 ——
这个链接是独有的,不要转给别人、不要让别人看到,是跟踪反馈的唯一凭证。

实现:在 fb_mail_layout() 里加 $privNoteHtml,
layout 传 personal=true 才渲染,位置在「按钮之后、纯文本链接之前」。
这样只要新增邮件是发给提交人的,标一下就自动带上,不会漏。

覆盖 7 处(全部发给提交人的):
  回复通知 / 状态变更 / 审核通过 / 转为不公开 / 关闭说明 / 提交回执 / 兜底
不覆盖 1 处(管理员通知,按钮指向后台,不是私人凭证)

提醒文案(斟酌过,讲清缘由而不只是禁止):
  标题:这个链接只有您能打开,请不要转发
  正文:它等同于您在这条反馈上的身份凭证 —— 谁拿到这个链接,
        谁就能查看和处理这条反馈。请只自己使用,不要发给别人,
        也不要发到公开的地方。如果不需要继续跟踪,删掉这封邮件即可。
  → 说清「是什么」而不是只喊「别外传」,用户才会真的照做;
    补一句退路(删掉邮件即可),避免像是免责声明。

验证:7 类邮件全部含提醒且都在页脚之前;管理员通知不含;
像素级检查确认视觉顺序为「蓝色按钮 y=719 → 黄色提醒块 y=874..982」,
即提醒在按钮下方、正文区内,不是埋在邮件底部。
2026-11-03 10:08:49 +08:00
laoyang 8763e8e17f 回复框去掉管理员口令与处理状态;带 token 时昵称/邮箱锁定为只读
用户三个问题,本质是同一件事:token 就是身份凭证,那就不该再让表单声明身份。

一、去掉管理员口令 / 处理状态
  用户:「回复这个地方,下面有个管理员口令,还有处理状态,都不要填了,
        只保留回复内容,加上人机验证就可以了」
  - 删掉表单里这两块,删掉 admin-pw/admin-status 的 CSS 与 bindAdminToggle 的 JS
  - 管理员身份只认后台登录态(fb_is_admin()),不再从公开表单收口令
  - 状态流转只在后台详情页那排按钮里做

二、带 token 时昵称/邮箱锁定为只读
  用户:「如果他带着 Token 进来之后邮箱又让他改了,我们又没办法知道谁是谁了」
  - 有 token = 已证明是提交人本人,表单不再让填昵称/邮箱,
    改为显示只读的「回复身份」块(原昵称 + 原邮箱)

三、服务端强制(关键)
  🔴 前端设成 hidden 是不够的 —— hidden 可以被改:
     谁拿到链接、改个 email 提交,这条反馈的联系人就变成他的了。
     所以服务端在 isOwnerView 时**直接丢弃 POST 里的 name/email**,
     一律以库里那条帖子的 author_name / author_email 为准。
     身份校验必须在服务端做。

验证(这次确保请求真的通过人机验证,不是被验证码挡住造成的假通过):
  题目「2 乘以 7」答 14 → 跳转到 #r3,回复确实入库;
  提交的是 黑客改名 / attacker@evil.example,
  实际入库 name=jervis email=zeussun@msn.com —— 伪造的两个字段都被丢弃。
  另外:无 token 的普通访客仍可自由填昵称邮箱;全站已无「管理员口令」残留。
  测试回复已清理,reply_count 已同步。
2026-11-03 10:02:57 +08:00
laoyang 8bcee46b8a 首页改介绍页;列表只服务当前软件;新增UUID追踪让提交人看得到未审核反馈
用户提的两个核心问题:

一、首页不该列全部问题
  场景:这套系统是给某个软件配反馈入口、把地址贴进那软件的「意见反馈」里。
  访客总是从某个软件过来的,看别的软件的问题对他没意义。
  ⇒ / 不带 sw 时渲染介绍页(特点介绍 + 「点右上角设置按钮选软件」+ 软件卡片)
     带 sw 才是该软件的问题列表,检索/筛选/分类全在本软件范围内

二、提交后未审核,用户点不进去(真实缺陷)
  邮件里给了「点击查看您刚才提交的反馈」,但 thread.php 只在 visible=1 时
  才给看,未审核的一律 404 / 只看到「等待审核」静态页 —— 链接等于白给。
  ⇒ threads 加 track_token(32位hex = 128位熵),提交时生成:
     - 邮件里所有链接一律带 ?t=<token>(不再是裸 id)
     - 带对 token:可看完整详情、可回复,哪怕未公开/已关闭
     - 没 token 或 token 错:对外一律 404,不泄露存在性
     - 公开列表仍用 id 链接(公开内容谁都能看,用于避免重复提交)

实现细节:
- threads.track_token + 唯一索引;幂等迁移,存量数据自动回填
- fb_track_new_token / fb_track_token_ok(hash_equals 防时序探测)
  / fb_thread_viewable / fb_thread_link(thread, personal)
- thread.php 权限从 fb_thread_public 改为 fb_thread_viewable
- 带 token 看自己未公开的反馈时,顶部给「私人跟踪链接」提示条
- 回复表单与回复后跳转都保留 token,否则回复完就失去访问权
- 提交完成后直接带 token 跳详情页,不再只给静态等待页
- close 类邮件补上「查看这条反馈」按钮(原来完全没链接)
- 顶栏「全部问题」改为「问题列表」且只在选中软件后显示
- 详情/提交页「返回问题列表」按所属软件返回,不再回介绍页
- new.php 无 sw 时不再默认落到第一个软件,改为让用户先选
  (默默替选会让反馈进错软件、通知发错人)

验证:首页 0 条问题卡片且未泄露任何标题、三个软件入口齐全;
未公开#4 无 token 404 / 错 token 404 / 正确 token 200 且显示完整详情
(不再卡在「等待审核」)、提示条与表单 token 都在;
已公开#2 无 token 可看;5 类邮件(回执/通过/回复/状态/关闭)链接全带 token;
两套回归各 30+ 项断言全过。
2026-11-03 09:54:12 +08:00
laoyang 2f28b1e3a9 下拉菜单改成极简小浮窗(参考家书 FamilyBook 的 popup 做法)
用户第三次指出这个按钮不对,原话:
  「你不用做那么复杂,就是参考一下 FamilyBook 里边那个设置按钮,
    非常简单的,点了之后就拉下来一个很窄的一个框,
    把所有的内容都铺在上面就可以了」

去看了 FamilyBook 的实现:顶栏那颗按钮是 bindSheet + SheetSize.FIT_CONTENT,
即「内容多高就多高」的小浮层,不是整幅面板。

改法:
- 面板从「left:0 right:0 铺满宽度 + 遮罩 + 锁滚动」
  改成「贴按钮右下、min-width 172px、内容撑高、圆角 12px 小浮窗」
- 删掉 sw-mask 遮罩、删掉 body overflow 锁定
- 面板移进 .topbar .wrap(flex 内),以它为定位参照,右对齐按钮
- 删掉菜单里「选择软件」标题(按钮本身已经说明用途)
- 条目字号降到 14px、图标 18px、内边距收紧,贴合窄框
- 「当前」标记从胶囊缩成 6px 小圆点,窄框里不占地方
- 按钮简化:去掉边框与三横线变×的动效,只留 hover 浅底

实测(CDP 真实浏览器):下拉框 172×138px,仅占视口宽 13.7%,
右边缘与按钮相差 4px;390px 与 1280px 两种宽度下各 15 项断言全过
(浮窗非整幅、无遮罩、未锁滚动、贴按钮下方、右对齐、
高度由内容决定、有圆角边框、条目纵排同x、y递增、点外部可关)。
2026-11-03 09:25:41 +08:00
laoyang 28d28ad51c 软件徽标带图标+主题色;标题用软件色;删冗余欢迎语;抽屉改竖排覆盖
用户四点要求:
1. 待审核标题前的软件名要带图标
2. 每个软件配了颜色,徽标与标题都应显示该颜色
3. 「欢迎在这里反馈使用问题、提出功能建议」这类话没意义,全部删掉
4. 右上角三横线抽屉:不要宽屏横排/窄屏竖排两套,
   统一竖排并把下面内容盖住,简单一点

改动:
- 新增 fb_software_chip():软件徽标带图标 + 该软件主题色
  (原来一律固定蓝色 #eef2f8/#2b4a74,等于白配了颜色)
  配 fb_color_soft()/fb_color_line() 由主色推导同色系浅底与描边
  替换 admin.php 两处、thread.php 一处(原来是 badge 套 badge,双重边框)
- 顶栏标题与内容区标识都用软件主题色(PHP 内联 --swc 变量)
- index.php 不再渲染 site_desc(配置项保留,后台仍可编辑)
- 抽屉改为统一竖排 absolute 浮层 + 遮罩盖住正文,删掉宽屏横排分支
  打开时锁背景滚动,点遮罩/Esc/再点按钮都能关
  .topbar 作为定位参照(sticky 即可)

顺手修:fb_hex_to_rgb 的三位简写展开前要先校验字符,
否则 zzz 这类非法值可能被误展开(本次测试用例选错才发现校验不够严)

验证:3 个软件 chip 的图标/名字/主色/浅底/描边全部正确,
无图标退回字符、XSS 转义通过、颜色工具函数 6 项通过;
CDP 真实浏览器在 390px 与 1280px 两种宽度下各 13 项断言全过
(绝对定位、在顶栏下方、铺满宽度、条目纵排同x、y递增、
层级高于正文、遮罩同步显隐、可关);
三个软件标题色分别 #b4553c / #6a4c93 / #0057b8;全部路由 200。
2026-11-03 09:20:14 +08:00
laoyang 314a1cf2a5 新增「未公开」状态;待审核改列表式,只留通过并公开
用户要求:
1. 待审核/全部问题都用列表形式
2. 待审核去掉「关闭说明」整块,后面只加一个「通过并公开」按钮
3. 标题本身就是进管理页的链接,去掉「进入管理」
4. 「未公开」应当是一种状态:提交后初始状态不是「待处理」而是「未公开」

状态流转(用户定义):
  提交 → 未公开
    ├─ 通过并公开 → 待处理 → 处理中 → 已解决 → 已关闭
    └─ 直接关闭   → 已关闭(不经过待处理)
  每次状态变更、每次有人回复,都给提问人发信

实现:
- statuses 新增 hidden「未公开」,sort=0 排最前,其余顺延
  幂等迁移:老库检测到没有 hidden 就整体后移一位再插入,
  并把 visible=0 且 status=open 的存量数据归到 hidden
- new.php 初始状态按可见性决定:未公开→hidden,公开→open
  (以前一律写 open,后台「待审核」里显示的全是「待处理」,状态反映不出事实)
- approve 动作:visible=1 且 hidden→open,并用更新后的行渲染邮件
- 详情页「通过并公开」按钮原来传原状态(未公开时通过后仍是未公开),改为固定传 open
- 待审核查询改为 status=hidden(兼容老数据 visible=0 且 open),
  不再用 visible=0 AND status<>closed —— 那个条件会把「已关闭但未公开」也收进来
- 新增 fb_admin_pending_row() 列表行渲染:标题即链接、右侧单个按钮
- 删除 fb_admin_thread_card() 及「关闭说明」表单
- 新增 .plist/.prow 列表样式与 .st-hidden 状态徽标(中性灰)
- mailer 补 hidden 状态的文案,避免落到兜底

验证(线上真实数据):6 个状态齐全、提交落 hidden、
待审核能查到且不含已公开、通过后转 open 并移出待审核、
后续 doing/resolved/closed 推进正常、6 个状态邮件均能渲染、
待审核行结构 10 项断言全过(无关闭说明/无 note/无进入管理/标题可点)。
测试数据已清理,用户原有 4 条数据完好。
2026-11-03 09:09:40 +08:00
laoyang db8c1969af 文案:全站「你」改为「您」
用户要求邮件模板统一用敬语,确认后网页也一并改。

范围:
- lib/mailer.php  61 处(全部邮件模板:回执/回复/状态/审核/关闭/不公开/新反馈)
- new.php          6 处(提交页提示、占位文字)
- thread.php       4 处(提交成功提示、回复框占位)
- lib/layout.php   1 处(页脚隐私说明)
- admin.php        5 处(测试邮件正文、关闭邮件主题模板、通知规则说明、
                          先审后显说明)

已核查无「您们」等机械替换产生的别扭表达;
邮件模板渲染后「你」零残留、「您」77 处,文案通顺。
线上四个页面可见文字均无「你」,全站路由 200。
2026-11-03 09:00:05 +08:00
laoyang 2ef119f7cb 标题字号折中;软件选择移入右上角汉堡菜单
用户反馈三点:
1. 顶栏图标太大(56px)
2. 顶栏文字反而比内容区还小 —— 主次颠倒
3. 不要再整页显示「选择软件:」,改成右上角三横线按钮,点开下拉选

字号标准(用户给的基准):
  「选择软件」标签 13.5px,比它大两号 ≈ 17px。
  → 顶栏 17px 字 / 30px 图标;内容区 15px 字 / 24px 图标。
  这样 17 > 15,层级正确。

   之前两轮都搞错了:
   - 先给顶栏 46px 图标 + 22px 字:图标过大
   - 再给 56px + 26px:更大
   - 而且内容区当时是 30px,比顶栏还大,视觉上「上面比下面小」

软件选择改造:
- 顶栏右侧新增汉堡按钮(三横线,展开变×)
- 抽屉面板放在 header 内、正文之上,默认 hidden
- 列出软件:图标 + 名字 + 当前项标记
- index.php 删除整行「选择软件:」+ chips
- new.php 表单内也删除那排 chip(同一选择不该出现两次)
- 交互:点击展开/再点收起/点外部关闭/Esc 关闭
- 宽屏抽屉横向排列,窄屏纵向

验证(CDP 驱动真实浏览器):
汉堡按钮存在、初始收起、点击展开、aria 同步、软件条目与当前标记齐全、
再点收起、点外部关闭 —— 全部 PASS;
顶栏字号 17px > 内容区 15px,页面无裸露 ?>;
全部路由 200;窄屏 15px/13px 层级仍正确。
2026-11-03 08:51:20 +08:00
laoyang 82484298e4 标题位置修正:顶栏即「XX反馈系统」,内容区只留图标+软件名
用户指出三处问题:
1. 最上面标题应显示「同步日历反馈系统」,而不是光秃秃的「同步日历」
2. 内容区那个重复的「同步日历反馈系统」不要再要了,
   那里只要「图标 + 同步日历」
3. 内容区下面多了一个裸露的 ?> 字符
4. 图标和标题文字都要再大一点

改动:
- 顶栏改为 fb_software_badge(sw, brand, fb_software_site_title(sw))
  图标 56px、文字 26px,成为页面唯一主标题
- 删除内容区的 h1.page-h1-sw(重复标题)
- 内容区改为 badge(lg):图标 40px + 文字 30px
- 清除 index.php 里多余的 ?> (上次编辑留下的残渣)
- badge 新增 brand 尺寸档与  参数,可覆盖显示文字
- 顶栏高度 72->84px 容纳更大的图标;窄屏仍有降级

验证:顶栏 title/文字正确、已无 page-h1-sw、
无裸露 ?>、内容区只显示「同步日历」、5 处图标全走 media.php
且均为有效 PNG、三个软件页与全部路由 200。
2026-11-03 08:34:03 +08:00
laoyang 669ab9e43e 修复图标显示不出来;标题改XX反馈系统;图标去掉背景色
一、图标显示不出来(根因)
用户上传图标时用的是修复前的代码,数据库里 icon_key 存的是
icons/2026/11/xxx.png —— 没有 local/ 也没有 oss.prefix 前缀。
上一轮我把键校验收紧成「必须带存储前缀」,于是这些键被判非法,
fb_icon_url() 返回空字符串,图标自然显示不出来。
而三个文件其实都真实存在于 OSS 上(13KB / 391KB / 379KB)。

修法两层:
1. fb_icon_key_ok / fb_icon_is_key 兼容历史无前缀形态,
   这些是有效资产,不能因为键格式不规范就让图标消失
2. 数据迁移:把对象复制到 feedlite/icons/… 并把数据库键改成规范形态,
   已对三个软件全部执行并验证(新键 200,大小与源文件一致)

二、标题改为「XX 反馈系统」
- 页头大标题 = 同步日历反馈系统(之前只有「同步日历」)
- 浏览器标签同步改
- 软件名本身含「反馈」时不叠加,避免「轻反馈反馈系统」

三、图标不要背景色
- 去掉色块底衬与圆角投影,直接「图标 + 文字」并排
- 前端 badge、提交页上下文条、邮件头部三处同步
- md 尺寸调到 28px,与 26px 标题文字视觉匹配

验证:三个软件页标题正确、图标全部 200 image/png、
全部页面 200、迁移后新键可读且大小一致。
2026-11-03 08:27:03 +08:00
laoyang 872baf4153 修复:图标存储未跟随后台配置,本地模式下图标显示不出来
用户指出图标应当与附件同一套逻辑:管理员在后台配 local 就存本机、
配 oss 就存 OSS,前台都一样走 media.php 代理。

原实现把图标键硬编码成 icons/…(不带存储前缀),导致:
- fb_attach_is_local() 恒为 false
- 本机模式下文件确实写进了 uploads/icons/,
  但 media.php 把它当 OSS 对象去读 → 503,图标完全显示不出来

修法:图标键与附件完全同规则,只是路径里多一段 icons/
  local 模式 → local/icons/YYYY/MM/<hex>.<ext>  落 uploads/icons/
  oss   模式 → <oss.prefix>/icons/YYYY/MM/<hex>.<ext> 落私有桶
前台地址两种模式一致(media.php?k=…),换存储方式旧图标不会失效。

同时:
- fb_icon_is_key/ok 改为同时识别两种前缀
- fb_icon_allowed_ext 移到 attach.php(media.php 与校验都会用,
  不能依赖 admin_taxonomy.php 被加载)
- 写入时加断言:键前缀必须与当前存储模式一致,脱钩就报错,
  不再静默存到错误位置

验证(线上真实环境,两种模式各跑一遍):
- local:键带 local/icons/ 前缀、写入 uploads/icons/ 成功、
  media.php 读回 200 image/png 501B、PNG 魔数正确、删除后 404
- oss:键带 fb/icons/ 前缀、is_local=false、URL 拼接正确
- 旧 bug 形态(icons/… 无前缀)现在被明确拒绝 400,不再误判成 OSS
- 测试后配置已复原为 oss,临时文件与诊断入口全部清理
2026-11-03 08:19:17 +08:00
laoyang 835c8c05da 软件图标支持上传图片:存 OSS/本机,media.php 代理读取
用户要求图标可以自己上传一张真实图片,而不是只能填一个字符。

设计:字符和图片并存,图片优先
- software 加 icon_key(图片对象键),icon(字符)保留作兜底
- 有图片用图片,删掉图片自动退回字符,任何时刻都不会没有图标
- 前端 badge 与邮件头部同一套判断,页面和邮件永远一致

存储与读取
- 图标走独立键空间 icons/YYYY/MM/<16hex>.<ext>
  不复用附件那套:附件必须挂在 thread 下才给读,图标不属于任何帖子
- 写:仅管理员(后台动作链)+ finfo 真实类型校验 + 尺寸下限 + 2MB 上限
- 读:media.php 对 icons/ 只校验键形状与文件存在(图标本就公开),
  写入侧仍然严格,不会让本站变成能读整个桶的开放代理
- 优先用 media.php?k= 代理;OSS 下顺便借图片处理缩放,省下行

顺手修掉两个真实安全问题
1. fb_icon_key_ok 原来用 [a-z0-9]{2,5} 匹配扩展名,.php 正好能过
   (3 个字母)—— 测试反向验证时发现,已改为图片扩展名白名单
2. fb_upload_key_ok 有同样的问题,附件键也能构造出 .php
   虽然真上传还有 finfo 兜底,但键本身就不该长这样,一并收紧
3. fb_icon_delete 原来用 @ 吞掉错误,删除失败仍返回「已保存」,
   用户会以为旧图清了、实际留了个可访问的孤儿图。改为返回 bool 并如实提示

验证:键校验 7 类非法输入全拒;图标写入 OSS 成功、media.php 读回 200 image/png、
PNG 魔数正确、缩放生效;越权读取(穿越/.php/未入库附件/不存在)全部按预期拒绝;
表单 enctype/文件框/accept/预览渲染齐全;仿真 PHP 伪图与 SVG 均被拒;
16x16 小图被拒。
2026-11-03 08:13:21 +08:00
laoyang 738c6c09ff 多软件区分:邮件按软件换色+图标+软件名;补提交回执邮件
用户要求:多软件并存时,用「颜色 + 图标 + 名字」三重区分,
邮件也要跟着所属软件变,一眼能看出是哪款软件的来信。

1. software 表加 icon / download_url 字段(幂等迁移)
   - icon 存单个字符(emoji / 汉字 / 字母),不存图片路径:
     前端与邮件用同一个字符渲染,天然一致,不用再管一套上传与权限
   - 没配 icon 时取名首字兜底,任何地方都不出现空图标
   - 后台软件编辑加图标快选(12 个常用 emoji)

2. 邮件配色改为跟随软件(fb_mail_brand 取 fb_software_colors)
   - 头部色条、按钮、引用块色条、徽标全部跟着软件走
   - 邮件头部改为「图标色块 + 软件名」,下面再小字标站点名
   - 主题行统一加【软件名】前缀,不点开就能分辨

3. 新增 receipt 事件:提交后立即给提问人发「已收到、待审核、请耐心等待」
   - 独立开关 notify.receipt_enabled(与「回复通知」是两件事)
   - 新增 7 处调用前的静默 return 也一并写日志

4. reply 邮件补上下文:原提问摘要 + 本次回复,像博客评论通知
5. status=resolved 文案改为「已完成并上线」,配了下载地址时给下载按钮

验证:多软件配色/图标/前缀全部通过(含反向验证),XSS 转义通过,
空图标兜底通过;页面顶栏、页头、选择器图标渲染正确。
2026-11-03 08:00:50 +08:00
laoyang e43f2ce67a 修复:邮件通知两处静默失效(测试暴露)
症状:notify_log 全表为空 → 用户报「完全没发邮件」,后台查不到任何线索。

1. 前 4 个前置判断(开关/收件人/自己回自己/冷却)直接 return 不写日志,
   等于「为什么没发」完全无痕。改为任何退出路径都写 notify_log,
   并带上可读原因。

2. new 事件(新反馈通知管理员)收件人只取 admin_email,该值为空时整条
   通知静默失效 —— 管理员永远收不到新反馈信,界面也无任何提示。
   改为回落 smtp.user / smtp.from;后台勾了「通知管理员」却没填邮箱时
   当场显示警告。

验证:走 fb_notify_event 全事件测试,6 个事件全部写日志、new 能送达。
2026-11-03 07:31:35 +08:00
laoyang 8a850ca0e5 修复:全站图片 503 —— oss.enabled 派生写入被 has() 守卫拦掉
问题:attachment.storage 存成 oss,但 oss.enabled 一直停在 0。
media.php 判 fb_oss_enabled() 为 false,直接 503「对象存储未配置」,
所有 OSS 附件(历史 + 新增)全部打不开。

根因有两层:
1. oss.enabled 是派生字段,表单里没有对应 input,上一个提交加的
   $save() 统一守卫用 $has() 判断「没提交」就 return,把它挡掉了;
2. 更根本的是两份状态描述同一件事 —— storage 和 enabled 迟早漂移。

修法:
- 派生写入改用 $saveForce(),并整段包进 if ($has(attachment.storage)),
  避免反向问题(没提交存储方式时被默认值改成 off);
- fb_oss_enabled() 改为只看 attachment.storage 作单一事实来源,
  oss.enabled 仅在 storage 完全没配过的老库上作为回落。
2026-11-03 07:21:04 +08:00
laoyang a850d479f0 安全:修复上传接口三个真实漏洞(可执行文件落盘 / 限流可绕过 / 真上传无限流) (#5)
## 用户提问

「上传视频和图片的接口,有没有限制?会不会所有人通过 POST 都能随意上传?」

## 结论:防护大多到位,但存在三个真实漏洞

已核实有效的:CSRF、扩展名白名单、MIME 白名单、filesize 实测大小、
is_uploaded_file、类型开关、总量上限、uploads 目录 .htaccess 禁直连禁执行、
附件只走 media.php 代理。

但以下三个是真问题:

### 1. 🔴 可执行文件能写进 uploads/(最严重)

MIME 只校验**前端上报的字符串**,从不检测文件真实内容。

实测:把 `<?php system($_GET[c]); ?>` 改名成 .png、POST 里写
`mime=image/png`,一路通过所有检查写入磁盘。已确认真实文件内容:

    00000000: 3c3f 7068 7020 7379 7374 656d 2824 5f47  <?php system($_G

虽然 uploads/.htaccess 禁了脚本执行,但那是**唯一一道防线** ——
换 nginx、改配置、目录迁移,任何一步疏忽就是 RCE。

修法:新增 fb_upload_detect_mime(),用 finfo 读文件头魔数
(getimagesize 兜底),在 **move_uploaded_file 之前**校验真实类型。
不合格返回 415 并拒绝落盘。

### 2. 🔴 伪造 X-Forwarded-For 可无限绕过所有限流

fb_client_ip() 无条件信任 CF-Connecting-IP / X-Forwarded-For / X-Real-IP,
而这些头客户端随便写。

实测(upload_per_hour=2,连发 6 次、每次换个 XFF):

    200,200,200,200,200,200   ← 上限 2 却全放行

影响面不止上传:fb_ip_hash() 用同一个函数,所以发帖限流、验证码频率、
所有基于 IP 的限制全部失效。

修法:默认只用 REMOTE_ADDR(客户端改不了)。新增 trusted_proxies 配置,
只有请求确实来自受信任代理/CDN 时才解析转发头,且只取**最右一跳**
(最左边是用户自己塞的,取最左等于把伪造的当前者)。

### 3. 真上传(action=put)没有限流

fb_rate_hit(upload) 只在领凭证时记一次,真正写盘那一段完全没有
fb_rate_check。拿到一个凭证后可反复 POST 覆盖写入。

修法:真上传独立限流 upload_put(与领凭证分开计数,避免互相挤占),
落地成功后才 fb_rate_hit。

## 验证:fb_upload_security.py,19 项

正常代码 **19/19**,覆盖:
- 无 CSRF 被拒 / 正常访客能传
- 限流生效(上限 3 连发 10 次 → 429 x8)
- 三种转发头都无法绕过
- PHP 伪装落地被拒(415,检测到 text/x-php)+ 未落盘
- .php/.phtml/.svg 全拒
- 存储关闭被拒
- 谎报 size 后落地仍用 filesize 拦住 + 未落盘
- uploads 防护文件完备(Require all denied / -Indexes / 禁脚本扩展)
- media.php 代理正常

## 反向验证(关键)

把三处修复全部回退后重跑,出现 4 项 FAIL:

    OPEN  改 XFF 不能绕过限流      上限2/6次: 200,200,200,200,200,200 放行 6
    OPEN  三种转发头都无法绕过      放行 9
    OPEN  伪装内容落地被拒         put 200
    OPEN  伪装内容没有落盘         磁盘=True      ← PHP 代码真的写进去了

证明测试确实能抓到,不是假通过。

## 测试踩的坑(写进注释)

- 领凭证必须带 ext/size/mime,缺了会返回「不支持的文件格式」,
  容易误判成「格式校验生效了」
- key 是 local/2026/11/x.png,实际落盘在 uploads/2026/11/x.png,
  直接拿 key 拼 FB_ROOT 判断「是否落盘」永远为 false → 假通过
- php -S 内置服务器不读 .htaccess,不能用 HTTP 状态验目录防护,
  改为检查防护文件内容(线上 Apache 实测 /uploads/ → 403)

## 回归

全新安装 33/33、邮件专项 73/73、配置守护 6/6。
2026-11-03 06:56:11 +08:00
laoyang 605640aa90 根治:配置被跨页面保存清空(第三次复发,改为强制守卫) (#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 两个脚本已不在仓库,
 属历史遗留,本次以全新安装 + 邮件专项 + 配置守护三套覆盖。)
2026-11-02 23:36:26 +08:00
laoyang 4033170ad7 修复:跨 tab 保存互相清空配置;补齐全流程邮件通知与统一模板 (#2)
## 一、用户报「我明明配过 SMTP,怎么全丢了、信也发不出」

**没有重置数据库**(installed_at 仍是 11-01 15:14,oss.*/site_name/admin.username 全在)。

真因是我上一轮「邮件拆独立 tab」挖的坑:站点设置与邮件通知**共用 act=settings**,
而保存函数把 smtp 字段无条件写库:

    foreach ($smtpText as $k) {
        fb_set_setting($k, trim((string)$post($k, \x27\x27)));   // 没提交 → 空串 → 覆盖
    }

在站点设置页保存附件设置时,该表单没有 smtp_* 字段,$post() 取到空串,
于是 host/user/from 被清成空。smtp.pass 因为单独做了「留空=不修改」而幸存,
所以现场是 pass 有值、其余全空 —— 这个组合本身就是 bug 的指纹。

修法:新增 $has() 判断「本次请求是否真的提交了该字段」,没提交就跳过。
按 tab 分两组保护:
- $isSiteForm:站点信息/页脚/附件/OSS,逐字段 has() 双保险
- $isMailForm:SMTP/通知开关,整段 + 逐字段

checkbox 尤其要保护:未提交会被当成「取消勾选」,
在站点设置页存一次就会把「有人回复时通知」全关掉。

## 二、notify.admin_new_thread 是假的(用户说「提交了反馈收不到邮件」)

这个设置项:白名单里有、后台有勾选框,**但没有任何代码实现它**。
grep 只命中三处(白名单/保存/界面),没有一处真正发信。

已在 new.php 提交成功后实现:查新帖 → 取所属软件名 → fb_notify_event(\x27new\x27)。

## 三、补齐全部事件,统一入口

此前只有 reply 和 close 两个事件,且两套模板风格不一致。
新增 fb_notify_event() 统一入口,处理「开关→收件人→节流→发信→日志」。

覆盖 8 类事件:reply / status / approve / hide / close / new。
- status 按状态给不同文案(待处理/处理中/已解决/关闭/不予考虑),
  「不予考虑」语气克制、必须带理由并留申诉入口
- approve / hide 原先完全不发信,现在补上
- 状态没变化时不发信(不打扰用户)

## 四、邮件模板重做

抽出 fb_mail_layout() 统一外壳,所有事件共用:
- table + 内联样式(Outlook/QQ邮箱/Gmail 兼容)
- 600px 卡片、顶部主色条、圆角徽标、字段表、引用块、callout、CTA 按钮
- 响应式(已验 375px 无溢出、无裁切)
- preheader 摘要、纯文本兜底链接、收件人提示

## 五、后台状态按钮(用户要求)

问题详情页顶部一排按钮,点一下立刻生效并自动发信:
「通过并公开」「处理中」「已解决」「不予考虑」「撤下」
- 当前状态对应的按钮置灰
- 「不予考虑」展开内联面板填理由(不用 window.prompt)
- 新增 setstatus 动作;「通过并公开」会同时置可见

## 六、notify_log 加 reason 列

原表只有 reply_id,无法区分事件类型。加 reason 并幂等迁移;
老库 reply_id 是 NOT NULL,非回复事件用 0 表示。

## 验证

邮件专项 73/73:
- 8 类事件模板全部完整 HTML、table 布局、徽标、页脚、无 script
- 8 个主题互不相同;状态徽标文案正确
- 总开关关闭时 admin_new_thread 仍可独立生效
- 节流只作用于 reply/status,close 不受影响
- SMTP 未启用时留失败日志(含 reason 与原因)
- C1-C5:站点页保存后 smtp.host/user/notify.enabled 均未被清空;
  邮件页保存后 oss.bucket 未被清空

反向验证:完整回退两层保护后 C1/C2/C3 全部 FAIL
(host 从 smtp.qiye.aliyun.com 变成空、notify.enabled 从 1 变 0),
证明测试能真正抓到该 bug,不是假通过。

回归:全新安装 33/33、老基线 25/25、附件 22/22。
2026-11-02 22:57:00 +08:00
laoyang 0365098fa4 修复:空格式白名单反而拒绝一切文件;定位「填 0 时正文图传不上」真因 (#4)
## 一、定位用户报的「提交反馈里图片不显示」

用户先说正文图不显示、回复的却显示,随后反馈「现在可以了,但之前确实不行」。

真因在 #4 已修掉,但当时没写清楚机制:

    for (var i = 0; i < files.length; i++) {
      if (countOf(kind) >= maxCount) {
        alert("最多上传 " + maxCount + " 个");
        break;                    // ← 整批文件一个都不传
      }
    }

数量上限填 0(本意不限)时,countOf 是 0、maxCount 是 0,
`0 >= 0` 为真 ⇒ 弹「最多上传 0 个」⇒ break 跳出整个循环 ⇒
选中几张就全部丢弃。上传区看起来"正常"(只是弹了个框),
提交后正文自然没有图。

已在线上核实用户新发的那条:aid=2 tid=2 reply_id=NULL,
附件正确挂在主题正文,详情页 .atts 渲染出 1 个 img,media.php 返回 200 / 225KB。

## 二、顺带修同一段里的第二个同类陷阱

    var allowed = (box.getAttribute("data-accept") || "").split(",");
    if (allowed.length && allowed.indexOf(file.type) < 0) { ... 拒绝 ... }

`"".split(",")` 返回 `[""]`,长度是 1 不是 0。
所以「未配置格式白名单」时,allowed.length 判真、indexOf 永远 -1
⇒ 空列表反而拒绝一切文件,这与"没配置=不限格式"的意图相反。

修法:split 后 filter 掉空项。实测:

    normal : allowed=["image/jpeg","image/png"] len=2 blocked=false
    empty  : allowed=[""]                      len=1 blocked=true   ← 旧代码

当前 UI 没有清空 mimes 的入口,现网触发不到,但属于同一类
「空/0 被当成最严格限制」的坑,一并修掉。

## 三、资源版本 14 → 15

🔴 上一轮就是改了 app.js 却忘升版本,浏览器一直用缓存旧 JS,
   导致修复看起来"没生效"。这次改完立刻升,三处引用
   (layout.php 的 css/js、install.php 的 css)都走 FB_ASSET_V 常量。

## 四、新增专项与反向验证

`_tooling/feedlite/fb_attach_zero_js.py`:19 项。
关键设计:数量判断条件**从源码正则抽取**后注入 node harness,
不在测试里硬编码正确写法 —— 否则修复被回退时测试照旧全过,成了假通过。

反向验证(故意把修复回退):
- 正常版本 19/19
- 回退版本 12/19,B1/B2/B4/B6/B9 全部 FAIL,能准确定位

⚠️ 第一版 harness 的 countOf 返回固定值,导致 B3/B8("传着传着到上限")
   假通过。改成动态读递增计数后才是真验证。

回归:全新安装 33/33、附件 22/22、老基线 25/25、约束检查 20/20。
2026-11-02 10:42:03 +08:00
laoyang 9701291b4e 修复:改了 app.js 却没升资源版本,浏览器一直用旧 JS (#4)
用户报「图片最多上传 0 个,请先移除已上传的文件」——JS 弹的。

## 根因:不是逻辑没写对,是新代码根本没加载

#4 已经把拦截改成 `maxCount > 0 && countOf(kind) >= maxCount`,
但**忘了把 FB_ASSET_V 加 1**。页面仍然输出 app.js?v=13,
浏览器直接从缓存读旧的 app.js(旧的判断是 `countOf >= maxCount`,
maxCount=0 时 `0 >= 0` 为真,一个都不许传)。

## 修法
FB_ASSET_V 13 → 14。这一改同时覆盖 style.css 与 app.js,
用户下次刷新就会拉到新文件。

## 新增检查脚本
`_tooling/feedlite/fb_asset_version_check.py`:
对比「最后一次改 assets/ 的提交」与「最后一次改 FB_ASSET_V 的提交」,
前者更新就报错。改前端资源却忘升版本这类问题肉眼极难发现,
必须用规则卡住。

用脚本自查当前仓库,确认本提交修完后转为合规。

## 教训
改 assets/ 下任何文件 → **必须同时把 lib/bootstrap.php 的 FB_ASSET_V 加 1**,
这是硬配套动作,写在 README 里但这次没执行到位。
2026-11-02 09:10:15 +08:00
laoyang 74beaf1163 附件限制全部支持填 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。
2026-11-02 08:55:23 +08:00
laoyang b0e6ac9cd8 邮件 tab 输入框不再预填出厂示例值 (#2)
用户反馈:「邮件通知这个页面里的那些输入框,当我鼠标点完之后,还是没有把里面的内容删除。」

上一轮(#3)只处理了 prefix 和邮件标题两个字段,**漏了 smtp 整段**。

根因同前:config.example.php 的 smtp 段有全套出厂示例值,
fb_cfg() 合并后表单把它们当"已填内容"显示,用户点进去得先删一遍。

## 本次处理

全部改用 fb_form_val():数据库里没设过 = 未自定义 = 显示空,示例只留 placeholder。

| 字段 | 出厂值 | 现在 |
|---|---|---|
| smtp.host | smtp.example.com | 空,placeholder 给真实可用的 smtp.qiye.aliyun.com |
| smtp.user | noreply@example.com | 空 |
| smtp.from | noreply@example.com | 空,placeholder「留空则同登录账号」 |
| smtp.from_name | 轻反馈 | 空,placeholder 显示当前站点名 |
| smtp.port | 465 | 空,placeholder 465 |
| smtp.timeout | 15 | 空,placeholder 15 |
| notify.cooldown_seconds | 300 | 空,placeholder 300 |

## 顺带修两个真问题

1. **留空不能存 0**:端口/超时/冷却原先 `(int)$post()`,留空会变成 0,
   0 端口根本连不上、0 超时立刻断。改成留空即删键,回落 config 默认值。
2. **cooldown 出厂值是 300 不是 60**:我一开始按 60 判,导致 300≠60 被当成
   "用户设过"而回显。核对 config.example.php:80 后改正。

⚠️ 注意 cooldown 的 0 是合法值(表示不限制),所以用「空串」区分"没填",
不能用 0 判断,否则用户真想设 0 会被当成默认。

## 验证
邮件预填专项 33/33:
- 7 个字段 value 全空,示例在 placeholder
- 无任何出厂示例值被预填
- 真实值保存后逐项回显(含 0 这个边界)
- 留空后回显空 + 数据库键被删 + 生效值回落 465/15/300
- 填 0 被正确保留

回归全绿:全新安装 33/33、安装向导 60/60、老基线 25/25、邮件 tab 专项 37/37。
2026-11-01 19:26:42 +08:00
laoyang 367f78f239 站点设置拆出独立「邮件通知」标签页 (#2)
用户反馈:「站点设置现在太长了,应该把邮箱设置单独拿出来,做一个标签页。」

原先一个 settings tab 里塞了:站点信息、页脚、邮件通知、附件存储、审核、
存储体检。光邮件那段就 82 行(10 多个字段 + 测试按钮),往下翻很累。

改动:
- 后台导航新增「邮件通知」tab,放在「站点设置」左边
- 邮件那段界面整体搬到新 tab(SMTP 配置 + 通知规则 + 测试发送)
- settings 里彻底移除,不留两处
- tab 白名单加 mail
- 新 tab 有自己的页头(h1 邮件通知)和「← 回站点设置」返回入口

保存逻辑没动:新 tab 的表单仍提交 act=settings,走的还是原来那段
处理代码,所以 SMTP / notify 的写入行为完全不变。

## 兼容
旧链接 admin.php?tab=settings 仍然有效,只是里面不再有邮件字段。
邮件配置数据没动,新 tab 打开就能看到原值。

## 验证
邮件 tab 专项 37/37:
- 6 个 tab 全部 200
- 导航有入口、文字正确
- settings 里 smtp_host/port/pass 均 0 次,无测试按钮
- mail tab 11 个字段齐全 + 总开关 + 页头 + 返回入口
- 保存后回显:服务器、端口、冷却秒数正确;口令显示「已设置」
- 两个 tab 互不干扰

回归全绿:全新安装 33/33、安装向导 60/60、老基线 25/25。
2026-11-01 17:29:32 +08:00
laoyang e408dc78b8 表单不再预填示例数据,示例只留 placeholder (#3)
用户反馈:输入框里预填的「示例值」点进去得先删干净才能填自己的,不合理。

必须分清两类,否则会误删真实配置:
- A 类 真实配置(从数据库读出来的)→ 保留
- B 类 出厂示例(代码/配置文件里写死的)→ 清空,示例移 placeholder

本次处理的 B 类:

1. 安装页「网站名称」
   原先回显 config.example.php 里的出厂名「轻反馈」,用户以为要保留。
   现在首次进入为空,仅在表单校验失败重来时回填用户刚才填的内容。

2. 安装页「管理员用户名」
   原先预填 admin,现在为空,placeholder 提示「例如 admin」。

3. 后台「对象键前缀」
   原先显示 fb(来自 config 默认值),现在为空,placeholder「留空即用 fb」。

4. 后台「关闭通知邮件标题」
   原先显示模板「关于你反馈的问题《%s》」,现在为空,placeholder 给出模板。

## 新增 fb_form_val()
config.example.php 里给的出厂默认值和「用户填过的值」在 fb_cfg() 里合并后
分不出来 —— 表单永远显示得出内容。这个函数按「数据库里有没有这条设置」判定:
设过就回显(哪怕值恰好等于默认),没设过就当未自定义、显示空。

## 顺带修复一个真 bug
close_mail_subject 原先留空会存成空字符串,fb_cfg() 返回空串后
sprintf() 得到空邮件标题 —— 用户清空模板就发不出有主题的邮件。
改成留空即删除该设置,回落到默认模板。

## 验证
#3 专项 20/20(含:真实配置保存后必须回显、清空后必须回落默认且不存空串);
回归全绿:全新安装 33/33、安装向导 60/60、老基线 25/25、约束检查 20/20。
2026-11-01 16:53:52 +08:00
laoyang f1a81cbaa1 RAM 角色自动探测 + 邮件配置界面 + 每个软件独立主题色 (#1 #2)
## 1. ECS RAM 角色改为自动探测
原先要用户去阿里云控制台查「实例 RAM 角色名」再手填,纯属多余 ——
机器自己知道答案。

新增 fb_oss_detect_instance_role():读元数据
GET /latest/meta-data/ram/security-credentials/(末尾带斜杠)拿到角色名列表。
- 兼容单行 / 多行 / JSON 数组三种返回
- 未配置角色时 fb_oss_instance_creds() 自动探测,不再报「未配置」
- 后台改成只读展示「已绑定:xxx」,手填入口收进「高级」折叠里
- 保存时留空 = 自动探测后写入,不存空值
- 探测失败给出可操作原因(没绑角色 / 不在 ECS 上 / 元数据被禁用)

## 2. 邮件配置界面(原先完全缺失)
lib/mailer.php 有完整 SMTP 实现,但后台一个输入框都没有,
只能改 config.php —— 用户装完根本找不到地方配。

根因有两层:
- fb_setting_keys() 白名单里没有任何 smtp.* / notify.* 键,
  就算加了表单,fb_set_setting() 也会把值全部丢弃
- 设置页只有「站点信息」「附件存储」两个区块

补上:白名单 15 个键 + 后台「邮件通知」区块
- SMTP 服务器/端口/加密方式(SSL/STARTTLS/不加密)
- 登录账号/密码(留空不修改,避免每次重填)
- 发件地址/显示名、超时、SSL 校验
- 通知规则:回复通知、每次回复都通知、新反馈通知管理员、发信冷却
- 「发送测试邮件」按钮,AJAX 发完就地显示成功或失败原因,
  不用保存再靠猜

## 3. 每个软件一个主题色
需求:从短链接进不同软件,颜色不同,一眼区分。

- software 表加 color 字段(幂等迁移)
- 全站配色靠 --brand / --brand-600 / --brand-soft 三个变量,
  按软件覆写这三个即可,不用为每个软件写一套 CSS
- fb_color_variants() 由主色推出深一档(×0.85)与浅一档(往白混 88%)
- 未配色的软件按 id 从 8 色调色板取默认色,保证同站不撞色
- 后台:颜色选择器(原生取色 + 8 个预设 + 用默认)
- 前台:index / new / thread 都按当前软件注入主题色,
  meta theme-color 也跟着变

验证:三功能专项 30/30;新旧回归全过
(全新安装 33/33、老基线 25/25、附件 22/22、安装向导 60/60、约束检查 20/20)
2026-11-01 16:39:09 +08:00
laoyang c19e475e85 修复:附件总量上限填 10240 被浏览器拦住(step 与 min 冲突) (#1)
用户反馈「全站附件总量上限填 10240 不行」。

根因:input 写成 step="16" min="10"。
HTML 的 step 是从 min 起算的 —— 合法值只有 10、26、42、58…(10 + 16n)。
10240 不在这个序列里,浏览器直接判为非法值、拒绝提交。

更糟的是这不是个例:1024(1G)、2048、4096、8192 全都不合法,
只有 10/26/42 这种怪数字能填进去。这个 bug 从该字段上线就存在。

修法:
- step="16" → step="any",允许任意数值
- 补 max="100000",与服务端 [10, 100000] 区间对齐,
  避免用户填 999999 被服务端静默截断成 100000

顺带排查另外两个容量字段,确认没事:
- 图片单张上限 min=0.2 step=0.1 → 0.2 起步正好对齐,0.5/1/5/10 都合法
- 视频单个上限 min=1 step=1 → 整数都对
只有总量那一个字段踩了坑。

新增 tools 级检查脚本(_tooling 侧 fb_input_constraint_check.py):
按 HTML 规范逐条验算每个 number 输入框的 min/max/step 是否放行
「默认值」与「常见取值」,并核对前后端区间是否一致。19/19 通过。
这类错误肉眼看不出来,必须用规则算。

验证:约束一致性检查 19/19;服务端 MB↔字节往返一致(含 10240)。
2026-11-01 15:26:08 +08:00
laoyang 1434a6dfb6 修复:英文标识留空真能自动生成;表单必填前端就地提示不刷新 (#1)
## 问题 1:文案说「留空自动生成」,实际却报错
界面上英文标识写的是「留空自动生成」,且不带红星;但纯中文名
(如「同步日历」)经 slugify 过滤后是空串,服务端照旧报「请填写英文标识」。
文案与行为自相矛盾,用户不明所以。

根因:fb_sw_slugify() 只保留 ASCII,中文全被过滤掉。

修法(不靠加红星了事):给 slugify 加兜底前缀 + 序号
- 软件:中文名 → sw<序号>(如 sw2)
- 分类:中文名 → cat<序号>
- 编辑时留空 = 保持原标识,不因改名把已有网址打散
- 纯英文名行为不变(FeedLite → feedlite)

实测:同步日历 → sw1 / 我的软件 → sw2 / FeedLite → feedlite

## 问题 2:必填项没填,整页刷新才看到错误
原先完全靠服务端校验:提交 → POST → 整页重刷 → 页面顶部弹一条错误。
用户视角就是「什么都没填,页面闪了一下」。

新增 bindInlineValidate()(assets/app.js):
- 给 form 加 data-validate 即启用,零配置
- 就地校验:字段下方显示红框 + 原因,聚焦并滚到可见
- 用户一改动立刻撤掉提示,不一直红着
- 规则取自原生 required / minlength / pattern / type=email

🔴 关键实现细节:用**捕获阶段**监听,并调 stopImmediatePropagation()。
因为 bindNoDoubleSubmit 会在 submit 时禁用按钮 5 秒,若校验排在它之后,
被拦下的表单按钮会被锁住,用户以为点坏了。所以校验必须先跑且拦住它。

## 服务端仍保留兜底
JS 被绕过(禁用脚本、直接构造请求)时,后端照样拒绝空用户名、短口令。

## 顺带收敛资源版本
app.js?v=5 与 style.css?v=N 原先各自写死,已统一到 FB_ASSET_V(升到 11)。
加上前一轮的 layout.php / install.php,现在全站只有 bootstrap.php 一处定义。

验证:
- 标识生成与前端校验专项 21/21
- 全新安装 33/33、老基线 25/25、附件专项 22/22
- 安装向导 60/60、后台管理 30/30
2026-11-01 15:21:27 +08:00
laoyang b835afcd04 data/uploads 改为由 PHP 自动创建并自补防护,不依赖部署账号建目录 (#1)
## 问题
部署时用 btops 建 data/ 与 uploads/,属主成了 btops:btops,Web 用户 www 写不进去,
安装向导直接报「目录不可写」,还得请人用 root 手工 chown —— 这不该让用户干。

## 修法:让 PHP 自己建
PHP 以 Web 用户身份运行,它建出来的目录属主天生就是 www,不需要任何 chown。
- 部署脚本不再创建 data/ uploads/(只建 assets/ lib/)
- 首次访问时 fb_db_init() 自动 mkdir data/,并立刻补防护文件
- uploads/ 由 fb_local_ensure_dir() 同样自动建 + 补防护
- 安装向导的环境体检 mkdir 之后也立刻补防护,不留空窗期

## 新增 data/ 防护文件自动生成
原先只有 uploads/ 有自动防护,data/ 没有 —— 数据库目录裸奔。
新增 fb_db_ensure_guard(),写 .htaccess(拒绝直读,禁 db/db-wal/db-shm/sqlite/php)
+ index.html 占位,带 `# fb-data-guard: vN` 版本标记,规则升级时整份重写。

## 顺带确认
站点根 .htaccess 已有全局兜底(拒绝 .db/.log/.md/.bak 等直读 + Options -Indexes),
即使某个目录暂时缺 .htaccess,/data/ 和 /data/feedback.db 也是 403。
现在是双保险,不依赖单点。

验证(WSL 真实环境):
- 全新安装 33/33、老基线 25/25、附件专项 22/22、安装向导 60/60
- data/ 与 uploads/ 均由 PHP 自动创建,属主即运行用户
- 两者 .htaccess 自动生成并带正确版本标记(v1 / v2)
2026-11-01 15:07:53 +08:00
laoyang 1c2e8d5e65 安装向导:装完自动删除 install.php;站点根只读不再阻断安装 (#1 #2)
## 装完自删
install.php 是可写文件,留着等于长期暴露一个「重装入口」。
现在安装成功后自动 unlink 自己,不再要用户手动删。

两种结果都如实告知,不假装:
- 删除成功 → 成功页显示「已自动删除」
- 删不掉   → 显示「没能自动删除」+ 具体原因(目录不可写 / 属主不符)+ 手工处理命令
安装本身不受影响:清理失败不算安装失败。

## 🔴 修掉一个越权动作(本轮最要紧的修改)
初版为了「尽量删掉」,先 @chmod(dirname($self), 0755) 再 unlink。
这等于程序自己把「站点根只读」这个安全设置拆掉了 —— 管理员有意设成只读的目录
被 PHP 改回可写,然后文件就被删了。既违反管理员意图,也让「删不掉」这个真实场景
永远测不出来(我第一版测试就是这么被骗过去的)。

删除失败就如实报告,不准硬删。

## 站点根只读不再阻断安装
原先环境体检把「站点根可写」列为硬性要求,只读就红叉、禁止提交。
但这没道理:config.php 写不进去时,设置会全部落到数据库 settings 表,功能一样。
反过来,站点根只读是更安全的做法。

现在 data/ 与 uploads/ 可写仍是硬要求,站点根只读只显示为提示(不影响判定)。

## rename 兜底收窄
只在目录可写时才试 rename(目录不可写时 rename 同样失败,
硬试会在站点里留下 .deleted.xxxx 垃圾)。改名成功但删不掉则还原。

验证:
- 自删 A 场景(目录可写)→ 文件已删,页面提示「已自动删除」
- 自删 B 场景(目录只读)→ 文件保留,提示「目录对 Web 用户不可写」,安装仍成功
- 全新安装 33/33、老基线 25/25、附件专项 22/22、安装向导 60/60
2026-11-01 12:55:08 +08:00
laoyang 7a578fac47 静态资源版本收敛到 FB_ASSET_V 常量 (#1)
原先 layout.php 与 install.php 各写一份 style.css?v=N,改样式时只改一处,
另一处会漂移 —— 实际就发生了:首页 v=10、安装页还是 v=7,两页样式不一致。

现在统一在 lib/bootstrap.php 定义 FB_ASSET_V,两处都引用它。
以后改 assets 下的 css/js,只改这一个数字。
2026-11-01 12:47:41 +08:00
laoyang 9b822d2d9a 安装向导:加管理员用户名,精简为填表即装、装完进首页 (#1 #2)
## 管理员账号(用户名 + 口令)
原先只有口令,没有用户名。现在两者都有:
- 用户名词入库 settings 的 admin.username(2~32 位,字母数字下划线点连字符)
- 口令仍是 bcrypt 哈希存 admin.password_hash,明文不落任何文件
- 登录校验 fb_admin_verify_login():用户名或口令任一不对都返回同一句
  「用户名或口令不正确」,且用户名错时也照常跑一次 password_verify,避免用耗时探测账号
- 向后兼容:老部署只设了口令没用户名时,仍可只填口令登录,
  并在登录页提示补一个用户名

## 安装向导精简
按用户要求「没数据就初始化,填完进主页」:
- 必填只剩 4 项:网站名称、管理员用户名、口令、确认口令
- 站点描述 / 官方邮箱 / 站点根地址改为 hidden(可留空自动探测)
- 密钥、令牌原本就自动生成
- 安装成功后展示网站名称与用户名,2 秒自动跳首页,也有按钮
- 按钮文案改为「完成安装,进入首页」

## 修掉一个真实 bug
首页把软件说明当成站点描述,导致「软件有 note 时站点描述被顶掉」。
两者是不同的东西:站点描述是全局设置必须始终显示,软件说明另起一行补充。
老基线 D6「首页描述变为新描述」就是被这个 bug 弄失败的。

## 另一个坑
admin.username 忘了加进 fb_setting_keys() 白名单,fb_set_setting() 会静默丢弃,
表现为「安装时填了用户名,装完却没有」。已补。

验证:
- 全新安装流程 32/32(真空站:无 config.php、无 data/*.db)
- 老基线 25/25、附件专项 22/22、安装向导 60/60
- PHP 8.5 全部入口 0 弃用
2026-11-01 12:39:20 +08:00
laoyang 55d2dfa46b 后台管理界面:软件/分类/状态可配,反馈可改挂归属 (#1)
## 新增 lib/admin_taxonomy.php
- 软件:增删改、排序、启停;新增时自动预置 Bug/功能建议/其他 三个分类
- 分类:在选定软件下增删改、排序、启停
- 状态:改名、改「给用户看的话」、排序、启停;标识不可改(历史数据靠它对应)
- 反馈改挂:th_move,可把反馈移到别的软件/分类,跨软件的非法分类会被拒

## 安全与边界
- 所有动作走 CSRF 校验(沿用现有机制)
- 不能删最后一个软件 / 没有分类的软件至少要留一个启用分类
- 有反馈挂着的软件或分类不能删,提示改为停用(停用后前台消失、数据保留)
- th_move 加入 fb_admin_is_taxonomy_act 白名单,否则会被「问题不存在」静默吞掉

## admin.php 接线
- 引入模块 + 动作分发 + tab 白名单加 taxonomy/statuses + 导航两项
- 前置检查放行不依赖 $t 的动作(否则新增软件报「问题不存在」)
- 全部问题列表:加软件筛选、分类跟着软件走;修掉三处仍在用旧
  fb_statuses()/fb_categories() 的遍历(那会渲染出错)
- 详情页加「归属」表单,软件徽章可显示

## 其他文件
- api.php:状态/分类改数据库;新增 ?sw= / ?software_id= / ?cid= 过滤,
  老的 ?category=<slug> 仍可用(在所属软件内解析)
- thread.php:详情页显示软件与分类徽章
- config.example.php:移除 categories / statuses,改由数据库管理

## 修掉两个真实 bug
1. 移除配置后出厂分类兜底只剩 4 个,漏了「同步问题」—— 建库后核对数量才发现
2. th_move 不在白名单,POST 后返回 200 却静默不生效

验证:后台 e2e 29/29;改挂与联动 13/13;PHP 8.5 全部入口 0 弃用;
19 个 PHP 文件语法全过。
2026-11-01 12:03:11 +08:00
laoyang 59f7466ae4 多软件归属:软件→分类两级,配置全部入库 (#1)
原先分类写死在 config.example.php,后台改不了;也没有「软件」概念。
现在改为数据库驱动的两级结构,后台可配(管理界面待做)。

## 数据层(lib/db.php)
- 新增 software / categories / statuses 三张表
- categories.software_id 外键:每个软件有自己的分类集合,可同名互不干扰
- threads 加 software_id / category_id;老 category 文本列保留不删(可回退)
- fb_db_migrate_taxonomy() 幂等迁移:建表 → 空表灌初始数据 → 回填老帖子

## 迁移实测
- 老 5 个分类(使用问题/Bug 反馈/功能建议/同步问题/其他)原样落进默认软件
- 老帖子按 category slug 回填 category_id,匹配不到的留 NULL

## 状态(5 个)
待处理 / 处理中 / 已解决 / 已关闭 / 不予考虑(wontfix)
每个状态带 user_note「给用户看的话」,后台可改,将进邮件正文。
待处理 = 刚提交:您提交的反馈已收到,我们正在处理中。

## 前台
- index.php:软件切换条 + 分类 chip(一级软件、二级分类)
- new.php:选软件、分类下拉随软件变、服务端校验分类归属
- URL:?sw=<slug>&cat=<slug>;老链接 ?cat=xxx 解析不到时退回全部,200 不 404

## 界面一致性
按用户要求去掉「单软件时隐藏软件选择器」的逻辑:
所有界面一致,配一个就列一个,配多个就列多个。

## 兼容
fb_categories() 现在必须传软件 id;fb_statuses() 返回行数组,
另有 fb_status_keys() 取 key=>name 映射。

验证:WSL 内临时实例实测——单软件显示 1 个软件条;两软件各显示
自己的分类集合与帖子,互不串;无 PHP 警告/致命错误。
2026-11-01 11:35:55 +08:00
laoyang 690228e7c1 安装体检:修正 fileinfo 说明(只影响本机存储模式)
原先写「上传校验会更严格」是不准确的:OSS 存储模式下,附件校验走对象存储
返回的 Content-Type(lib/attach.php 的 oss 分支),根本不调用 finfo。真正用到
fileinfo 的只有本机存储分支(lib/attach.php 的 local 分支、media.php)和
media.php 的本机附件 MIME 探测。

改动:
- install.php 体检项说明改为「按文件内容识别附件类型(仅本机存储模式用得上)」,
  缺失文案补上「不影响安装」,补救提示写清 OSS 模式以对象存储返回的类型为准、
  与本扩展无关,避免管理员误以为装个扩展就补了个安全缺口。
- README 同步区分两种存储模式;补充两点排查经验:
  面板显示「有」不等于运行中的进程已加载,要看 php.ini 里的 extension 行;
  且改完 ini 必须重启/重载 PHP-FPM 才生效(reload 即可,不必 restart)。

验证:php -l 双版本通过;安装向导 60/60、主回归 25/25、PHP 8.5 兼容 19/19;
线上部署 17/17,6 个文件 md5 逐个一致,冒烟全绿。
2026-10-23 09:07:39 +08:00
laoyang 23772b37ab 安装体检:把 fileinfo 列为推荐扩展
新装一套 PHP(尤其宝塔里的 8.5)时 fileinfo 常常默认没开,此时
上传的类型校验只能按「扩展名 + 浏览器声明的 MIME」核对。代码本身
有回落链、不会报错,但用户无从得知这层弱化,因此在安装体检里显式
列出,并说明开启后的收益。

- install.php:可选扩展支持 [说明, 缺失文案, 补救提示] 三元组
- README:扩展清单补 fileinfo,并提示换 PHP 版本后要复查一次
2026-10-23 08:42:47 +08:00
laoyang 6135e7b890 兼容 PHP 8.5:消除全部弃用调用
PHP 8.5 把 curl_close()/finfo_close() 这类"空操作函数"标为弃用,
并把预定义局部变量 http_response_header 标为弃用(编译期判定,@ 与三元短路都压不住)。

- 句柄关闭收进 fb_curl_close()/fb_finfo_close():仅 PHP < 8.0 真正调用,
  新版本直接跳过(8.0 起句柄随引用计数释放),保留 7.4 兼容
- 响应头读取改用 http_get_last_response_headers()(8.4+);
  老版本经 get_defined_vars() 从作用域变量表取,源码不再出现已弃用标识符
- 新 API 是全局状态,用「请求前基线」比对,避免把上一次的残留头当成本次结果
- 安装体检与 README 的推荐版本区间更新为 8.1~8.5

验证:PHP 8.5.12 下五套测试全绿(25/25、22/22、60/60、7/7、反向 4 组全被抓住),
error_log 零条弃用记录;PHP 8.4.25 同样全绿。
2026-10-23 08:23:55 +08:00
laoyang 5006d54be3 附件读取统一走 media.php 代理,真实地址不再暴露
无论附件存在本机 uploads/ 还是 OSS 私有桶,前台给浏览器的地址现在完全一致:
media.php?k=<对象键>。界面上不再出现 uploads/ 真实路径,也不出现任何 bucket /
endpoint / 签名痕迹。

- lib/attach.php:新增 fb_attach_proxy_url(),fb_attach_url() 与
  fb_attach_thumb_url() 的本机分支一并改走代理(地址不落库、渲染现算,
  对历史附件立即生效,无需迁移)。
- uploads/ 整目录禁止 Web 直读:附件只能经 media.php 出,真实物理路径
  即便被猜到也取不到,同时绕不开 media.php 的可见性鉴权。
- 规则版本化自动升级:.htaccess 内埋 `# fb-local-guard: vN`,
  旧版规则在任意请求时由 PHP(目录属主)整份重写。之所以不能靠部署覆盖:
  部署账号对 uploads/ 无写权限,只有程序自己能改。
- 随包 uploads/.htaccess 改为程序产出的逐字节副本,并加断言防漂移
  (否则线上会被自动升级覆盖,随包那份形同虚设)。
- 顺带修复真实源码泄漏:config.php.bak_<时间戳> 这类「非 .php 结尾、
  但含源码」的备份文件此前可被公开下载。加固根 .htaccess:
  去掉正则结尾锚点、补 .php[.\-_] 兜底、点名拒绝 config.php / oss.sts.php。
- media.php / upload.php / admin.php / README.md:文档与响应头同步。

验证:php -l 19 文件 0 失败;主回归 25/25、附件专项 22/22、安装向导 60/60、
统一代理渲染 7/7、反向验证 V1-V4 全被抓住;线上冒烟 30/30、
线上统一代理真机 13/13(OSS 私有桶 + 内网 endpoint 下上传→代理读回→
Range 206→伪造键 403)。
2026-09-28 23:33:45 +08:00
laoyang 27187b38f1 页脚入口与安装向导;加固管理员口令存储
需求落实:
1. 页脚常驻「管理后台」链接,不用再手敲 admin.php。
2. 页脚「由 FeedLite 驱动」中的 FeedLite 改为超链接(指向开源仓库);
   后台新增「页脚」设置组,可关闭整句(关闭后只留管理后台链接),也可改链接地址。
3. 管理员口令存储方式在后台如实展示:数据库 settings.admin.password_hash,
   存 bcrypt 单向哈希(不可逆),明文不落盘。
4. 新增安装向导 install.php:data/ 下没有数据库时自动 302 引导;
   体检环境 -> 填站点信息与口令 -> 生成 config.php -> 建库建表 -> 完成。

顺带修掉一个真实安全隐患:
  config.php 里的出厂占位口令 CHANGE_ME_admin 原先可被当作有效口令登录后台
  (等于公开仓库里就写着线上后门口令)。现改为占位/空值一律拒绝,
  未设口令时后台明确提示去安装向导,不再含糊报「口令不正确」。

实现要点:
- 新增 fb_cfg_file():只读配置文件、不叠加 settings 表、绝不触达数据库。
  「是否已安装」以数据库文件是否存在为判据,而 fb_cfg() 读 settings 会触发
  fb_db() 就地建库 —— 那会把安装态自己造出来,所以判定必须走不碰库的通道。
- bootstrap 只在已安装时建库;未安装则 302 到 install.php(CLI 下报错退出)。
- fb_settings_flat() 在未安装时直接返回空,杜绝任何隐式建库。
- install.php 用文本级替换生成 config.php,保留全部注释与 OSS/SMTP 段落;
  生成的 PHP 用 token_get_all(TOKEN_PARSE) 校验后才落盘。
- 页脚链接只放行 http/https,后台保存时校验(防 javascript: 伪协议)。
- style.css 页脚行改 flex,版本号 v6 -> v7。

验证:
- php -l 全量通过;现有回归 25/25、附件专项 13/13 保持通过。
- 安装向导专项 60/60(含「明文口令未写入任何文件」「数据库字节里搜不到明文」)。
- 反向验证:撤销三处修复后对应断言确实失败(断言非装饰)。
- 几何检查:本机 15/15 + 页脚专项全过;线上真实 URL 15/15 + 页脚专项全过。
- 线上真机冒烟 22/22。
2026-09-28 22:53:55 +08:00
laoyang 1401ad899b OSS 附件改为私有桶+内网Endpoint+服务器代理架构
背景:桶为私有、且只用内网 Endpoint(外网不可达),浏览器既无法直传、
也无法直连。原「浏览器直传 + 签名URL直连」两条路都失效,改为全站代理。

lib/oss.php:
- endpoint 自动转内网(*-internal),use_internal_endpoint 开关控制,内网走 http
- 移除浏览器直传凭证(PostObject),改为服务器端 PUT(流式 fb_oss_put_file)
- 新增 fb_oss_stream_get 流式读取(回调式,支持 Range,不整文件进内存)
- 签名支持 x-oss-process 子资源 + Authorization 头签名(Content-Type 入签)
- 新增 fb_oss_probe 连通性自检、fb_oss_error_text 错误归因(含跨地域提示)

media.php(新增): 附件代理输出,鉴权边界=对象键须在本站附件表(或本人待入库
  登记)且帖子对访客可见,防止本站沦为能读整个共用桶的开放代理;透传 Range。

upload.php: local/oss 统一走两步(领键→action=put),OSS 模式由服务器代理 PUT。
lib/db.php: 新增 pending_attachments 表 + idx_att_key 索引(迁移幂等自动建)。
lib/attach.php: 待入库登记(mark/clear/mine/gc),解决上传后入库前的预览鉴权。
cron.php: 清理过期待入库登记 + 桶连通性自检输出。
admin.php: 设置页加「使用内网 Endpoint」开关、存储体检显示实际连接地址与探测按钮。
README/config: 全面更新为代理架构(无需CORS、无需自定义域名、同地域要求)。

验证:本地 e2e 25/25 + 附件专项 13/13(含防开放代理/Range/内网改写);
线上真机上传→内网PUT→media.php代理读回→Range 全通过。
2026-09-28 22:12:28 +08:00
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00