Files
FamilyBook/dav.php
T
laoyang be582569dd v31 第 31 批:DAV(CalDAV/CardDAV)同步修复 —— 发现链缺失 + ETag 潮汐 + 资源重复
用户原话:「carddav 和 caldav 好像都不好用,没有任何数据被同步出来。
同步的客户端,在 D:/Yongquan/code/SyncCalendar 中有,你可以用这个里面的代码试试同步一下。」

三处真根因**全部靠实测**拿到(不是"看代码猜"):写了 _fb_dav_diag.py,
逐字复刻 SyncCalendar 的 DavClient 请求序列(PROPFIND/REPORT/GET + 它那几个 XML 体),
打线上一次就复现出用户症状(15 条失败),修完线上 28/0。

1. 发现链(discovery)**整段缺失** ← 这就是"没有任何数据"的根因
   标准客户端(DAVx⁵ / iOS / SyncCalendar)建账号按 RFC 6764 走:
     GET /.well-known/caldav → 302 到 /dav/        ← 原来 404(落到 index.php 去登录页)
     PROPFIND <上下文路径> → current-user-principal ← 原来回的是"日历本清单",没有 principal
     PROPFIND <principal> → calendar-home-set       ← 原来没这个属性
     PROPFIND <home> Depth:1 / <book> / REPORT      ← 这两步一直是通的
   ① 就断 ⇒ 客户端**在填完账号点保存那一刻**就失败,一条数据都拉不到。
   为什么历次验收全绿:上批只验了 PROPFIND /dav/ 与 /carddav/ —— 恰好是**一直通**的那两步,
   断点在其**前面**,从来没被测过。
   改:两个入口补 PRINCIPAL_PATH/HOME_PATH + /.well-known/* 301(**在鉴权之前**)+
   根/principal/列表三处都给 current-user-principal。.htaccess 用**独立标记块**追加
   well-known 重写规则(本仓库第 4 次"独立入口必须独立块")。

2. DTSTAMP 取 time() → ETag 潮汐(增量同步**永远不收敛**)
   App\Ics::stampOf() 原为"没给时间就 time()",而**派生条目**(观影/行程/生日/农历/
   节假日/节气)没有 updated_at/created_at ⇒ 每生成一次 ICS 就写一个新"现在" ⇒ 正文变
   ⇒ ETag 变。线上实测:同一本连续两次 REPORT **每一条 ETag 都不同**,整本 GET 的 ETag
   也在变 ⇒ 客户端把"增量同步"退化成"每轮全量重拉",且拉回来的对不上、永远不收敛。
   改:缺时间写固定值 19700101T000000Z(确定优先于"诚实的当前时间")。

3. 派生本把"日历视图条目"当"CalDAV 资源"
   Calendar::exportRows() 对派生本按**日历视图**取数(重复日程展开 N 次、跨天逐日、
   生日/农历逐日派生)。线上 /dav/1.ics/ 实测 **2981 个 response / 只有 196 个不同 href**
   (anniv-0_familybook.ics 自己重复 **2399** 次)。CalDAV 的资源是**一个 .ics 文件** ⇒
   客户端要么把同一条反复入库,要么判定服务器异常而中止本轮同步。
   改:CalDav::resources() 按 **UID 去重**(资源名本就是 UID)。

4. 连带两个真 bug(同一段代码顺出来的)
   · exportRows() 派生分支**没按本过滤**:month() 里"任务""纪念日"两段不带 kind 判断
     (它们 cal=0)⇒ 取"观影本"会把全家所有生日一起带出来(2981 条里绝大多数是 anniv)。
   · CalDav::parsePath() 的 $event=(int)$evSeg:派生资源名(movie-100_familybook)
     (int) 后全是 0 ⇒ 单条资源请求被当"整本",GET /dav/4.ics/lunar-...ics 实测回 **400 条**。

5. 顺带补全:系统日历本(农历/节假日/传统节日/节气)原来**导出是空的**
   它们只走派生、没有 calendar_events 行,而 exportRows 只认 custom/movie/trip ⇒
   手机上订阅这几个本一条都没有。改:按 [今年-1, 今年+5] 派生(**封顶**,农历每天一条,
   130 年会有 4.7 万条)。实测耗时 lunarRange 31ms / traditional 51ms / Anniversary 30ms。

改:.htaccess、router.php、dav.php、carddav.php、app/CalDav.php、app/CardDav.php、
app/Calendar.php、app/Ics.php、templates/settings/me.php(补"或只填服务器地址"的提示)。

验证(全部 0 失败):DAV 探针 28/0(**线上修前 15 失败 → 修后 28/0**,同一套探针两处都跑)、
DAV 反向验证 8/0(7 处修复各自改回旧行为,断言全变红)、e2e 1478/0、升级 153/0、
城市 32/0、几何 428/0 + 移动 680/0 + 交互 92/0、b24 41/0、b27 34/0、b28 39/0、b29 38/0、
b30_import 26/0、b30_ui2 16/0、b30_notify 23/0;部署静态断言新增 12 条守卫并逐条证伪通过。
线上 7 个文件 SHA256 与本地一致;/.well-known/caldav→301 到 /dav/ 实测生效。

⚠️ 本轮在部署脚本上真踩到并修掉两个坑(已写进 pitfalls.md):
   · router.php **不发到线上**,把它写进部署断言 → grep "No such file" → 部署中止在
     "改了一半"的状态(迁到 push.sh 第 1 段)。
   · `set -euo pipefail` 下 `X=$(... | grep -c ...)` 在 **0 匹配**时退出码 1 ⇒
     **整个脚本静默退出**(不打 FAIL,日志停在上一行)。已给全部 7 处补 `|| true`。
2026-10-08 20:05:01 +08:00

437 lines
21 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
/**
* 家书 FamilyBook · CalDAV 入口(v24 用户第 7 条后半)
*
* 「把我的日历本,通过 CalDAV 协议分享给别人」—— 别人在他们的
* iOS 日历 / 安卓 DAVx⁵ / Thunderbird / 群晖日历里填上这个地址就能订阅到。
*
* 地址:https://<站点>/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 <principal> → c:calendar-home-set → 拿到"日历本们的容器"
* ④ PROPFIND <home-set> Depth:1 → 列出各日历本
* ⑤ PROPFIND <book> 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') {
http_response_code(301);
header('Location: /dav/');
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 = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
/**
* 输出一段 multistatus(207 + XML 头)。
* ⚠️ 抽成函数是因为发现链那几个分支要吐**结构完全不同**的 XML,
* 内联三份 implode 容易漏掉某一份的命名空间声明。
*/
$emit = static function (string $xmlns, array $parts): void {
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus ' . $xmlns . '>' . implode('', $parts) . '</d:multistatus>';
};
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
if ($method === 'PROPFIND') {
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
// 客户端拿它当"从这个域名开始找 DAV 服务"的起点。
if ($isRoot) {
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
'<d:response><d:href>/</d:href><d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. '</d:prop></d:propstat></d:response>',
]);
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"', [
'<d:response><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:principal/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
// 5c. home-set:列出这个人可见的全部日历本。
// ⚠️ `PROPFIND /dav/` 也走这条路(用户与老客户端都直接把 /dav/ 当 home 用),
// 差别只在 href 报哪个 —— 客户端会拿它当后续请求的 base,报错就 404。
if ($isHome || $bookId === 0) {
$baseHref = $isHome ? CalDav::HOME_PATH : '/dav/';
$parts = [];
foreach (CalDav::books($familyId, $me) as $c) {
$href = CalDav::hrefOf($c);
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$c['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$c['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>';
}
// 首次请求 `/dav/`(bookId=0 且不是 home 路径)时,把发现链的三个答案一并给出:
// 有些客户端在 /dav/ 上**同时**问 current-user-principal —— 少给一个它就又断了。
if (!$isHome) {
array_unshift($parts, '<d:response><d:href>' . CalDav::x($baseHref) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. '</d:prop></d:propstat></d:response>');
}
$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[] = '<d:response><d:href>'
. CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
. '<d:resourcetype/>'
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
. '</d:prop></d:propstat></d:response>';
}
$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"', [
'<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
]);
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/<book>/<seg>.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('#<c:time-range[^>]*start="([^"]+)"#i', $bodyRaw, $m)) {
$from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
}
if (preg_match('#<c:time-range[^>]*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"', [
'<d:response><d:href>'
. CalDav::x(CalDav::bookHref($bookId) . $evSeg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
. '</d:prop></d:propstat></d:response>',
]);
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[] = '<d:response><d:href>'
. CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
. '</d:prop></d:propstat></d:response>';
}
$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, '不支持这个请求方法。');