v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /

名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
This commit is contained in:
2026-10-07 11:35:31 +08:00
parent 09e30d27d4
commit a5e559b2b3
23 changed files with 2379 additions and 212 deletions
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* 家书 FamilyBook · 导航图标**公开**出口(v24 第 25 批 用户第 9 条新增)
*
* 为什么需要它(两个约束撞在一起,谁都绕不开):
* ① 家书的 `uploads/` 整个目录是 `<Require all denied>`(见 uploads/.htaccess)——
* 所有附件一律经 `media.php` 出去,而 `media.php` **要求登录态**(未登录 401)。
* 线上实测:直接取 `uploads/nav-icons/xxx.png` 得 **403**。
* ② 但**导航展示页 `/nav/?<短语>` 是全站唯一免登录页**(用户明确要求),
* 而它的图标请求也来自浏览器、同样不带登录态。
* ⇒ 于是"要登录才给图"和"公开页面要显示图"直接冲突:图标一个都显示不出来。
* 这正是用户报的「图标也没有」在服务端的那一半原因。
*
* 做法:仿 media.php 开一个**窄得多的**公开出口 ——
* 只服务 `Nav::ICON_DIR` 这一个目录、且文件名必须严格符合
* `md5串.<扩展名>`。别的一律 404(连目录都不列)。
*
* ⚠️ 安全性说明(为什么这里敢免登录):
* · 图标是**公开网站自己的 favicon**,不含任何用户隐私;
* · 文件名是 `md5(网址)`,且这些网址本来就列在**同样是公开的**展示页上 ——
* 这里的暴露面不比展示页本身更大;
* · 只能取到这一层目录里的"名字长得像图标"的文件,路径穿越 / 目录遍历都不可能;
* · 目录写权限在部署账号手里(见部署脚本),Web 侧只读。
*
* 用法:`/nav_icon.php?f=<文件名>&v=<mtime>`
* ⚠️ `v` 只用于缓存击穿,不参与鉴权(值随便传也不会取到别的文件)。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Nav;
/** 统一的失败出口(与 media.php 同一套做法:不吐任何多余信息) */
function nav_icon_fail(int $code, string $message): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$file = (string)($_GET['f'] ?? '');
// ---- ① 只认文件名(杜绝任何路径成分)----
//
// ⚠️ 用 basename 而不是"检查有没有 `..`":前者把 `a/../../x.png` 直接截成 `x.png`,
// 连"要判断什么"都不需要判断;后者总有没想到的编码绕过(%2e%2e、双重编码…)。
$file = basename(str_replace('\\', '/', $file));
// ---- ② 严格白名单:必须是「32 位十六进制 + 图片扩展名」----
//
// ⚠️ 这一条是**真正的门**:没有它,`f=.htaccess` 之类就能被读出去
// (哪怕 basename 已经挡住了路径,文件名本身仍然可以是敏感文件)。
if (!preg_match('/^[a-f0-9]{32}\.(png|jpe?g|gif|webp|svg|ico)$/i', $file)) {
nav_icon_fail(404, '没有这个图标');
}
$abs = FB_ROOT . '/' . Nav::ICON_DIR . '/' . $file;
if (!is_file($abs)) {
nav_icon_fail(404, '没有这个图标');
}
// ---- ③ MIME:由**内容**嗅探,绝不信扩展名 ----
//
// (与 media.php 同一口径。SVG 会被 finfo 认成 text/plain,按扩展名补回。)
$body = (string)@file_get_contents($abs);
$mime = (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) ?: '';
if ($mime === '' || $mime === 'application/octet-stream') {
$mime = 'image/png';
}
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
if ($ext === 'svg' || $mime === 'image/svg+xml') {
$mime = 'image/svg+xml';
}
// 能被浏览器当页面/脚本执行的一律降级(与 media.php 同一条防线)
if (strpos($mime, 'text/html') !== false || strpos($mime, 'javascript') !== false
|| strpos($mime, 'xhtml') !== false) {
$mime = 'application/octet-stream';
}
$etag = '"' . md5($file . '|' . strlen($body)) . '"';
$inm = trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? ''));
if ($inm !== '' && $inm === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
header('Cache-Control: public, max-age=31536000, immutable');
exit;
}
http_response_code(200);
header('Content-Type: ' . $mime);
header('Content-Length: ' . strlen($body));
// ⚠️ 长缓存 + immutable 是安全的:文件名里含 `md5(网址)`,换图必换名;
// 调用方还会再挂一个 `?v=<mtime>`(见 Nav::iconUrl),双保险。
header('Cache-Control: public, max-age=31536000, immutable');
header('ETag: ' . $etag);
header('X-Content-Type-Options: nosniff');
if ($mime === 'image/svg+xml') {
// SVG 能带脚本 → 即便要显示,也禁止执行脚本、禁止加载外部资源
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox");
}
echo $body;
exit;