/dav/ * 账号:手机号或姓名(同登录) * 口令:**DAV 专用口令**(在「个人设置 → DAV 同步」页生成,不是登录口令) * ⚠️ 这把口令**同时**给 CalDAV 与 CardDAV 用(用户 2026-10-06 第 22 批), * 所以手机上填一次就够,不必在日历与通讯录里各配一遍。 * * ⚠️ 为什么单独一个文件而不是塞进 index.php: * CalDAV 用的是 HTTP 方法名(PROPFIND / REPORT / OPTIONS / PUT), * index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西, * 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。 * * 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。 * 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。 */ declare(strict_types=1); require __DIR__ . '/app/bootstrap.php'; use App\Auth; use App\CalDav; use App\Calendar; /** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */ function dav_fail(int $code, string $msg): void { http_response_code($code); header('Content-Type: text/plain; charset=utf-8'); echo $msg; exit; } // ---- 1. 鉴权:HTTP Basic(DAVx⁵ / iOS 都原生支持)---- $user = $_SERVER['PHP_AUTH_USER'] ?? null; $pass = $_SERVER['PHP_AUTH_PW'] ?? null; // 有些 SAPI 不填 PHP_AUTH_USER,得自己从 Authorization 头里拆 if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION'])) { if (preg_match('/^Basic\s+(.+)$/i', (string)$_SERVER['HTTP_AUTHORIZATION'], $m)) { $decoded = base64_decode($m[1], true); if ($decoded !== false && strpos($decoded, ':') !== false) { [$user, $pass] = explode(':', $decoded, 2); } } } $who = Auth::davAuth($user, $pass); if (!$who) { header('WWW-Authenticate: Basic realm="FamilyBook DAV"'); dav_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。'); } $familyId = (int)$who['family_id']; $me = (int)$who['person_id']; $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); $path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_URL_PATH); $hit = CalDav::parsePath($path); $bookId = (int)$hit['book']; $evId = (int)$hit['event']; // ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)---- if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) { dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。'); } // ---- 3. OPTIONS:客户端第一句就问"你支持什么" ---- if ($method === 'OPTIONS') { http_response_code(200); header('DAV: 1, 2, calendar-access'); header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT'); header('MS-Author-Via: DAV'); exit; } // ---- 4. 找不到那本 → 404(不要回 200 空列表:客户端会以为"这本是空的")---- $cal = null; if ($bookId > 0) { $cal = Calendar::mineOne($familyId, $me, $bookId); if (!$cal) { dav_fail(404, '没有这本日历。'); } } /** 日历本的显示名:带上是"谁的本"(共享来的本子同名时能分清) */ $dispName = static function (array $c) use ($me): string { $nm = (string)$c['name']; if ((int)($c['owner_id'] ?? 0) !== $me && !empty($c['owner_name'])) { $nm .= '(' . (string)$c['owner_name'] . ')'; } return $nm; }; // ---- 5. PROPFIND:清单 / 属性 / 资源列表 ---- if ($method === 'PROPFIND') { $depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0'); if ($bookId === 0) { // 根:列出这个人可见的全部日历本 $parts = []; foreach (CalDav::books($familyId, $me) as $c) { $href = CalDav::hrefOf($c); $parts[] = '' . CalDav::x($href) . '' . '' . '' . '' . CalDav::x($dispName($c)) . '' . '' . CalDav::x('家书 · ' . (string)$c['name']) . '' . '' . CalDav::x((string)$c['color']) . '' . '' . '' . ''; } CalDav::multis(); echo '' . "\n" . '' . implode('', $parts) . ''; exit; } if ($depth === '1') { // 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉" $rows = CalDav::rows($cal, $familyId, $me); $parts = []; foreach ($rows as $r) { $rid = (int)($r['id'] ?? 0); $uid = (string)($r['uid'] ?? ''); // 派生本(观影 / 旅程)没有 calendar_events.id,用 uid 段做资源名 $seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid); if ($seg === '') { continue; } $etag = '"' . md5($uid . '|' . (string)($r['starts_at'] ?? '') . '|' . (string)($r['updated_at'] ?? '')) . '"'; $parts[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' . '' . '' . CalDav::x($etag) . '' . '' . 'text/calendar; charset=utf-8' . ''; } CalDav::multis(); echo '' . "\n" . '' . implode('', $parts) . ''; exit; } // Depth 0:这一天历本自身的属性 CalDav::multis(); echo '' . "\n" . '' . '' . CalDav::x(CalDav::bookHref($bookId)) . '' . '' . '' . '' . CalDav::x($dispName($cal)) . '' . '' . '' . ''; exit; } // ---- 6. GET / HEAD:整本导出成 ics(不少客户端就走这一条)---- if ($method === 'GET' || $method === 'HEAD') { if (!$cal) { dav_fail(404, '没有这本日历。'); } $ics = CalDav::ics($cal, $familyId, $me); $etag = '"' . md5($ics) . '"'; if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) { http_response_code(304); header('ETag: ' . $etag); exit; } http_response_code(200); header('Content-Type: text/calendar; charset=utf-8'); header('ETag: ' . $etag); header('Cache-Control: private, max-age=0, must-revalidate'); $fn = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$cal['name']); header('Content-Disposition: inline; filename="' . $fn . '.ics"'); if ($method === 'GET') { echo $ics; } exit; } // ---- 7. REPORT:calendar-query(按时间范围) / calendar-multiget(按 href 取)---- if ($method === 'REPORT') { if (!$cal) { dav_fail(404, '没有这本日历。'); } $bodyRaw = (string)file_get_contents('php://input'); // calendar-multiget:客户端点明要哪几条(我们按整本回,客户端自己挑) $wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false; // calendar-query 的时间范围(有就按范围裁;没有就整本) $from = ''; $to = ''; if (preg_match('#]*start="([^"]+)"#i', $bodyRaw, $m)) { $from = substr($m[1], 0, 8); // 20260101T000000Z → 20260101 $from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2); } if (preg_match('#]*end="([^"]+)"#i', $bodyRaw, $m)) { $to = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2); } // 单条资源请求(/dav/3.ics/12.ics) if ($evId > 0) { $one = CalDav::oneEvent($bookId, $evId, [$familyId, $me]); if (!$one) { dav_fail(404, '没有这条日程。'); } $evIcs = \App\Ics::export([$one], (string)$cal['name']); CalDav::multis(); echo '' . "\n" . '' . '' . CalDav::x(CalDav::bookHref($bookId) . $evId . '.ics') . '' . '' . '' . CalDav::x('"' . md5($evIcs) . '"') . '' . '' . CalDav::x($evIcs) . '' . ''; exit; } $rows = CalDav::rows($cal, $familyId, $me); $parts = []; foreach ($rows as $r) { $day = substr((string)($r['starts_at'] ?? ''), 0, 10); if ($from !== '' && $day !== '' && $day < $from) { continue; } if ($to !== '' && $day !== '' && $day > $to) { continue; } $rid = (int)($r['id'] ?? 0); $uid = (string)($r['uid'] ?? ''); $seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid); if ($seg === '') { continue; } $evIcs = \App\Ics::export([$r], (string)$cal['name']); $parts[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' . '' . '' . CalDav::x('"' . md5($evIcs) . '"') . '' . '' . CalDav::x($evIcs) . '' . ''; // calendar-multiget 时客户端自己按 href 挑;我们整本给,列表不长(家庭场景) unset($wantsMulti); } CalDav::multis(); echo '' . "\n" . '' . implode('', $parts) . ''; exit; } // ---- 8. 其它方法一律 405(并告知支持哪些)---- header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT'); dav_fail(405, '不支持这个请求方法。');