/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。 * 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。 * * --------------------------------------------------------------------------- * 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。 * * 真根因**不是**"哪里写错了",而是**发现链(discovery)整段缺失**—— * 而这恰好是上一批验收唯一没覆盖的部分(上批只验了 `PROPFIND /dav/` 与 * `PROPFIND /carddav/`,那两步一直是通的)。 * * 标准客户端(DAVx⁵ / iOS / SyncCalendar)建账号时**一定**按 RFC 6764 走这么一条链: * ① GET /.well-known/caldav → 302 到 /dav/ * ② PROPFIND /dav/ → d:current-user-principal → 拿到 principal 路径 * ③ PROPFIND → c:calendar-home-set → 拿到"日历本们的容器" * ④ PROPFIND Depth:1 → 列出各日历本 * ⑤ PROPFIND Depth:1 / REPORT → 取每条日程 * * 实测(线上,本批新写的 _fb_dav_diag.py):① 404、②③ 都回空。 * 于是客户端拿到 207 却**找不到 principal** → 它认为"这个地址不是 DAV 服务器" * → 在"填完账号点保存"那一刻就失败,一条数据都拉不到。 * ⇒ 这就解释了为什么"看起来协议都对、就是没数据"。 * * 本批补齐 ①②③,并顺手修掉两个只有真客户端才暴露的坑(见 REPORT / GET 段注释): * · 派生本(观影/旅程)把"日历视图的逐日条目"当成了"资源",导致同一 href 重复上千次; * · ICS 里的 DTSTAMP 每次生成都写"现在" → ETag 每轮都变 → 增量同步永远不收敛。 */ 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); } } } $method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')); $path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_URL_PATH); /** * ⚠️ `/.well-known/caldav` 是**唯一免鉴权**的入口(RFC 6764 §6.1)。 * 客户端在"还不知道用户名口令对不对"的时候先探这里; * 在这里就 401 的话,很多客户端会直接报"服务器不支持 CalDAV", * 而不是弹口令框 —— 所以这一跳必须在鉴权**之前**。 * * ⚠️ 跳转目标选**上下文路径 `/dav/`**(RFC 6764 §6.1 的原话就是 * "redirect to the context path"),而不是 principal: * `/dav/` 的 PROPFIND 里**同时**带了 current-user-principal、 * calendar-home-set 和日历本清单 —— 三种客户端(要 principal 的、 * 要 home 的、直接当集合用的)一次都满足,少一次往返。 * (本批实测踩过:跳到 principal 只对"严格按 RFC 5397 走的"客户端够用。) */ if ($path === '/.well-known/caldav') { /* 🔴🔴 v42(用户第 10 条)核心修复之一: 这条路径原来**只回 301**(跳 `/dav/`)。匿名 GET 时这是对的; 但 Thunderbird 建账号时**常常是带鉴权直接 PROPFIND 这个地址** (它把 `.well-known/caldav` 当成"DAV 服务的入口"来探,而不是只当跳转)。 对带鉴权的 PROPFIND 回 301 的后果:**TB 不跟这个跳转** (它对"服务发现跳转"的跟随策略与浏览器不同),于是判定 "这里没有 CalDAV 服务" ⇒ 报「找不到日历」。 ⇒ 修法:**按方法分流** · 有鉴权凭据 + PROPFIND → 直接给出与 `/dav/` 一致的发现响应(207, 含 current-user-principal / calendar-home-set / 日历本清单), 客户端**一步到位**,不依赖它对 301 的跟随; · 其余(匿名、GET、OPTIONS…)→ 仍是 301 到绝对 URL `/dav/`(RFC 6764 语义)。 ⚠️ 免鉴权这条必须保住:匿名 OPTIONS/GET 不能 401(否则客户端以为不支持 DAV)。 */ $wkWantPropfind = (strtoupper($method) === 'PROPFIND'); $wkHasAuth = !empty($user) && $pass !== null && $pass !== ''; if ($wkWantPropfind && $wkHasAuth) { $wkWho = Auth::davAuth($user, $pass); if (!$wkWho) { header('WWW-Authenticate: Basic realm="FamilyBook DAV"'); dav_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。'); } $wkFamily = (int)$wkWho['family_id']; $wkMe = (int)$wkWho['person_id']; $wkParts = [ '/dav/' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . '', ]; foreach (CalDav::books($wkFamily, $wkMe) as $wkC) { $wkParts[] = '' . CalDav::x(CalDav::hrefOf($wkC)) . '' . '' . '' . '' . CalDav::x('家书 · ' . (string)$wkC['name']) . '' . '' . ''; } CalDav::multis(); echo '' . "\n" . '' . implode('', $wkParts) . ''; exit; } http_response_code(301); /* 🔴🔴 v42(用户 2026-10-11 第 10 条:「caldav 还是不行,thunderbird 提示找不到日历」) 这里原来是**相对路径** `Location: /dav/`。 ⚠️ RFC 7231 允许相对 Location(客户端按 RFC 3986 解析), 但 Thunderbird 的 CalDAV 服务发现在这一跳上**对相对 Location 不可靠** —— `curl -L` 会跟、浏览器会跟,但 TB 自己不跟, 它把这个 301 当成"这里没有 CalDAV 服务" ⇒ 报「找不到日历」。 而我们的探针用 curl/urllib(都会自动跟)⇒ 全绿, 于是"探针全过、真客户端不行"这个组合一直复现不出来。 ⇒ 改成**绝对 URL**(RFC 6764 §6.1 的示例也是绝对 URL)。 ⚠️ 按**真实请求的 host**拼(不能写死域名): 本站既可能被 family.example.com 访问,也可能被 IP / 其它别名访问, 写死会把客户端带到别的域(跨域后鉴权头会丢)。 */ $wkHost = (string)($_SERVER['HTTP_HOST'] ?? ''); $wkProto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; // 走代理(宝塔 nginx→apache)时协议可能丢,再从 X-Forwarded-Proto 补一次 if ($wkProto === 'http' && !empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) { $wkProto = (string)$_SERVER['HTTP_X_FORWARDED_PROTO']; } $wkTarget = ($wkHost !== '') ? ($wkProto . '://' . $wkHost . '/dav/') : '/dav/'; header('Location: ' . $wkTarget); exit; } $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']; /** 统一的尾斜杠形式,供"发现链路径"比对(`/dav` 与 `/dav/` 视为同一个) */ $norm = '/' . trim($path, '/') . '/'; $hit = CalDav::parsePath($path); $bookId = (int)$hit['book']; $evSeg = (string)($hit['seg'] ?? ''); // 资源名(可能是 'movie-100_familybook') $evId = (int)$hit['event']; // 数字形式(自定义日程 id;派生条目恒为 0) /** * 发现链上的三个固定节点。 * ⚠️ 它们**不是日历本**,必须在下面的"找不到那本 → 404"之前识别出来, * 否则会被当成 bookId=0 走"列日历本"那条路 —— 客户端就问不到 principal 了。 * * ⚠️⚠️ `$isRoot` **只**指**站点根 `/`**,不包括 `/dav/`: * 这里踩过一次(本批实测)—— 把 `/dav/` 也算进 isRoot,于是 * `PROPFIND /dav/` 只回一行 principal、**一本日历都不列**, * 客户端拿不到任何日历本 ⇒ 仍然是"同步不出数据"。 * `/dav/` 是**日历本的容器**,必须走 5c 的列表分支。 * (`/dav/` 的响应里会**同时**带上 current-user-principal 与 calendar-home-set, * 所以把 /dav/ 当入口的老客户端也一次就能往下走。) */ $isRoot = ($path === '/' || $path === ''); $isPrincipal = ($norm === CalDav::PRINCIPAL_PATH); $isHome = ($norm === CalDav::HOME_PATH); // ---- 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, 3, 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; }; /** 一个 `d:propstat` 里塞当前用户的读权限(只读实现,所以恒为 read) */ $readOnlyPriv = '' . ''; /** * 输出一段 multistatus(207 + XML 头)。 * ⚠️ 抽成函数是因为发现链那几个分支要吐**结构完全不同**的 XML, * 内联三份 implode 容易漏掉某一份的命名空间声明。 */ $emit = static function (string $xmlns, array $parts): void { CalDav::multis(); echo '' . "\n" . '' . implode('', $parts) . ''; }; // ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ---- if ($method === 'PROPFIND') { $depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0'); // 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。 // 客户端拿它当"从这个域名开始找 DAV 服务"的起点。 // 🔴 v33:同时给出 **两套** home-set(calendar + addressbook)—— // 站点根是 CalDAV/CardDAV **共用的**发现起点,客户端只会看自己要的那个属性。 // ⚠️ 光靠这段还不够:线上 Apache 会把 `PROPFIND /` 交给 index.php // (→ 302 登录页),所以 `.htaccess` 里必须补一条"DAV 方法才转发"的规则。 if ($isRoot) { $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" ' . 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [ '/' . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . '', ]); exit; } // 5b. principal:客户端拿 calendar-home-set 才能知道"去哪列日历本"。 // ⚠️ 还要回 `current-user-principal`(指向自己)—— 客户端**在 principal 上 // 也会再问一遍这个属性**(RFC 5397 里 principal 资源本就该有它)。 // 本批实测:不回的话,严格按 RFC 走的客户端会在这里判"拿不到 principal" // 而中止建账号(探针的"照它报的地址再走一步"那两条正是抓这个的)。 if ($isPrincipal) { $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" ' . 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [ '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . $readOnlyPriv . '', ]); exit; } // 5c. home-set:列出这个人可见的全部日历本。 // ⚠️ `PROPFIND /dav/` 也走这条路(用户与老客户端都直接把 /dav/ 当 home 用), // 差别只在 href 报哪个 —— 客户端会拿它当后续请求的 base,报错就 404。 if ($isHome || $bookId === 0) { $baseHref = $isHome ? CalDav::HOME_PATH : '/dav/'; $parts = []; // 🔴🔴 2026-10-11(本批线上实测发现的协议隐患,修之): // RFC 4918 规定 **Depth:0 只描述「请求的那个资源本身」**, // 而这里原来无论 depth 都把**所有日历本**一起回。 // 线上实测的坏结果:`PROPFIND /dav/ Depth:0` 的**第一条 response // 是 `/dav/1.ics/`**(日历本自己,**不含 current-user-principal**) // —— 严格客户端(Thunderbird / iOS)只读"与请求 URL 匹配的那条"或 // 第一条,就可能判"拿不到 principal"而中止。 // ⇒ 按 depth 分流: // · Depth:0 → **只回 `/dav/` 自身**(principal、home-set 都在这一条里, // 所以"把 /dav/ 当入口的老客户端"仍然一次就能往下走); // · Depth:1 → 自身 + 各日历本。 if ($depth !== '1') { $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" ' . 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [ '' . CalDav::x($baseHref) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . $readOnlyPriv . '', ]); exit; } 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']) . '' . '' . $readOnlyPriv . ''; } // 🔴 v33:**始终**先输出 home 集合自身。 // RFC 4918 规定 PROPFIND 响应**必须包含被请求的资源本身**,Depth:1 也是。 // 原来只在 `!$isHome`(请求 `/dav/`)时才输出,于是 // `PROPFIND /dav/calendars/`(Thunderbird 走的就是这条)只回子本、没有自己 // ⇒ 严格客户端判定"这个 home 不存在" ⇒ 日历空。 $parts[] = '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . $readOnlyPriv . ''; // 首次请求 `/dav/`(bookId=0 且不是 home 路径)时,把发现链的三个答案一并给出: // 有些客户端在 /dav/ 上**同时**问 current-user-principal —— 少给一个它就又断了。 if (!$isHome) { array_unshift($parts, '' . CalDav::x($baseHref) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . ''); } $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/"', $parts); exit; } if ($depth === '1') { // 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉" $rows = CalDav::resources($cal, $familyId, $me); $parts = []; foreach ($rows as $r) { // ⚠️ 资源名走 CalDav::segmentOf(与 REPORT / GET 同一处算)—— // 三处各自拼字符串的话,客户端 PROPFIND 记下的 href // 与它 REPORT 时用的 href 对不上,就会反复重拉。 $seg = CalDav::segmentOf($r); if ($seg === '') { continue; } $etag = CalDav::resourceOf($r, (string)$cal['name'])['etag']; $parts[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' . '' . '' . CalDav::x($etag) . '' . '' . 'text/calendar; charset=utf-8' . ''; } $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts); exit; } // Depth 0:这一天历本自身的属性 $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [ '' . CalDav::x(CalDav::bookHref($bookId)) . '' . '' . '' . '' . CalDav::x($dispName($cal)) . '' . '' . $readOnlyPriv . '', ]); exit; } // ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ---- if ($method === 'GET' || $method === 'HEAD') { if (!$cal) { dav_fail(404, '没有这本日历。'); } // 6a. 单条资源(/dav/3.ics/12.ics)—— 客户端增量同步走的就是这条。 // 🔴 v31 修的真 bug:原来**没有**这个分支,于是 `/dav/1.ics/movie-100_familybook.ics` // 落进了下面的"整本导出",一次回 2981 条 VEVENT(634 KB)。 // 客户端拿一条资源却收到整本 → 要么把整本当成"这一条"的正文(爆炸式重复入库), // 要么因为 UID 不匹配而判定"资源不存在"。**两种都是"同步不出数据"**。 if ($evSeg !== '') { $one = CalDav::oneResource($cal, $familyId, $me, $evSeg); if (!$one) { dav_fail(404, '没有这条日程。'); } $res = CalDav::resourceOf($one, (string)$cal['name']); $etag = $res['etag']; 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); if ($method === 'GET') { echo $res['ics']; } exit; } // 6b. 整本导出(老客户端走这条把整本当一段 ics 读)。 // ⚠️ 用 resources()(按 UID 去重)而不是 rows():整本里同一条日程出现 2399 次, // 客户端会把它当成 2399 个不同事件灌进本地库。 $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-multiget(按 href 取) / calendar-query(整本或按时间范围)---- if ($method === 'REPORT') { if (!$cal) { dav_fail(404, '没有这本日历。'); } $bodyRaw = (string)file_get_contents('php://input'); // calendar-multiget:客户端点明要哪几条。 // 🔴 v31:这里**改成真的按 href 过滤**(原来"整本回、客户端自己挑")。 // 原因:现在 href 是确定性的(`/dav//.ics`,seg 由 segmentOf 统一给), // 按 href 取回来的资源名 → 直接反查,不再需要猜"绝对 URL / 前缀 / 编码"。 // 整本回的问题在派生本上尤其严重(2981 条里 2785 条是重复的同一 href)。 $wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false; $wantHrefs = []; if ($wantsMulti) { // 只认 href 的最后一段(前面的域名/前缀怎么变都不影响) if (preg_match_all('#<[^>]*href[^>]*>([^<]+)]*href>#i', $bodyRaw, $mm)) { foreach ($mm[1] as $h) { $h = trim(html_entity_decode($h, ENT_QUOTES | ENT_XML1, 'UTF-8')); $seg = preg_replace('/\.ics$/', '', (string)basename(rtrim($h, '/'))); if ($seg !== '') { $wantHrefs[$seg] = true; } } } } // calendar-query 的时间范围(有就按范围裁;没有就整本) $from = ''; $to = ''; if (preg_match('#]*start="([^"]+)"#i', $bodyRaw, $m)) { $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 ($evSeg !== '') { $one = CalDav::oneResource($cal, $familyId, $me, $evSeg); if (!$one) { dav_fail(404, '没有这条日程。'); } $res = CalDav::resourceOf($one, (string)$cal['name']); $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [ '' . CalDav::x(CalDav::bookHref($bookId) . $evSeg . '.ics') . '' . '' . '' . CalDav::x($res['etag']) . '' . '' . CalDav::x($res['ics']) . '' . '', ]); exit; } $rows = CalDav::resources($cal, $familyId, $me); $parts = []; foreach ($rows as $r) { $seg = CalDav::segmentOf($r); if ($seg === '') { continue; } if ($wantsMulti && !isset($wantHrefs[$seg])) { continue; // multiget:只要点名的 } $day = substr((string)($r['starts_at'] ?? ''), 0, 10); if (!$wantsMulti) { if ($from !== '' && $day !== '' && $day < $from) { continue; } if ($to !== '' && $day !== '' && $day > $to) { continue; } } $res = CalDav::resourceOf($r, (string)$cal['name']); $parts[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' . '' . '' . CalDav::x($res['etag']) . '' . '' . CalDav::x($res['ics']) . '' . ''; } $emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts); exit; } // ---- 8. 其它方法一律 405(并告知支持哪些)---- header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT'); dav_fail(405, '不支持这个请求方法。');