Files
FamilyBook/nav_icon.php
T
laoyang a5e559b2b3 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() 不再报错。
2026-10-07 11:35:31 +08:00

106 lines
4.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;