Files
FamilyBook/dav.php
T
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00

495 lines
24 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 = [];
// 🔴🔴 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"', [
'<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>'
. '<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;
}
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, '不支持这个请求方法。');