Files
FamilyBook/dav.php
T
laoyang 5da4de0d20 v33 第 33 批:DAV(Thunderbird)三处真根因修复
用户原话:
  看看 dav 功能,还是不好用,thunderbird 提示这两个地址下没有任何联系人和日程。

关键教训:v31 那个探针(复刻 SyncCalendar 的 DavClient)线上 28/0 全过,
但用户还是空的 —— 因为 Thunderbird 的请求形状不一样:它真正取数据用的是
REPORT(calendar-multiget / addressbook-multiget),而 v31 探针只走
PROPFIND + GET。复刻了 A 客户端全绿,证明不了 B 客户端能看见东西。

三处真根因(都靠新探针 _fb_dav_tb.py 逐步 dump 原始 XML 抓出):
① CardDAV 的 REPORT 回的是草案名 <card:address-data>,而 RFC 6352 规定是
   <card:addressbook-data>。Thunderbird 要后者 → 拿到 0 个数据块 → 通讯录永远空。
   修:新增 $cardDataXml 闭包,照客户端点名的属性回正文(没点名默认正式名)。
   注:第一版写成两个都发 → 正文重复一份、e2e 的 BEGIN:VCARD 计数翻倍,改成按需回。
② home-set 的 Depth:1 响应里没有集合自身。RFC 4918 要求 PROPFIND 响应必须包含
   被请求的资源本身,原来只在非 home 请求时才输出 → PROPFIND /dav/calendars/
   只回子本、没有自己 → 严格客户端判 home 不存在 → 一本都看不见。
   修:dav.php / carddav.php 的 home 分支始终先输出集合自身。
③ 站点根 PROPFIND / 被 Apache 交给 index.php → 302 跳登录页 → Thunderbird 判定
   这个域名下没有日历(RFC 6764 允许从域名根发现)。
   修:router.php(本地)+ .htaccess / 部署脚本 5c 段独立标记块(线上):
   RewriteCond 限住 REQUEST_METHOD + RewriteRule ^$ dav.php;
   必须限方法,否则 GET / 被 ^$ 吞掉、首页 404(已加反向守卫)。
   并让根响应与两个 principal 都给出两套 home-set(calendar + addressbook)。

验证:线上真机 _fb_dav_tb.py 25/0(站点根通 / home-set 含自身 / CardDAV REPORT
真带回 3 个数据块,修复前是 0);逐本 REPORT 所有集合都能取回数据;
旧诊断(SyncCalendar 序列)28/0 无回归;GET / 仍是 302 未被吞。
本地 e2e 1503/0、v33 DAV 反向验证 7/0(三处各自改回旧行为 → 断言全变红);
部署脚本静态断言段本地空跑通过。
2026-10-09 08:45:22 +08:00

462 lines
22 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 服务"的起点。
// 🔴 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"', [
'<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>'
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-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" '
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
'<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>'
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-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>';
}
// 🔴 v33:**始终**先输出 home 集合自身。
// RFC 4918 规定 PROPFIND 响应**必须包含被请求的资源本身**,Depth:1 也是。
// 原来只在 `!$isHome`(请求 `/dav/`)时才输出,于是
// `PROPFIND /dav/calendars/`(Thunderbird 走的就是这条)只回子本、没有自己
// ⇒ 严格客户端判定"这个 home 不存在" ⇒ 日历空。
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::HOME_PATH) . '</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>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-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, '不支持这个请求方法。');