.ics/ (0) → 某一本的属性(displayname / color / 组件集) * PROPFIND /dav/.ics/ (1) → 这本里所有日程的资源清单(getetag) * REPORT /dav/.ics/ → calendar-query / calendar-multiget,取正文 * GET /dav/.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/.ics $parts = explode('/', $rest); $bookSeg = $parts[0]; $book = (int)preg_replace('/\.ics$/', '', $bookSeg); $seg = ''; if (isset($parts[1]) && $parts[1] !== '') { // 单条资源:.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 个 ``,只有 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']); } }