Files
FamilyBook/app/CalDav.php
T
laoyang 4ccbcc3360 v45 第 45 批:日程「重复到」换最新控件 + CalDAV 双向同步
用户 2026-10-12 两条:

1. 新建日程里「重复到」的那个日历选择框,改成最新控件。
   - templates/calendar/_event_fields.php:裸 <input type=date> 换成
     .dt-input.dt-antd-single[data-antd-date=1](AntD 纯日期 DatePicker),
     复用既有 mountAntdDate,不新写挂载逻辑。
   - 值载体仍是 name=rep_until 的原生框(包在 .dt-native 里),
     服务端契约与 Repeat::build($_POST) 一字不改。

2. CalDAV 从「只读」改成「双向同步」。
   用户给了一台可用的参照服务器(群晖 DAViCal)要求「完全按参照返回」;
   抓参照 + 复刻 Thunderbird / SyncCalendar 的真实请求序列后确认:
   参照是可写的,而只读会让两个客户端都判「不可用」。
   - dav.php:放行 PUT(新建 201+ETag / 更新 204+ETag / If-Match 不符 412)、
     DELETE(204)、MKCALENDAR(201+Location),仍逐本判权;
     OPTIONS 的 Allow 与 DAV 头照参照补齐(DAV 头两行,注意 PHP header 追加);
     current-user-privilege-set 按实际可写性给,并排除系统派生本(农历等)。
   - app/CalDav.php:资源名统一用 UID(原自定义本用 id、派生本用 uid 两种混着,
     一开写就表现为「客户端 PUT 到 UID 名、服务端 href 是 id 名 ⇒ 写进去读不回」);
     补 owner / getctag / getcontenttype / calendar-description;新增 ctagSeedOf。
   - app/Ics.php:修一个一直存在的真 bug —— 导出 RRULE 漏了 `RRULE:` 前缀
     (库里存的是去前缀的值),导致所有导出/同步出去的重复日程都变成单次。
   - templates/home.php:首页红线补 data-now-min(给探针做零容差判据用)。

验证:本地 e2e 1510/0、TB 探针 35/0、DAV 诊断 28/0、
v43 真机 33/0 + 反向 9/0、v44 真机 41/0 + API 63/0 + 反向 19/0、
v45 重复到 10/0 + 反向 2/0、v45 DAV 双向 24/0 + 反向 5/0;
全流水线 PUSH_RC=0(含部署静态断言第 45 批形态)。
2026-10-12 21:40:53 +08:00

369 lines
18 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/';
/**
* 🔴🔴 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
{
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)。
*
* 🔴🔴 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([$r], $calName);
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
}
/**
* 日历本的 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() ?: [];
return (string)($r['n'] ?? 0) . '|' . (string)($r['mx'] ?? '') . '|' . (string)($r['sq'] ?? 0);
}
/**
* 按**资源名**找一个条目(供单条资源的 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']);
}
}