.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::caldavToken() —— 登录口令绝不该在客户端里被反复存着。 * * ⚠️ 只读:所有写方法(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 与"是不是要单个日程" */ 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]; } $rest = substr($p, strlen(self::ROOT)); $rest = trim($rest, '/'); if ($rest === '') { return ['book' => 0, 'event' => 0]; } // 形如 3.ics 或 3.ics/.ics $parts = explode('/', $rest); $bookSeg = $parts[0]; $book = (int)preg_replace('/\.ics$/', '', $bookSeg); $event = 0; if (isset($parts[1]) && $parts[1] !== '') { // 单条日程:.ics —— uid 里可能含点,所以只去掉结尾的 .ics $evSeg = preg_replace('/\.ics$/', '', $parts[1]); $event = (int)$evSeg; // 我们用的是 calendar_events.id } return ['book' => $book, 'event' => $event]; } // ---------------- 应答 ---------------- 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); } /** 单条日程(按 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 都用它) */ public static function ics(array $cal, int $familyId, int $me): string { return Ics::export(self::rows($cal, $familyId, $me), (string)$cal['name']); } }