Files
laoyang ef574d82a4 v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢
根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段):
 · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate;
 · dav.php 只取 $list[0](覆盖行全丢);
 · calendar_events 没有对应列。

修法:
 · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动;
   UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/
   LAST-MODIFIED 仍按 RFC 保留 UTC。
 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id),
   FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端
   「只删这一次」共用);PUT 的例外语义 = 权威替换。
 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐,
   表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。
 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4
   与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。
 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。
 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。

Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。

验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。
反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。
2026-10-15 20:19:09 +08:00

440 lines
22 KiB
PHP
Raw Permalink 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/';
/**
* 🔴🔴 v41(用户 2026-10-10 第 11 条:「caldav 和 carddav 功能还是不好用,
* thunderbird 返回的结果是,找不到联系人,找不到日历本,再仔细检查这个功能,
* 这次要务必彻底解决。」)
*
* 真根因(线上实测出来的,不是猜的):
* 原来 `HOME_PATH = '/dav/calendars/'`,而**日历本自己的 href 是 `/dav/1.ics/`**
* —— 也就是说 **日历本并不在 home-set 之下**。
* Thunderbird(严格按 RFC 4791/6352):拿到 `calendar-home-set` 后,
* PROPFIND 它、**只看它的成员**当作日历本 ⇒ 那里一个都没有
* ⇒ 报「找不到日历本」。CardDAV 侧一模一样(`/carddav/addressbooks/`
* vs 通讯录 `/carddav/1.vcf/`)⇒ 「找不到联系人」。
*
* ⚠️ 为什么 v33 那批没抓出来:那批的探针**只验证"发现链每一步都回 207"**,
* 而每一步确实都回了 207、principal 与 home-set 也都拿到了 ——
* **层级关系错了这件事,单个请求的返回码看不出来**。
* ⇒ 新探针必须断言"**home-set 的成员里必须有日历本/通讯录**"。
*
* 修法:让 home-set 就指向**本协议自己的根**(`/dav/`、`/carddav/`)——
* ① 成员关系立刻成立(`/dav/1.ics/` 确实在 `/dav/` 之下);
* ② **所有既有 URL 一字不变**(客户端里配的 `/dav/`、`/dav/1.ics/` 继续有效,
* 不需要用户重新配置,也不会让已同步的客户端全部失效);
* ③ 少一层跳转。
* ⚠️ 别改成"把日历本挪到 /dav/calendars/ 下面"——那会让**每一个用户**的
* 客户端配置作废(已订阅的日历会 404),代价大得多。
*/
public const HOME_PATH = '/dav/';
/** 通讯录那边同义(见 App\CardDav,两边刻意保持一致) */
public const CARD_PRINCIPAL_PATH = '/carddav/principal/';
public const CARD_HOME_PATH = '/carddav/';
// ---------------- 应答 ----------------
public static function xmlHeader(): void
{
/* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**关键对齐**。
抓参照(DAViCal)发现:它**每一个响应**(不只 OPTIONS)都带两行 `DAV:` 头:
DAV: 1, 2, 3, access-control, calendar-access, calendar-schedule
DAV: extended-mkcol, bind, addressbook, calendar-auto-schedule
而我们原来**只有 OPTIONS 带**。Thunderbird 的 CalDAV 探测里会读
`DAV:` 头来确认"这确实是个 DAV 服务"(它把 `calendar-access` 当作
"支持 CalDAV" 的判据)。PROPFIND/REPORT 的响应缺这个头时,
部分 TB 版本会认为"服务器没声明 CalDAV 能力"。
⇒ 照参照,在这个**所有 XML 响应都必经**的地方补上。
⚠️ Content-Type 也用**参照的字面形态** `text/xml`(原来 `application/xml`)。
两者都能被解析,但参照是 `text/xml`,用户要求「完全按参照返回」;
且个别老解析器对 `application/xml` 的处理路径不同,对齐更稳。
⚠️ 第二个 `DAV:` 头必须 `false` 追加 —— PHP 同名 header 默认**覆盖**。 */
header('Content-Type: text/xml; charset="utf-8"');
header('DAV: 1, 2, 3, calendar-access, calendar-schedule', false);
header('DAV: extended-mkcol', false);
}
/** 多状态应答(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)。
*
* 🔴🔴 v45(用户第 2 条,照参照 DAViCal 对齐):**一律用 UID 的净化形式**。
* 原来自定义日程用 `calendar_events.id`、派生条目用 UID —— 两种混着。
* 这在"只读"时还凑合(href 是我们自己生成的,客户端只跟着走),
* 但一开**写**就立刻露馅:
* · 客户端 PUT 到 `/dav/1.ics/<UID>.ics`(它按 UID 命名资源,参照也是),
* 服务端却按 id 生成 href ⇒ 它随后 GET/REPORT 自己刚写的那条
* `/dav/1.ics/<UID>.ics` **取不回来**(404)⇒ 同步看起来"写进去了又没了"。
* · 参照 DAViCal 的资源名就是 UID(`syncal-1758000003001-1d8b60.ics`)。
* ⇒ 统一成 UID:客户端写什么名、就能读回什么名。
* ⚠️ UID 为空的行(老数据)回退用 id,保证总有名字。
*/
public static function segmentOf(array $r): string
{
$uid = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($r['uid'] ?? ''));
$uid = $uid === null ? '' : trim($uid);
if ($uid !== '') {
return $uid;
}
$rid = (int)($r['id'] ?? 0);
return $rid > 0 ? (string)$rid : '';
}
/**
* 一个**资源**的 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([self::withExceptions($r)], $calName);
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
}
/**
* 给一条日程行补上它的「重复系列例外」——
* `_skip_dates`(= `EXDATE`,哪几次被取消)与 `_overrides`(= `RECURRENCE-ID` 覆盖行)。
*
* 🔴 v52b(2026-10-15):这两样必须进正文,也必须进 ETag。
* 只进正文不进 ETag 的话:客户端推完覆盖行 → 服务器存下了 →
* 但 ETag 没变 ⇒ 客户端拿 If-Match 再推一次时**判不出"已经是最新的"**,
* 严重时会被判成"服务器没接受"而反复重推(甚至回滚自己的改动)。
* 所以 ETag 由**最终正文**算(本函数产出的东西直接进 Ics::export)。
*
* ⚠️ `isset` 判据是**故意**的:`exportRows()` 出来的行已经带好了这两样
* (批量查过,见 Calendar::exportRows),这里就别再逐条查 ——
* 整本 REPORT 时那会变成 N+1。
*/
public static function withExceptions(array $r): array
{
$eid = (int)($r['id'] ?? 0);
if ($eid <= 0) {
return $r;
}
if (!isset($r['_skip_dates'])) {
$r['_skip_dates'] = Calendar::skipsOf($eid);
}
if (!isset($r['_overrides'])) {
$r['_overrides'] = Calendar::overridesOf($eid);
}
return $r;
}
/**
* 日历本的 ctag 种子 —— 客户端拿 ctag 判「这本整体变没变」。
*
* 🔴 v45(用户第 2 条,照参照 DAViCal 补齐属性时加的)。
* ⚠️ 用**这一本里所有资源 ETag 的摘要**当种子,而不是"现在时间"——
* 否则每请求一次 ctag 都变 ⇒ 客户端认为"本子一直在变" ⇒ 每轮全量重拉
* (与 ETag 潮汐是同一个坑,见 resourceOf 的说明)。
* ⚠️ 这里**不扫全表**:只取 calendar_events 的 (id, updated_at, sequence) 与
* 该本条目数 —— 这几样任一变化都意味着"这本变了",够用且便宜。
*/
public static function ctagSeedOf(array $cal): string
{
$st = Database::get()->prepare(
'SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), \'\') AS mx,
COALESCE(SUM(sequence), 0) AS sq
FROM calendar_events WHERE calendar_id = ?'
);
$st->execute([(int)$cal['id']]);
$r = $st->fetch() ?: [];
/* 🔴 v52b:**例外也要算进 ctag** ——
客户端拿 ctag 判"这本整体变没变",没变就跳过整轮拉取。
"只改其中一次"(取消某次 / 把某次挪到别的时间)**不动 calendar_events 那一行**
(`updateEvent` 都没被调),所以只按上面那个种子算的话 ctag 恒定不变 ⇒
客户端以为没变、永远拉不到这次改动(表现:"我在手机上改的,另一台设备上看不见")。 */
$seed = (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
$st2 = Database::get()->prepare(
'SELECT COUNT(*) AS n, COALESCE(SUM(CAST(o.sequence AS SIGNED)), 0) AS sq,
COALESCE(MAX(o.updated_at), \'\') AS mx
FROM calendar_event_overrides o
JOIN calendar_events e ON e.id = o.event_id
WHERE e.calendar_id = ?'
);
$st2->execute([(int)$cal['id']]);
$o = $st2->fetch() ?: [];
$seed .= '|' . (string)($o['n'] ?? 0) . '|' . (string)($o['mx'] ?? '') . '|' . (string)($o['sq'] ?? 0);
$st3 = Database::get()->prepare(
'SELECT COUNT(*) AS n, COALESCE(MAX(s.created_at), \'\') AS mx
FROM calendar_event_skips s
JOIN calendar_events e ON e.id = s.event_id
WHERE e.calendar_id = ?'
);
$st3->execute([(int)$cal['id']]);
$s = $st3->fetch() ?: [];
return $seed . '|' . (string)($s['n'] ?? 0) . '|' . (string)($s['mx'] ?? '');
}
/**
* 按**资源名**找一个条目(供单条资源的 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;
}
// 🔴🔴 v45:资源名现在**统一是 UID 的净化形式**(见 segmentOf),
// 所以先按 UID 直接查库 —— 这是最快也最准的一条。
// ⚠️ 为什么不能只靠"遍历 resources() 比对":一本几千条时每条都要
// 全量派生一遍(含 130 年农历换算),代价太大。
$st = Database::get()->prepare(
'SELECT e.* FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
WHERE e.calendar_id = ? AND c.family_id = ? AND e.uid = ? LIMIT 1'
);
$st->execute([(int)$cal['id'], $familyId, $seg]);
$row = $st->fetch();
if ($row) {
// 能看见这本就能看见里面的条目(共享只读也算)
return Calendar::mineOne($familyId, $me, (int)$cal['id']) ? $row : null;
}
// 兼容老式客户端 / 老链接:资源名是**纯数字 id**(v45 之前我们生成的形态)
if (ctype_digit($seg)) {
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
if ($one) {
return $one;
}
}
// 派生资源名:从派生列表里按净化后的 uid 找(派生条目不在 calendar_events 里)
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']);
}
}