7 Commits
Author SHA1 Message Date
laoyang ffde47d476 v45c 修复:Thunderbird「找不到日历/联系人」真根因 —— propstat 缺 <d:status>
用户 2026-10-13 再次反馈「还是不行,仍然提示找不到日历」。
按用户要求,在线上装了临时请求日志器(记录 TB 发来的每个请求与我们的每条响应)。
但真正的突破口是**读 Thunderbird 自己的源码**:TB 就在本机,
解开 C:/Program Files\Mozilla Thunderbird\omni.ja,拿到 modules/caldav/*.sys.mjs。

TB 157 的 PROPFIND 解析器(modules/caldav/CalDavRequest.sys.mjs,
PropfindResponse.get data)逐字是:

    for (const propStat of response.querySelectorAll(":scope > propstat")) {
      const status = propStat.querySelector(":scope > status").textContent;   // ← 无 null 保护
      ...
    }

我们的每个 <d:propstat> **都没有 <d:status> 子节点**(RFC 4918 要求有),
于是 querySelector 返回 null → null.textContent 抛 TypeError →
detectCollection() 整条发现链中断 → 客户端报「找不到日历 / 联系人也没有」。
**CalDAV 与 CardDAV 同型**,所以用户看到的「日历和联系人都没有」是同一个 bug。

为什么历史验证全都没抓到(三层叠加):
  1. 老 DAV 探针只做 XML 正则匹配,从不校验 RFC 结构;
  2. curl 不会因为缺 status 报错 —— 响应看着完全正常(207 + 属性齐全);
  3. 只有「把客户端的解析器真跑一遍」才现形。

改动:
  · dav.php 12 处 + carddav.php 12 处
    `</d:prop></d:propstat>` → `</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>`

验证(含新增的永久守护):
  · 新增 `_tb_parse_repro.js`:用 jsdom 逐字复刻 TB 那段解析逻辑喂真实响应。
    修复前:线上 10/10 个 propstat 全部 TypeError;修复后:0 个异常。
  · 新增 `_tb_detect_sim.js`:完整复刻 TB 的发现算法(detectCollection /
    handlePrincipal / handleHomeSet 三函数照抄)并真发请求。
    修复前线上三条路径全部抛异常 ⇒ 0 本日历(与用户症状一致);
    修复后线上 **发现 8 本日历**,CardDAV 1 个通讯录。
  · `_fb_dav_tb.py` 新增 ⑨「TB 解析器结构守护」5 条断言(纯 Python,不依赖 Node)
    → 探针 39 → 44 项,全绿。
  · `_b45_dav_falsify.py` 新增 2 个注入(dav/carddav 各去掉 status)证明断言会红
    → 反向验证 7 → 9 项,全绿。
  · `familybook_deploy.sh` 新增静态断言(`</d:prop></d:propstat>` 出现次数必须为 0)。
  · 本地 e2e 1510/0;TB 探针 44/0;部署脚本跑通。

⚠️ 顺带修一个反向验证自身的坑:`patch()` 原来只替换第一处出现,
   而 `</d:prop><d:status>…</d:propstat>` 在文件里有 12 处、第一处在 /.well-known 分支,
   导致注入没落到被测路径上 ⇒ 断言假绿。已改为替换全部出现。
2026-10-13 08:20:07 +08:00
laoyang feff31baad v45b 修复:Thunderbird「找不到日历/联系人」的真根因
用户 2026-10-12 晚:「dav 还是不行,thunderbird 提示找不到日历,联系人也没有。」

真根因(一个由两半无缝衔接构成的 bug):
  TB 添加账号 → GET /.well-known/caldav → 我们 301 到 /dav/
              → TB **跟随这个 GET 跳转** → GET /dav/
              → 我们回 **404「没有这本日历」**
              → TB 判定"这里没有日历" ⇒ 报「找不到日历」。
  CardDAV 完全同型(/.well-known/carddav → GET /carddav/ → 404)
  ⇒ 所以日历和联系人是**同一个 bug**。

修法(对齐参照 DAViCal 的状态码语义):
  · GET 打到 home 容器(bookId=0)→ **405**(原来 404)
    404 = "资源不存在" ⇒ 客户端放弃;405 = "资源在、方法不行" ⇒ 客户端改用 PROPFIND。
  · REPORT 打到容器 → **403**(参照实测也是 403)。
  · dav.php 与 carddav.php **两处同修**(同型孪生 bug)。

顺带对齐参照(「完全按参照返回」口径):
  · 每个 XML 响应都带两行 `DAV:` 头(原来只有 OPTIONS 带)——
    CalDav::xmlHeader / CardDav::multis(所有 XML 响应的必经处)。
  · Content-Type 改 `text/xml`(参照的字面形态)。

探针补齐(这类 bug 长期漏网的三个原因):
  · 原 TB 探针只发 PROPFIND/REPORT,**从不发 GET /dav/**;
  · curl 默认不跟随跳转(要 -L);
  · 得先 GET .well-known 再跟到 /dav/ 才显形。
  ⇒ _fb_dav_tb.py 新增 ①c(follow=True 真跟跳转,断言跟随后不能 404、应为 405);
     _fb_dav_tb.py 支持 FB_SITE(供反向验证指向注入副本)。

验证:
  · TB 探针 35 → **39/0**(新增 4 条 ①c 全过)
  · v45b DAV 反向验证 **7/0**(新增 2 个注入:dav/carddav 容器 GET 改回 404
    —— 都已证明会让断言变红)
  · 全流水线 PUSH_RC=0
  · 线上真机复验:跟随 well-known 的 GET 跳转 → CalDAV 405 / CardDAV 405
    (修复前都是 404);PROPFIND 带两行 DAV 头 + text/xml;
    发现链 207、8 个日历本、1 个通讯录。
2026-10-13 00:29:55 +08:00
laoyang 81dfacaf1b v42 第 42 批:十一条反馈(界面 9 条 + CalDAV 两处新根因)
CalDAV(用户「还是不行,thunderbird 提示找不到日历」):
- 🔴 `.well-known/caldav` 原来**只回 301**。Thunderbird 建账号时常是
  **带鉴权直接 PROPFIND** 这个地址(把它当 DAV 服务入口探),而它对这一跳的
  301 **不跟随** ⇒ 判定"这里没有 CalDAV" ⇒ 找不到日历。
  ⇒ 按方法分流:带鉴权 PROPFIND → 直接 207 给发现内容(principal / home-set /
    日历本清单,一步到位);其余仍是 301。carddav 同修。
- 🔴 `Location` 由**相对** `/dav/` 改成**绝对 URL**(按真实 HTTP_HOST 拼,
  协议从 HTTPS / X-Forwarded-Proto 取)。TB 在服务发现这一跳对相对跳转不可靠。
- 线上终验:匿名 GET 301+绝对URL;带鉴权 PROPFIND 直接 207(含 principal/home-set);
  Depth:0 只回自身;Depth:1 列出 12 本日历。

界面九条:
- ① 新建旅程不再带入旧数据(真根因:`_list_body.php` 的 `foreach ($rows as $t)`
  循环后 `$t` 残留,而 `form.php` 读的正是 `$t` ⇒ 弹框被填上列表最后一趟的数据)。
  修:循环变量改 `$row` + `form.php` 契约兼容(`$tIn` 优先)。
- ②③④ 行程弹框里的 AntD 时间控件 / 出行方式联动车牌与车次 / 高德地址联想全部失效
  (真根因:AJAX 片段里的内联 `<script>` **既不执行也不保留**)。
  ⇒ 加 `runInlineScripts()` 把片段内联脚本抠出来执行;
  另修 `app.js`:`if (!hasMonth) return` 的短路让 `window.fbBootAntd` 从未被定义,
  把它提到所有 return 之前。
- ④b⑤ 标签改「照片」「时间范围」。
- ⑥ 简介补"还没有简介,点这里写一句"的占位(简介本身一直在,是那条 summary 为空)。
- ⑦ 资源编辑复用新建弹框(`#resAddCard`),并在其底栏补「共享」「附件」入口
  (第 7 条改动后这两个能力一度无入口)。
- ⑧ 纪念日「月/日」→「日期」+ AntD 纯日期控件(新增 mountAntdDate / [data-antd-date]),
  存储契约仍是 month + day。
- ⑨ 编辑纪念日弹框关不掉(关闭逻辑原来只在 days/index.php,而 days/edit 是整页)
  ⇒ 搬进 days/form.php 自包含。

验证:e2e 1510/0;v42 真机 33/0;DAV·TB 35/0;DAV 反向 8/0;
b23 55/0;b28 39/0;b32 26/0;b32 反向 5/0;v31 DAV 28/0;b31 反向 8/0;
部署断言空跑 337/0;完整流水线 PUSH_RC=0。
2026-10-11 18:35:03 +08:00
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00
laoyang 5da4de0d20 v33 第 33 批:DAV(Thunderbird)三处真根因修复
用户原话:
  看看 dav 功能,还是不好用,thunderbird 提示这两个地址下没有任何联系人和日程。

关键教训:v31 那个探针(复刻 SyncCalendar 的 DavClient)线上 28/0 全过,
但用户还是空的 —— 因为 Thunderbird 的请求形状不一样:它真正取数据用的是
REPORT(calendar-multiget / addressbook-multiget),而 v31 探针只走
PROPFIND + GET。复刻了 A 客户端全绿,证明不了 B 客户端能看见东西。

三处真根因(都靠新探针 _fb_dav_tb.py 逐步 dump 原始 XML 抓出):
① CardDAV 的 REPORT 回的是草案名 <card:address-data>,而 RFC 6352 规定是
   <card:addressbook-data>。Thunderbird 要后者 → 拿到 0 个数据块 → 通讯录永远空。
   修:新增 $cardDataXml 闭包,照客户端点名的属性回正文(没点名默认正式名)。
   注:第一版写成两个都发 → 正文重复一份、e2e 的 BEGIN:VCARD 计数翻倍,改成按需回。
② home-set 的 Depth:1 响应里没有集合自身。RFC 4918 要求 PROPFIND 响应必须包含
   被请求的资源本身,原来只在非 home 请求时才输出 → PROPFIND /dav/calendars/
   只回子本、没有自己 → 严格客户端判 home 不存在 → 一本都看不见。
   修:dav.php / carddav.php 的 home 分支始终先输出集合自身。
③ 站点根 PROPFIND / 被 Apache 交给 index.php → 302 跳登录页 → Thunderbird 判定
   这个域名下没有日历(RFC 6764 允许从域名根发现)。
   修:router.php(本地)+ .htaccess / 部署脚本 5c 段独立标记块(线上):
   RewriteCond 限住 REQUEST_METHOD + RewriteRule ^$ dav.php;
   必须限方法,否则 GET / 被 ^$ 吞掉、首页 404(已加反向守卫)。
   并让根响应与两个 principal 都给出两套 home-set(calendar + addressbook)。

验证:线上真机 _fb_dav_tb.py 25/0(站点根通 / home-set 含自身 / CardDAV REPORT
真带回 3 个数据块,修复前是 0);逐本 REPORT 所有集合都能取回数据;
旧诊断(SyncCalendar 序列)28/0 无回归;GET / 仍是 302 未被吞。
本地 e2e 1503/0、v33 DAV 反向验证 7/0(三处各自改回旧行为 → 断言全变红);
部署脚本静态断言段本地空跑通过。
2026-10-09 08:45:22 +08:00
laoyang be582569dd v31 第 31 批:DAV(CalDAV/CardDAV)同步修复 —— 发现链缺失 + ETag 潮汐 + 资源重复
用户原话:「carddav 和 caldav 好像都不好用,没有任何数据被同步出来。
同步的客户端,在 D:/Yongquan/code/SyncCalendar 中有,你可以用这个里面的代码试试同步一下。」

三处真根因**全部靠实测**拿到(不是"看代码猜"):写了 _fb_dav_diag.py,
逐字复刻 SyncCalendar 的 DavClient 请求序列(PROPFIND/REPORT/GET + 它那几个 XML 体),
打线上一次就复现出用户症状(15 条失败),修完线上 28/0。

1. 发现链(discovery)**整段缺失** ← 这就是"没有任何数据"的根因
   标准客户端(DAVx⁵ / iOS / SyncCalendar)建账号按 RFC 6764 走:
     GET /.well-known/caldav → 302 到 /dav/        ← 原来 404(落到 index.php 去登录页)
     PROPFIND <上下文路径> → current-user-principal ← 原来回的是"日历本清单",没有 principal
     PROPFIND <principal> → calendar-home-set       ← 原来没这个属性
     PROPFIND <home> Depth:1 / <book> / REPORT      ← 这两步一直是通的
   ① 就断 ⇒ 客户端**在填完账号点保存那一刻**就失败,一条数据都拉不到。
   为什么历次验收全绿:上批只验了 PROPFIND /dav/ 与 /carddav/ —— 恰好是**一直通**的那两步,
   断点在其**前面**,从来没被测过。
   改:两个入口补 PRINCIPAL_PATH/HOME_PATH + /.well-known/* 301(**在鉴权之前**)+
   根/principal/列表三处都给 current-user-principal。.htaccess 用**独立标记块**追加
   well-known 重写规则(本仓库第 4 次"独立入口必须独立块")。

2. DTSTAMP 取 time() → ETag 潮汐(增量同步**永远不收敛**)
   App\Ics::stampOf() 原为"没给时间就 time()",而**派生条目**(观影/行程/生日/农历/
   节假日/节气)没有 updated_at/created_at ⇒ 每生成一次 ICS 就写一个新"现在" ⇒ 正文变
   ⇒ ETag 变。线上实测:同一本连续两次 REPORT **每一条 ETag 都不同**,整本 GET 的 ETag
   也在变 ⇒ 客户端把"增量同步"退化成"每轮全量重拉",且拉回来的对不上、永远不收敛。
   改:缺时间写固定值 19700101T000000Z(确定优先于"诚实的当前时间")。

3. 派生本把"日历视图条目"当"CalDAV 资源"
   Calendar::exportRows() 对派生本按**日历视图**取数(重复日程展开 N 次、跨天逐日、
   生日/农历逐日派生)。线上 /dav/1.ics/ 实测 **2981 个 response / 只有 196 个不同 href**
   (anniv-0_familybook.ics 自己重复 **2399** 次)。CalDAV 的资源是**一个 .ics 文件** ⇒
   客户端要么把同一条反复入库,要么判定服务器异常而中止本轮同步。
   改:CalDav::resources() 按 **UID 去重**(资源名本就是 UID)。

4. 连带两个真 bug(同一段代码顺出来的)
   · exportRows() 派生分支**没按本过滤**:month() 里"任务""纪念日"两段不带 kind 判断
     (它们 cal=0)⇒ 取"观影本"会把全家所有生日一起带出来(2981 条里绝大多数是 anniv)。
   · CalDav::parsePath() 的 $event=(int)$evSeg:派生资源名(movie-100_familybook)
     (int) 后全是 0 ⇒ 单条资源请求被当"整本",GET /dav/4.ics/lunar-...ics 实测回 **400 条**。

5. 顺带补全:系统日历本(农历/节假日/传统节日/节气)原来**导出是空的**
   它们只走派生、没有 calendar_events 行,而 exportRows 只认 custom/movie/trip ⇒
   手机上订阅这几个本一条都没有。改:按 [今年-1, 今年+5] 派生(**封顶**,农历每天一条,
   130 年会有 4.7 万条)。实测耗时 lunarRange 31ms / traditional 51ms / Anniversary 30ms。

改:.htaccess、router.php、dav.php、carddav.php、app/CalDav.php、app/CardDav.php、
app/Calendar.php、app/Ics.php、templates/settings/me.php(补"或只填服务器地址"的提示)。

验证(全部 0 失败):DAV 探针 28/0(**线上修前 15 失败 → 修后 28/0**,同一套探针两处都跑)、
DAV 反向验证 8/0(7 处修复各自改回旧行为,断言全变红)、e2e 1478/0、升级 153/0、
城市 32/0、几何 428/0 + 移动 680/0 + 交互 92/0、b24 41/0、b27 34/0、b28 39/0、b29 38/0、
b30_import 26/0、b30_ui2 16/0、b30_notify 23/0;部署静态断言新增 12 条守卫并逐条证伪通过。
线上 7 个文件 SHA256 与本地一致;/.well-known/caldav→301 到 /dav/ 实测生效。

⚠️ 本轮在部署脚本上真踩到并修掉两个坑(已写进 pitfalls.md):
   · router.php **不发到线上**,把它写进部署断言 → grep "No such file" → 部署中止在
     "改了一半"的状态(迁到 push.sh 第 1 段)。
   · `set -euo pipefail` 下 `X=$(... | grep -c ...)` 在 **0 匹配**时退出码 1 ⇒
     **整个脚本静默退出**(不打 FAIL,日志停在上一行)。已给全部 7 处补 `|| true`。
2026-10-08 20:05:01 +08:00
laoyang 6ceae11293 v24 第 22 批:名册加 CardDAV 同步 + 一把通用 DAV 口令(日历与通讯录共用)
用户 2 条原话:
  ①「名册,增加cardav同步功能。」
  ②「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令来同步,
     避免在第三方客户端进行同步时自己的密码泄露。」

用户拍板的两个岔路(AskUserQuestion):
  · 口令范围 = **一把通用 DAV 口令**(日历 + 通讯录共用),不是两个 ——
    手机上填一次就都通;两把会让用户要在两个页面各配一遍、还容易只配一半。
  · 同步范围 = 与**名册页完全一致**(Person::roster(kind='member')),不另发明判据。

════ 一、 口令统一:caldav_credentials → dav_credentials(Schema v23)════
表**改名**(不是新建一张 carddav_credentials):
  · 老站 RENAME → 已配好的口令一字不动,**所有设备不用重配**;
  · 新装时两表都不在 → 跳过(直接 RENAME 一张不存在的表会抛异常、把升级打断);
  · 两表并存(上次升级跑一半 / 有人手工建过)→ 搬行(INSERT IGNORE)+ DROP 旧表。
⚠️ 迁移**必须**是 RENAME + 存在性守卫(新增 Schema::hasTable),
   只改建表语句里的名字的话,老库会**再建一张空表**、旧表留在原地没人读 →
   用户所有已配好的同步设备立刻全部 401(而且改密码也修不好:新口令写进了新表)。

Auth:caldavIssue/Revoke/Auth/hasCalDav → davIssue/davRevoke/davAuth/hasDav,
新增 davLastUsed()(UI 上显示"最近使用")。

口令管理入口搬到**个人设置 → DAV 同步**(它管的是"我的凭据",同时服务日历与通讯录;
放在日历页会让只想同步通讯录的人在日程里找半天)。
  · 旧路由 calendar/caldav_token|caldav_revoke 保留为 302 → 老书签不 404;
  · 日历本页只留地址 + 一个指路按钮(密码类的东西只该有一个管理入口,
    两处各放一套按钮迟早对不上:这页重置了、那页还显示"已配置")。

════ 二、 CardDAV(全新)════
· app/Vcard.php —— 名册 → vCard **3.0**(iOS/DAVx⁵ 推荐;4.0 在 iOS 上会被降级丢字段)。
  FN 必须有(缺了 iOS 会**整张卡丢掉**)、UID 用 person id(稳定,不随改名变)、
  标签能对上固定词就走 TYPE(手机上显示成"手机/工作"),对不上就原样放进
  X-ABLabel(**不猜**成 HOME —— 猜错了用户看到"家"其实是"奶奶家")。
· app/CardDav.php —— books/rows/one/cardOf/etag/parsePath;ROOT='/carddav/'。
· carddav.php —— OPTIONS / PROPFIND(0,1) / GET / REPORT;**写方法一律 403**。
  只读是与 CalDAV 同样的取舍:与其"半支持"(客户端以为改成功了、服务端却没落库),
  不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
· **地址空间分离、鉴权同一套**:/carddav/ 与 /dav/ 各自独立(资源类型与命名空间
  完全不同,混在一棵树里要靠猜客户端意图,猜错的表现是"连上了但一本都看不到");
  但两者调**同一个** Auth::davAuth、读**同一张** dav_credentials 表 ——
  这正是"一把口令管两种同步"的落点,也保证不会出现"日历能连、通讯录连不上"。
· 路由三处:router.php(本地 php -S)+ .htaccess(书面来源)+ 部署脚本 5c 段。
  ⚠️ 线上用**独立标记块** `# >>> FamilyBook CardDAV`:老站已经有
  `# >>> FamilyBook CalDAV`,把新规则并进那一段会被"标记在不在"的幂等判断
  **整段跳过** → /carddav/ 永远 404,而本地(走 router.php)完全正常。
· 打包白名单加 carddav.php(站点根下的独立入口最容易漏 —— dav.php / upload_stage.php
  都漏过一次,症状都是"线上那个功能 404、本地一切正常")。

════ 三、 🔴 三个真 bug(全是"本地校验全绿"型,靠 vCard 往返测试抓出来)════
把 App\Vcard(导出)与 App\ContactImport(导入)各自单测都是绿的,
但**导出→导入跑一遍**立刻翻出三个:

 ① Vcard::fold **漏了"续行补前导空格"**(照抄 Ics::fold 时抄漏了最后一步)。
    后果:折出来的那几段变成**独立的属性行** → 客户端把半个名字/半个地址当垃圾丢掉。
    而"每行 ≤75 字节""确实折行了"全是绿的 —— 只有「**折行可无损还原**」这条断言能抓。

 ② ContactImport 用 `explode(';')` 给 ADR / N 分段 —— PHP 的 explode **不认识转义**,
    值里写了 `\;`(字面分号)照样在那里拆开 → 地址「青岛市,市南区;某某路1号」被
    从中间切断,后半截(含省名)跑到别的段上;导进来的人地址只剩前半句、
    而且看着像"省没填"。手机通讯录里带分号的地址("XX路1号;3单元")不算罕见。
    → 新增 `splitVcardSegments()`:**按未转义的分号**切,切完再逐段 unescape。
    ⚠️ "先 unescape 再分段"是同一个错的另一种写法(本轮第一版就这么改,往返照样红)。

 ③ Vcard::adrField 的**分号数量对、但位置错一位**(省落到了"邮编"格)。
    第一版 `;;` + 街道 + `;;;` + 省 + `;` —— 总数也是 6,可多出来的那个挤在前面 →
    解析器读段 4 拿到空串。**段数对、总数对、只有位置错一位**,
    所以"数分号个数"的检查发现不了它。

════ 四、 顺带修的一个 ETag 一致性 bug ════
同一张卡在 PROPFIND(清单)与 REPORT(取正文)里原本算出**两个不同的 ETag**
(一个用不带结尾 CRLF 的正文、一个用带 CRLF 的)→ 客户端认为这张卡一直在变、
**反复重拉**(同步永远不收敛),而服务端看不出任何异常(两边都合法)。
→ 新增 `CardDav::cardOf()`,把**正文与 ETag 成对返回**,结尾 CRLF 只加在正文上、
不参与 ETag。修法不是"两处改成一样",而是**让它们没有机会不一样**。

════ 五、 UI ════
· 个人设置页「DAV 同步」卡:两个地址(/dav/ 与 /carddav/)+ 用户名预填 + 一次性明文
  (只显示这一次,走一次性会话槽位)+ 生成/重置/撤销 + "最近使用"。
· 名册页 CardDAV 卡:地址 + "这 N 个人"(人数与名册页一致,用户当场能对上)+
  指路口令的按钮。**不放**生成表单。
· 日历本页改口径:只留 /dav/ 地址 + 指路链接。

════ 六、 验证 ════
- e2e **1106/0**(本批新增约 45 条:CardDAV 全协议、越权 404、只读 403、
  一把口令开两把锁、撤销后两处**同时**失效、口令一次性/不落库、ETag 一致性)
- **vCard 规格体检 36/36**(新工具 `_fb_b22_vcf_spec.php`:转义/折行/多值/标签/
  空名兜底 + **导出→导入往返一致**)
- **真机探针 28/0**(新工具 `_fb_b22_ui.py`:两块 UI 渲染、**在浏览器里点按钮**生成
  口令、刷新后明文消失但状态留下、名册页人数对得上、日历本页改口径、
  撤销后两处 401、全程零 JS 报错)
- 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0 · 结构升级 149/0
- 部署静态断言空跑 **307/0** · 线上部署 134 项自检 0 失败
- **反向验证 14/14**(`_fb_b22_falsify.py`):迁移不干活 / 读旧表 / 断入口 /
  去掉 addressbook 声明 / 只读守卫失效 / 不校验口令 / 越权不再查名册 /
  折行不补空格 / 分段改回 explode / ADR 错位 / UI 卡拿掉 / 明文不再一次性 /
  撤销不删凭据 —— 逐条改回旧行为,对应断言都变红,还原后复绿。

════ 七、 🔴🔴 线上实测挖出的**最重要**的一条:DAV 鉴权在 Apache 上一直是坏的 ════
部署后在线上以 **Web 用户(www)** 身份做端到端验收时,DAV 全部 401 ——
而本地 e2e 全绿。追下去发现两层原因:

 ① **测试假象**:我把 DAV 请求放进了一个**临时探针文件**里,用 `curl` 打它。
    但 Apache 请求 `.php` 文件时**不套用站点根 .htaccess 的 URL 重写** ——
    所以探针里的请求根本没被转给 dav.php/carddav.php,拿到的是**探针自己的 401**,
    不是 DAV 的。第一轮"全 401"有一半是探针自己造的假象。

 ② **真问题**:Apache 默认**不把 `Authorization` 头交给 PHP-FPM** ——
    `$_SERVER['PHP_AUTH_USER']` 与 `HTTP_AUTHORIZATION` **全是 null**。
    实测(这台服务器 Apache 2.4.53,直接监听 80/443,**没有 nginx**):
        基线            : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
        `CGIPassAuth On`: {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
    ⇒ **线上 CalDAV 从来没被真实客户端连上过**(服务端代码是对的、环境少了这一行)。
      本轮 e2e 用 `php -S`,那里 `PHP_AUTH_USER` 是**自己填好的**,所以一路全绿 ——
      又一个"本地好好的、线上不行"。

  修法:部署脚本 5c 段追加**独立标记块** `# >>> FamilyBook DAV auth`,内容
  `CGIPassAuth On`(Apache **2.4.13+ 的核心指令**,不需要额外模块、**不用动宝塔面板**)。
  仓库 `.htaccess` 同步写上(它是那段规则唯一的书面来源)。

  修完后线上端到端**全部通过**(以 www 身份、真实 HTTP 服务、真实数据):
    · CalDAV:`PROPFIND → 207`、**8 本日历本**、`PUT → 403`
    · CardDAV:根 `207` + `<card:addressbook/>`;清单 `207`、**1089 张卡**、1089 个 ETag;
      整本 vcf `200`、1089 张、全是 3.0、`text/vcard`;**最长行 75 字节**(真实数据上
      折行真的发生了);`REPORT → 207` 带 `address-data`;`PUT → 403`;
      `OPTIONS → 200 DAV: 1, 2, 3, addressbook`
    · 导出的卡数 **1089 == 名册人数 1089**(口径一致)
    · 口令错 → `401`
  临时探针用完即删并复验 404;临时插入的口令用完即清(不动用户已配的凭据)。

════ 八、 关于 CalDAV「本来是好的」这件事 ════
严格说:**服务端实现一直是对的**,缺的是 Apache 那一行 `CGIPassAuth`。
也就是说这个仓库里的 CalDAV 之前**上线即不可用**(只是没人用真实客户端试过)。
本批顺手把它一起修好了 —— 现在 /dav/ 与 /carddav/ 都能被 iOS / DAVx⁵ 正常连上。
2026-10-06 17:33:48 +08:00