Files
FamilyBook/app/CalDav.php
T
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

297 lines
13 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
/**
* CalDAV 服务端(v24 用户第 7 条后半)
*
* 用户原话:「再增加caldav功能,就是可以把属于我的日历本,通过caldav协议分享给别人。」
*
* 实现的是 RFC 4791 的一个**只读子集** —— 够 iOS / 安卓 / Thunderbird / DAVx⁵
* 把家书的日历本订阅进来;本阶段不做 PUT/DELETE(在手机上改日程)。
* 少做这一半的代价是"要处理冲突与 ETag 回写",而收益(随时改)并不急。
*
* 客户端会依次发这些请求(这是**必须**按顺序实现的最小集):
*
* OPTIONS /dav/ → 声明支持 DAV: 1, calendar-access
* PROPFIND /dav/ (Depth: 0) → 当前用户可见的日历本清单
* PROPFIND /dav/<book>.ics/ (0) → 某一本的属性(displayname / color / 组件集)
* PROPFIND /dav/<book>.ics/ (1) → 这本里所有日程的资源清单(getetag)
* REPORT /dav/<book>.ics/ → calendar-query / calendar-multiget,取正文
* GET /dav/<book>.ics/ → 整本导出成一个 ics(很多客户端走这条)
*
* 鉴权用 **HTTP Basic**(DAVx⁵ / iOS 都原生支持),口令不是登录口令:
* 见 Auth::davAuth() —— 登录口令绝不该在客户端里被反复存着。
*
* ⚠️ 口令是**日历与通讯录共用**的一把(用户 2026-10-06 第 22 批):
* 同一个用户名 + 同一串口令,既连 /dav/(本文件)也连 /carddav/(App\CardDav)。
* 两个入口各自独立鉴权、互不依赖 —— 「日历能连、通讯录连不上」这种半通状态
* 在协议层就不可能出现(同一套 davAuth、同一张 dav_credentials 表)。
*
* ⚠️ 只读:所有写方法(PUT/DELETE/PROPPATCH)一律 403。
* 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。
*/
declare(strict_types=1);
namespace App;
final class CalDav
{
/** 资源前缀(URL 里的路径) */
public const ROOT = '/dav/';
/**
* 当前用户可见的日历本。与「日程」模块里看到的是同一批
* (我建的 + 共享给我的),口径必须一致,否则手机上少一本、网页上多一本。
*/
public static function books(int $familyId, int $personId): array
{
return Calendar::mine($familyId, $personId);
}
/**
* 日历本 → URL 段。用 id 而不是名字:名字里有中文/空格/斜杠时,
* 客户端与服务端对"怎么编码"的理解常常不一致(实测 DAVx⁵ 会二次编码)。
*/
public static function hrefOf(array $cal): string
{
return self::ROOT . (int)$cal['id'] . '.ics/';
}
public static function bookHref(int $calId): string
{
return self::ROOT . $calId . '.ics/';
}
/**
* 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程"。
*
* 🔴 v31 修的真 bug:`$event` 原来是 `(int)$evSeg` —— 于是**派生条目**的
* 资源名(`movie-100_familybook`、`lunar-2026-10-01_familybook`)
* `(int)` 之后全是 **0**,被当成"没指定单条资源" → 整本导出。
* 实测:`GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 **400 条 VEVENT**。
*
* 这也正是**为什么不能只认数字 id**:资源名有两种(自定义日程用 id、
* 派生条目用净化后的 uid),`(int)` 会把后者静默压成 0。
* ⇒ 改为按 `$seg` 是否为空判断;`$event` 仍给**数字**那份(自定义日程用),
* 同时把原始段原样带出去(`seg`),让调用方按资源名精确匹配。
*
* @return array{book:int, event:int, seg:string}
*/
public static function parsePath(string $path): array
{
$p = (string)parse_url($path, PHP_URL_PATH);
if (strpos($p, self::ROOT) !== 0) {
return ['book' => 0, 'event' => 0, 'seg' => ''];
}
$rest = substr($p, strlen(self::ROOT));
$rest = trim($rest, '/');
if ($rest === '') {
return ['book' => 0, 'event' => 0, 'seg' => ''];
}
// 形如 3.ics 或 3.ics/<seg>.ics
$parts = explode('/', $rest);
$bookSeg = $parts[0];
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
$seg = '';
if (isset($parts[1]) && $parts[1] !== '') {
// 单条资源:<seg>.ics —— 段里可能含点/短横,所以只去掉结尾的 .ics
$seg = (string)preg_replace('/\.ics$/', '', $parts[1]);
}
return ['book' => $book, 'event' => (int)$seg, 'seg' => $seg];
}
/**
* 客户端**发现链**用的固定路径(RFC 6764 / 6352)。
*
* 🔴 v31 修的真 bug:原来这两个入口**完全没实现**,客户端的发现链第一句就断。
* 实测(DAVx⁵ / iOS 每次添加账号都这么做):
* PROPFIND / → 问 current-user-principal → 服务器根本没有 PROPFIND 处理,
* 落到 index.php 的 ?r= 路由 → 返回登录页 HTML(客户端解析失败)
* PROPFIND /dav/ 问 current-user-principal → 我们回的是"日历本清单",
* 里面**一个 principal 都没有** → 客户端判定"这不是个 DAV 服务器"
* 症状正是用户报的「同步不出任何数据」—— 连账号都建不起来,谈何同步。
*
* ⚠️ principal / home-set 不能"就地"写成 `/dav/` 自己:
* 客户端会拿 home-set 当**集合根**再往下 PROPFIND Depth:1。
* 直接回 /dav/ 的话,Depth:1 会要求列出 /dav/ 下所有日历本的**资源**,
* 语义错位且体积巨大。这里给独立路径,行为与"标准 DAV 服务器"一致。
*/
public const PRINCIPAL_PATH = '/dav/principal/';
public const HOME_PATH = '/dav/calendars/';
/** 通讯录那边同义(见 App\CardDav,两边刻意保持一致) */
public const CARD_PRINCIPAL_PATH = '/carddav/principal/';
public const CARD_HOME_PATH = '/carddav/addressbooks/';
// ---------------- 应答 ----------------
public static function xmlHeader(): void
{
header('Content-Type: application/xml; charset=utf-8');
}
/** 多状态应答(207)—— PROPFIND / REPORT 都用它 */
public static function multis(int $code = 207): void
{
http_response_code($code);
self::xmlHeader();
}
/** 把一个 xml 文本片段做最省事的转义(我们只回显自己库里的字符串) */
public static function x(string $s): string
{
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
/**
* 某个日历本里"能列出来"的日程。
*
* ⚠️ 与网页日历同源:导出/订阅的内容必须与页面上看到的一致。
* 派生本(观影 / 旅程)走 Calendar::exportRows,自定义本走 calendar_events。
*/
public static function rows(array $cal, int $familyId, int $me): array
{
return Calendar::exportRows($cal, $familyId, $me);
}
/**
* 一个日历本里,"**资源**"意义上的条目(每个 href 一条)。
*
* 🔴 v31 修的真 bug:原来直接把 rows() 当成资源列表用,而
* `Calendar::exportRows()` 对**派生本**(观影 / 旅程)是按"日历视图"取数的 ——
* 重复日程会展开成 N 次发生、跨天日程逐天出一条、纪念日/农历/节假日逐日派生。
* 实测线上 `/dav/1.ics/`(观影本):**2981 个 `<d:response>`,只有 196 个不同 href**
* (其中一条 `anniv-0_familybook.ics` 自己重复了 2399 次)。
* CalDAV 的资源是**一个 .ics 文件**,同名重复出现会让客户端:
* · 把同一条日程反复入库(删除/去重逻辑整天在跑);
* · 或直接判定"服务器状态异常"而中止本轮同步。
*
* ⇒ 这里按 **UID** 去重(CalDAV 的资源名就是 UID)。同一个 UID 的多条谱系
* (重复日程的每次发生)在**一个资源**里由 RRULE 自己表达,本就该合并。
*
* ⚠️ 保留**第一次**出现的那条:rows() 按时间升序,第一条就是系列里最早的那次,
* 它的 title / 时长信息最完整(跨天条目里也只有第一天带 time)。
*/
public static function resources(array $cal, int $familyId, int $me): array
{
$seen = [];
$out = [];
foreach (self::rows($cal, $familyId, $me) as $r) {
$uid = (string)($r['uid'] ?? '');
if ($uid === '') {
$out[] = $r; // 没有 UID 的(理论上不该有)不参与去重,照样列出
continue;
}
if (isset($seen[$uid])) {
continue;
}
$seen[$uid] = true;
$out[] = $r;
}
return $out;
}
/**
* 资源名(href 的最后一段,不含 .ics)。
*
* - 自定义日程:用 calendar_events.id,稳定且与 oneEvent() 的入参一一对应;
* - 派生条目:id 为 0,用 uid 的净化形式(同书注释:"派生本没有 calendar_events.id")。
*/
public static function segmentOf(array $r): string
{
$rid = (int)($r['id'] ?? 0);
if ($rid > 0) {
return (string)$rid;
}
$uid = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($r['uid'] ?? ''));
return $uid === null ? '' : $uid;
}
/**
* 一个**资源**的 ICS 正文与 ETag —— 与 CardDav::cardOf 同一个套路:
* 正文与 ETag **成对**算出来,不给"两处各算一次"留机会。
*
* 🔴 v31:ETag 必须由**内容**算出。原先 REPORT 里用 md5($evIcs),
* 而 $evIcs 里含每次都在变的 DTSTAMP(见 Ics::stampOf)⇒ ETag 潮汐。
* 现在 DTSTAMP 已确定,但**判据仍钉在"内容哈希"上** —— 这样以后
* ICS 生成器再加什么时间性字段,也不会又变成"每轮都变"。
*
* @return array{ics:string, etag:string}
*/
public static function resourceOf(array $r, string $calName): array
{
$ics = Ics::export([$r], $calName);
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
}
/**
* 按**资源名**找一个条目(供单条资源的 GET / REPORT 用)。
*
* ⚠️ 认两种资源名(与 segmentOf() 一一对应):
* · 纯数字 = calendar_events.id(自定义本)
* · 其它 = uid 净化形式(派生本:movie-100_familybook、lunar-2026-10-01_familybook)
* v31 之前只认数字,于是派生本的单条资源请求会被当成"整本"处理
* (实测 `GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 400 条 VEVENT)。
*
* ⚠️ 数字那一支**直接查库**(`oneEvent`),不走 resources():
* 理论上"合法资源名"必然出现在 resources() 里(href 就是从它生成的),
* 但"必须能取到"和"必须扫全表才能取到"是两件事 —— 一本几千条时
* 每取一条都全量派生一遍(含 130 年农历换算)代价太大。
* 派生本没有 id,只能从列表里找;量小(几十到几百条),可接受。
*/
public static function oneResource(array $cal, int $familyId, int $me, string $seg): ?array
{
$seg = trim($seg);
if ($seg === '') {
return null;
}
// 数字资源名 = calendar_events.id(自定义本)
if (ctype_digit($seg)) {
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
if ($one) {
return $one;
}
}
// 派生资源名:从派生列表里按净化后的 uid 找
foreach (self::resources($cal, $familyId, $me) as $r) {
if (self::segmentOf($r) === $seg) {
return $r;
}
}
return null;
}
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
{
[$familyId, $me] = $familyIdAndMe;
$st = Database::get()->prepare(
'SELECT e.* FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.id = ? AND e.calendar_id = ? AND c.family_id = ?'
);
$st->execute([$eventId, $calId, $familyId]);
$r = $st->fetch();
if (!$r) {
return null;
}
// 共享只读的人也要能取到(能看见这本就能看见里面的条目)
$cal = Calendar::mineOne($familyId, $calId, $me);
if (!$cal) {
return null;
}
return $r;
}
/**
* 一整本导出成 ics 文本(GET 与 REPORT 都用它)。
*
* ⚠️ 走 resources()(按 UID 去重)而不是 rows():日历视图里同一条日程会被
* 重复日程/跨天/派生规则展开成很多条,整本导出时那些都是**同一个资源**。
*/
public static function ics(array $cal, int $familyId, int $me): string
{
return Ics::export(self::resources($cal, $familyId, $me), (string)$cal['name']);
}
}