Files
laoyang 610d524e68 CalDAV 待办改为按分类拆成多本(对齐参照 DAViCal)
用户提供参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9)并指出
「应该可以直接显示那个家庭的待办分类」。

## 参照实测结果

那台服务器的待办本结构:

    /caldav/<user>/tfxnz/     家庭计划   VTODO
    /caldav/<user>/zlqlzm/    工作任务   VTODO
    /caldav/<user>/tdotbsnl/  个人成长   VTODO
    /caldav/<user>/home_todo/ Inbox     VTODO

⇒ 每个分类是**独立的 calendar 集合**,各自 supported-calendar-component-set
只含 VTODO,客户端里显示成并列的多个待办列表。

VTODO 正文还带 CREATED / LAST-MOD / DTSTAMP / SEQUENCE / PRIORITY /
PERCENT-COMPLETE / STATUS / DESCRIPTION / LOCATION / TRANSP。

## 上一版的问题

第一版做成「一个「家庭待办」本装全部任务」,所有分类混在一起,
客户端里只有一个列表 —— 用户要的是分类可见。

## 这一版

### 本的组织

    /dav/tasks-<categoryId>.ics/   每个 task_categories 一行 = 一本
    /dav/tasks-inbox.ics/          category_id 为空的任务(Inbox)

⚠️ 用 `tasks-<id>` 而不是裸 id:真实日历本是 `/dav/<id>.ics/`,
裸 id 会与它们撞号(calendar_events 与 task_categories 是两套独立序列)。

### 分类跟随「本」

客户端把资源 PUT 进哪一本,任务就归哪个分类。
这是「按分类拆本」的自然语义,也让「在客户端里移动任务到另一本」等价于改分类。

### 跨本隔离

GET/DELETE 单条时会校验「这条任务确实属于这个本」,
否则改一下 href 就能用 A 本读到 B 本的任务。

## 验证(39 项全过)

真实数据(5 个分类 / 32 条任务):

| 本 | 条数 |
|---|---|
| 家庭事务 | 6 |
| 工作相关 | 19 |
| 个人事务 | 2 |
| 党支部工作 | 1 |
| 轻舟相关 | 1 |
| 收件箱 | 3 |

每本 supported-calendar-component-set 均只含 VTODO,无 VEVENT。
PUT 到 tasks-1.ics 后 Location 返回 /dav/tasks-1.ics/task-40.ics,
GET 读回、更新、完成、删除全部正常。

回归:原有日历本(观影等)PROPFIND/GET 正常,组件类型仍是 VEVENT。
鉴权:无口令 401、不存在的分类本 404、非法段 404。

测试数据已清理,tasks 仍为 32 条。

## 文件

- app/TaskDav.php     重写为多本模型
- app/Ics.php         VTODO 导出/解析(上一版已加,本版未改)
- dav.php             五处拦截改多本

## 已知限制

看板名(正在进行/等待反馈/等待结果)通过 CATEGORIES 输出给客户端,
但客户端改它不会反向改看板 —— 标准 VTODO 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
2026-11-02 15:41:54 +08:00

1214 lines
65 KiB
PHP
Raw Permalink 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 里。
*
* v24~v44 是**只读**实现(RFC 4791 的子集):写方法一律 403。
* 🔴 v45(用户 2026-10-12 第 2 条):**改成双向同步** —— 用户给了一个可用的
* 参照服务器(群晖 DAViCal)要求「完全按照这个来返回就行」。抓参照 + 复刻
* Thunderbird / SyncCalendar 的真实请求序列后确认:参照是**可写**的,
* 而只读会带来两个真问题(见下面 §7b 的长注释)。现在支持:
* PUT(新建/更新日程,按 UID 落库,带 ETag 与 If-Match 乐观锁)
* DELETE(删日程)· MKCALENDAR(建普通本)
* ⚠️ 仍**不支持** PROPPATCH / POST / PATCH,也**不声称** ACL —— 那些明确拒。
*
* ---------------------------------------------------------------------------
* 🔴 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;
use App\Database;
use App\Ics;
use App\TaskDav;
/** 统一的失败出口(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 = [
'<d:response><d:href>/dav/</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>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
];
foreach (CalDav::books($wkFamily, $wkMe) as $wkC) {
$wkParts[] = '<d:response><d:href>' . CalDav::x(CalDav::hrefOf($wkC)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书 · ' . (string)$wkC['name']) . '</d:displayname>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
CalDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:card="urn:ietf:params:xml:ns:carddav">'
. implode('', $wkParts) . '</d:multistatus>';
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. 写方法:**逐本判权**(v45 起支持双向同步)----
//
// 🔴🔴 v45(用户 2026-10-12 第 2 条):用户给了一个可用的参照服务器
// (群晖 DAViCal)要求「完全按照这个来返回就行」。抓参照 + 复刻 SyncCalendar
// 客户端的真实请求序列后,确认真根因是:
// **参照是双向可写的,而家书对写方法一律回 403、权限也只声明 read。**
// 后果分两层:
// ① SyncCalendar 用 `PUT .../synccalendar-probe.ics` **探测可写性**(见它
// DavClient.probeWritable)——收到 403 就把整本标成「只读」;
// ② Thunderbird 更直接:它是**双向同步**客户端,PUT/DELETE 被拒就报错,
// 且日历显示为只读,「在 TB 里改日程」完全不可用。
// ⇒ 本轮按参照实现 PUT / DELETE(MKCALENDAR 也一并给)。
//
// ⚠️ 但**不能**无条件放开:家书里日历本有「只读共享」这回事(别人共享给你的本
// 可能只有 read 权限)。所以先按本子判权,**能写才进写分支**,不能写仍 403。
// ⚠️ PROPPATCH / POST / PATCH 仍不支持(参照的 Allow 里也没有 PROPPATCH;
// 它支持 ACL/MKCALENDAR,我们不需要 ACL)—— 明确拒比"写一半"好。
if ($method === 'PROPPATCH' || $method === 'POST' || $method === 'PATCH') {
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
dav_fail(405, '家书的 DAV 不支持这个写方法(支持 PUT / DELETE)。');
}
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
// 🔴 v45:Allow / DAV 头**照参照 DAViCal 补齐** ——
// · Allow 里要有 PUT / DELETE / MKCALENDAR(否则客户端判定"不可写");
// · DAV 头补 `calendar-schedule` 与第二行 `extended-mkcol, ...`(参照有两行)。
// 参照:`DAV: 1, 2, 3, access-control, calendar-access, calendar-schedule`
// `DAV: extended-mkcol, bind, addressbook, calendar-auto-schedule`
// ⚠️ 我们**不**声称 access-control(没实现 ACL),也不声称 addressbook
// 这一层是 CardDAV 的(本文件是 CalDAV 入口;CardDAV 那边照旧)。
if ($method === 'OPTIONS') {
http_response_code(200);
// ⚠️ 第二个参数 `false` = **追加而不是替换**。参照 DAViCal 发的是**两行** DAV 头;
// PHP 的 `header()` 同名默认**覆盖** ⇒ 不给 false 的话第二行会把第一行顶掉,
// `calendar-access` 就没了(本探针 A3 抓到的正是这个)。
header('DAV: 1, 2, 3, calendar-access, calendar-schedule', false);
header('DAV: extended-mkcol', false);
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
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` 里的权限集。
*
* 🔴 v45(用户第 2 条「完全按参照返回」):**按实际可写性**给 ——
* 参照 DAViCal 对可写本给的是 `<privilege><all/>` + 一整串 write 相关权限;
* 原来我们**恒给 read**,于是 SyncCalendar 判「只读」(见它
* `entry.writable = /write/i.test(privSet)`),Thunderbird 也不让改。
* ⇒ 能写就声明 `<write/>`(`write-content` / `bind` / `unbind` 一并给,
* 它们正是"能建/能删资源"的语义,参照也是这么给的);
* 只读则只给 `<read/>`。 */
$privOf = static function (bool $writable): string {
if ($writable) {
return '<d:current-user-privilege-set>'
. '<d:privilege><d:read/></d:privilege>'
. '<d:privilege><d:write/></d:privilege>'
. '<d:privilege><d:write-properties/></d:privilege>'
. '<d:privilege><d:write-content/></d:privilege>'
. '<d:privilege><d:bind/></d:privilege>'
. '<d:privilege><d:unbind/></d:privilege>'
. '</d:current-user-privilege-set>';
}
return '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
};
/** 某个本子我能不能写(照 Calendar::canWrite 的真规则)。
*
* ⚠️ 还要**排除系统派生本**(农历/节假日/传统节日/二十四节气)——
* 那些本我虽然是 owner(`canWrite` 恒真),但它们是系统生成的、不能往里写。
* 漏这一条会让权限集对派生本声明 `write` ⇒ 客户端显示"可编辑"、
* 用户改完才在 PUT 那一步吃 403(体验很差,也是本探针 B2 抓到的)。 */
$canWriteCal = static function ($cal) use ($me): bool {
return $cal !== null
&& Calendar::canWrite($cal, $me)
&& !Calendar::isReadonlyKind((string)($cal['kind'] ?? ''));
};
// 兼容旧调用点:home / principal 这类"容器"不是具体某一本,按"至少有一本可写"给
$readOnlyPriv = $privOf(false);
/**
* 输出一段 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') {
/* ============================================================
* 待办本(VTODO)—— 必须在原有日历逻辑之前拦下
* ============================================================
* /dav/tasks.ics/ 是本模块的虚拟本,不对应 calendar_events 的任何一行。
* 在这里前置拦截,原有日历分支一个字都不用改(零回归风险)。
*/
if (TaskDav::isTaskPath($path)) {
/* ============================================================
* 待办本(VTODO)—— 按分类多本,与参照 DAViCal 同构
* ============================================================
* 用户 2026-11-02 给了参照服务器并指出
* 「应该可以直接显示那个家庭的待办分类」。
* 参照实测:每个分类是独立的 calendar 集合,各自 comp 只含 VTODO。
*
* ⚠️ supported-calendar-component-set 必须是 VTODO:
* 给 VEVENT 的话客户端会按日程解析,VTODO 会被过滤掉不显示。
*/
$tHit = TaskDav::parse($path);
$tSeg = $tHit === null ? '' : (string)$tHit['seg'];
$tBook = TaskDav::resolveBook($familyId, $me, $tSeg);
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tDepth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
$taskEmit = static function (string $xmlns, array $items): void {
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus ' . $xmlns . '>' . "\n"
. implode("\n", $items) . "\n"
. '</d:multistatus>';
exit;
};
if ($tDepth !== '1') {
// Depth:0 —— 这一本自身的属性
$ctag = TaskDav::ctag($familyId, $me, $tCatId);
$taskEmit(
'xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:cs="http://calendarserver.org/ns/" '
. 'xmlns:x="http://apple.com/ns/ical/"',
[
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tBook['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tBook['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. '<d:current-user-privilege-set><d:privilege><d:all/></d:privilege></d:current-user-privilege-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]
);
}
/* Depth:1 —— 本 + 里面的资源清单。
⚠️ href 必须与 GET/REPORT 用同一个 segmentOf 算,
三处不一致的话客户端会反复重拉(日历那边踩过)。 */
$items = [
'<d:response><d:href>' . CalDav::x($tBook['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($tBook['name']) . '</d:displayname>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
];
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
$seg = TaskDav::segmentOf($t);
if ($seg === '') {
continue;
}
$etag = TaskDav::resourceOf($t, $tBook['name'])['etag'];
$items[] = '<d:response><d:href>'
. CalDav::x($tBook['href'] . $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:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$taskEmit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $items);
}
$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:status>HTTP/1.1 200 OK</d:status></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:status>HTTP/1.1 200 OK</d:status></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:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
foreach (CalDav::books($familyId, $me) as $c) {
$href = CalDav::hrefOf($c);
$w = $canWriteCal($c); // 🔴 v45:按本子给真权限(含派生本只读)
/* 🔴 v45:**照参照补齐属性**(用户「完全按参照返回」)——
参照 DAViCal 在日历本上给了:resourcetype / displayname /
getcontenttype / sync-token / getctag / supported-calendar-component-set /
current-user-privilege-set / owner,并把没实现的属性单独放一个
404 propstat(getctag / calendar-color / description / timezone)。
⚠️ 我们**照给** owner 与 getctag(客户端用 getctag 判"这本变没变"),
但不给 sync-token(我们没实现 sync-collection —— 谎报会让
客户端走 sync-collection 然后拿到 501)。
⚠️ calendar-color 我们**有**(模块色),所以放在 200 那一组里,
不像参照那样 404 —— 有就给,是更好的实现。 */
$ctag = '"' . md5($href . ':' . CalDav::ctagSeedOf($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>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<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>'
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($w)
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></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:status>HTTP/1.1 200 OK</d:status></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:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>');
}
/* 待办本:**按分类拆成多本**(与参照 DAViCal 同构)。
用户 2026-11-02:「应该可以直接显示那个家庭的待办分类」。
参照实测:每个分类是独立 calendar 集合,客户端里并列显示为多个待办列表。
没有这一整批,客户端 PROPFIND /dav/ 时根本不知道有待办,也就不会去拉。 */
foreach (TaskDav::books($familyId, $me) as $tb) {
$tCtag = TaskDav::ctag($familyId, $me, (int)$tb['id']);
$parts[] = '<d:response><d:href>' . CalDav::x($tb['href']) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($tb['name']) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('岁月家 · 待办 · ' . $tb['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x($tb['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($tCtag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($tCtag) . '</d:getctag>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:current-user-principal>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', $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:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
exit;
}
// Depth 0:这一天历本自身的属性
// 🔴 v45:权限按**这本实际可写性**给(原来恒 read);并按参照补
// getcontenttype / getctag / owner / calendar-description / 颜色。
$cw = $canWriteCal($cal);
$ct = '"' . md5(CalDav::bookHref($bookId) . ':' . CalDav::ctagSeedOf($cal)) . '"';
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', [
'<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>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$cal['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$cal['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. '<cs:getctag>' . CalDav::x($ct) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ct) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($cw)
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
if ($method === 'GET' || $method === 'HEAD') {
/* 待办本:整本导出,或单条 VTODO。
⚠️ 单条必须校验资源属于**这一本**且本用户可见,
否则改 URL 就能读到别人的任务(越权)。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tSub = (string)($tHit['sub'] ?? '');
if ($tSub !== '') {
$tParsed = TaskDav::idOfSegment($tSub);
$tOne = $tParsed > 0 ? TaskDav::one($familyId, $me, $tParsed) : null;
// ⚠️ 还要确认这条**确实属于这一本**,否则可用 A 本的 href 读到 B 本的任务
if ($tOne === null) {
dav_fail(404, '没有这条待办。');
}
$tOwnCat = (int)($tOne['category_id'] ?? 0);
$tExpectCat = $tCatId === -1 ? 0 : $tCatId;
if ($tOwnCat !== $tExpectCat) {
dav_fail(404, '这条待办不在这本里。');
}
$tRes = TaskDav::resourceOf($tOne, $tBook['name']);
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . $tRes['etag']);
if ($method !== 'HEAD') {
echo $tRes['ics'];
}
exit;
}
// 整本
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . TaskDav::ctag($familyId, $me, $tCatId));
if ($method !== 'HEAD') {
echo TaskDav::ics($familyId, $me, $tCatId, $tBook['name']);
}
exit;
}
/* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**真 bug 修在这里**。
原来这一段是:
if (!$cal) { dav_fail(404, '没有这本日历。'); }
而 `$cal` 在 `bookId === 0` 时恒为 null —— 也就是**请求的是 home 容器
`/dav/` 本身**(不是某一本)时,一律回 **404**。
为什么这会让 Thunderbird「找不到日历」:
TB 添加账号时先 `GET /.well-known/caldav` → 我们回 301 到 `/dav/`,
**TB 会跟随这个跳转**(它跟随 GET 跳转,只是不跟随带鉴权的 PROPFIND 跳转),
于是变成 `GET /dav/` ⇒ 拿到 **404「没有这本日历」**
⇒ TB 判定"这个地址后面没有日历" ⇒ 报「找不到日历」。
⚠️ 探针一直没抓到:`_fb_dav_tb.py` 只发 PROPFIND/REPORT,**从来不发这个 GET**;
而 curl 默认也不跟随(要 -L)——本次用 `-L` 走了一遍才暴露。
⇒ 对齐参照(DAViCal):容器上的 GET 回 **405 Method Not Allowed**,
**不是 404**。语义差别很关键:
· 404 = "这个资源不存在" ⇒ 客户端认为"这里没有日历"(致命);
· 405 = "这个方法不行,但资源在" ⇒ 客户端改用 PROPFIND,正常工作。
⚠️ Allow 里必须**不含 GET**(否则自相矛盾),且带上 PROPFIND/REPORT。 */
if (!$cal) {
header('Allow: OPTIONS, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
dav_fail(405, 'GET 只能用于某一本日历或其中的条目;列日历本请用 PROPFIND。');
}
// 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') {
/* 待办本:calendar-query / calendar-multiget 都返回 VTODO。
⚠️ 支持 multiget 按 href 过滤 —— 整本回在条目多时是纯浪费。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tBody = (string)file_get_contents('php://input');
$tWantsMulti = stripos($tBody, 'calendar-multiget') !== false;
$tWant = [];
if ($tWantsMulti && preg_match_all('#<[^>]*href[^>]*>([^<]+)</[^>]*href>#i', $tBody, $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 !== '') {
$tWant[$seg] = true;
}
}
}
$tItems = [];
foreach (TaskDav::tasksIn($familyId, $me, $tCatId) as $t) {
$seg = TaskDav::segmentOf($t);
if ($seg === '' || ($tWantsMulti && !isset($tWant[$seg]))) {
continue;
}
$res = TaskDav::resourceOf($t, $tBook['name']);
$tItems[] = '<d:response><d:href>'
. CalDav::x($tBook['href'] . $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:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' . "\n"
. implode("\n", $tItems) . "\n"
. '</d:multistatus>';
exit;
}
/* 🔴 v45b:REPORT 打到 **home 容器**(`/dav/`,bookId=0)时 ——
照参照(DAViCal)回 **403**,不回 404。
参照实测:`REPORT /caldav/<user>/` → `403 Forbidden`(不是 404)。
404 会让客户端以为"整条路径都不存在",403 才是"这里不能这么查"。
⚠️ REPORT 本来就该打到**某一本**上(calendar-query 需要一个 calendar 集合),
打到容器上属于用错,403 是准确的状态码。 */
if (!$cal) {
dav_fail(403, 'REPORT 要打到某一本日历上(如 /dav/1.ics/),不能打在容器上。');
}
$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:status>HTTP/1.1 200 OK</d:status></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:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
exit;
}
// ---- 7b. PUT:新建 / 更新一条日程(RFC 4791 §5.3.2)----
//
// 🔴 v45(用户第 2 条「完全按参照返回」):这是「双向同步」的核心。
// Thunderbird / SyncCalendar 都靠 PUT 把本地的改动推上来。
//
// 契约(照参照与 RFC):
// · 请求体 = 一份完整 VCALENDAR(含一个 VEVENT,带 UID);
// · 资源名由客户端决定(`<UID>.ics` 或任意名)—— 我们**按 UID 去重落库**,
// 不依赖文件名,这样客户端改名/换名也不会重复建;
// · 新建成功 → **201 Created** + `ETag`(客户端记下来做 If-Match);
// · 更新成功 → **204 No Content** + 新 `ETag`(RFC:更新不带正文);
// · 带 `If-Match` 且与当前 ETag 不符 → **412 Precondition Failed**
// (这是防"两个设备同时改"的乐观锁,客户端会重拉后再推);
// · 目标本不可写 → **403**(只读共享的本子不能写,与网页端同一条规则);
// · 正文不是合法日历 → **415**(参照与我们都不接受非 text/calendar)。
//
// ⚠️ 落库一律走 `Calendar::importEvent()` —— 它与网页「导入 .ics」是**同一个**
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
if ($method === 'PUT') {
/* 待办本:新建 / 更新一条 VTODO。
⚠️ 路径里有段 = 更新那一条;没有段 = 新建(客户端会给新资源自己编名字)。
⚠️ PUT 到不存在的资源名是**创建**(RFC 4918 §9.7),
由 TaskDav::put 内部判断,这里不预先拒绝。 */
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tCatId = (int)$tBook['id'];
$tBody = (string)file_get_contents('php://input');
if (trim($tBody) === '') {
dav_fail(400, '正文是空的。');
}
$tCtype = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if ($tCtype !== '' && stripos($tCtype, 'calendar') === false
&& stripos($tCtype, 'text/plain') === false) {
dav_fail(415, '只接受 text/calendar 正文。');
}
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
try {
$tRes2 = TaskDav::put($familyId, $me, $tCatId, $tTaskId, $tBody);
} catch (RuntimeException $e) {
dav_fail(403, $e->getMessage());
}
$tSaved = TaskDav::one($familyId, $me, (int)$tRes2['id']);
if (!$tSaved) {
dav_fail(500, '写入后读取失败。');
}
$tRes3 = TaskDav::resourceOf($tSaved, $tBook['name']);
$tNewHref = $tBook['href'] . TaskDav::segmentOf($tSaved) . '.ics';
http_response_code($tRes2['created'] ? 201 : 204);
header('ETag: ' . $tRes3['etag']);
if ($tRes2['created']) {
header('Location: ' . $tNewHref);
}
exit;
}
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
if (!Calendar::canWrite($cal, $me)) {
dav_fail(403, '这本日历是只读共享,你不能往里写。');
}
// 派生本(观影/旅程/农历/节假日…)是**系统生成的只读本**,不能被客户端写。
if (Calendar::isReadonlyKind((string)$cal['kind'])) {
dav_fail(403, '这本是系统生成的只读日历,不能写入。');
}
$bodyRaw = (string)file_get_contents('php://input');
// 只收 text/calendar(客户端本来就会带;不带也宽容放行,但空正文一定拒)
$ctype = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if ($ctype !== '' && stripos($ctype, 'calendar') === false && stripos($ctype, 'text/plain') === false) {
dav_fail(415, '只接受 text/calendar 正文。');
}
if (trim($bodyRaw) === '') {
dav_fail(400, '正文是空的。');
}
$list = Ics::parse($bodyRaw);
if (!$list) {
dav_fail(415, '正文里没有找到 VEVENT。');
}
/* 🔴🔴 v52b(2026-10-15):「一个资源里可以有**多个 VEVENT**」。
重复系列的标准形态 = 主事件(无 `RECURRENCE-ID`)+ 若干**覆盖行**
(带 `RECURRENCE-ID` = 被替换的那次**原发生时刻**,各自己的 `DTSTART` = 改到的新时间)。
见 RFC 4791 §4.1 与 RFC 5545 §3.8.4.4;Google / 苹果 / 雷鸟 / SyncCalendar
表达"仅这一次改到别的时间"用的就是它。
⚠️ 原来这里写死 `$list[0]` ⇒ 覆盖行**全被丢掉**,于是:
· 用户在手机上"把周三那次改到周五"→ 同步过去只剩原系列 ⇒ 改动**丢失**;
· 而且因为"那一次"没被排除,下一轮拉取又把它画回原时间(幽灵日程)。
修法:主事件与覆盖行分开收,主事件走原来的 upsert,覆盖行走 replaceOverrides。 */
$v = null; // 主事件(应当是**没有** RECURRENCE-ID 的那条)
$ovrs = []; // 覆盖行
/* 「取消某一次」在真实客户端里有**两种**表达,语义完全等价(RFC 5545 §3.8.4.4):
· `EXDATE` —— 记进 `skip_dates`(SynCalendar / 苹果 / 雷鸟走的都是这条);
· `RECURRENCE-ID` + `STATUS:CANCELLED` 的覆盖行 —— 也被本服务器按"已取消"收留。
🔴 形态二以前是被**当成普通覆盖行**存下来的 ⇒ 网页日历在系列里那次照样画一条
(只是标着已取消),而且 `Repeat::occurrences()` 里那次**根本没被排除**。
这里统一折算成 skip 日期,两种流派就都对了。 */
$cancelled = []; // 形态二的覆盖行(只在"正文里只有它"时要退化成一条普通日程)
$cancelDays = []; // 它们等价排除了哪几天(`Y-m-d`)
foreach ($list as $one) {
$ridRaw = trim((string)($one['recurrence_id'] ?? ''));
if ($ridRaw === '') {
if ($v === null) {
$v = $one;
}
// 同一份正文里出现**多个**无 RECURRENCE-ID 的 VEVENT:取第一个,
// 其余无法表达(一个资源只能有一个主事件),宁可少存不可乱存。
continue;
}
if (strtolower((string)($one['status'] ?? '')) === 'cancelled') {
$cancelled[] = $one;
$cd = Calendar::ridDay($ridRaw);
if ($cd !== '') {
$cancelDays[$cd] = true;
}
continue;
}
$ovrs[] = $one;
}
if ($v === null) {
// 极端形态:正文里只有覆盖行(某些客户端"单独移动/取消这一次"会这么发)。
// 这时**必须**把它当普通日程存下来 —— 直接 415 会让这条日程整个消失。
$from = $ovrs ? 'ovr' : ($cancelled ? 'cx' : '');
if ($from === 'ovr') {
$v = array_shift($ovrs);
} elseif ($from === 'cx') {
$v = array_shift($cancelled);
}
if ($from !== '') {
// ⚠️ 它自己就是这条日程,别把自己的发生日当成"被排除的一天"排除掉,
// 否则这条日程会在网页上整个消失(只有"取消",连"是什么"都看不到)。
$sd = Calendar::ridDay(trim((string)($v['recurrence_id'] ?? '')));
if ($sd !== '') {
unset($cancelDays[$sd]);
}
unset($v['recurrence_id']);
}
}
if ($v === null || empty($v['starts_at'])) {
dav_fail(422, 'VEVENT 缺 DTSTART。');
}
if (trim((string)($v['title'] ?? '')) === '') {
$v['title'] = '(无标题日程)';
}
$uidIn = (string)($v['uid'] ?? '');
/* 这次要落的「例外」。⚠️ 语义是**权威替换**(PUT 的正文就是这条资源的全部真相,
见 RFC 4791 §5.3.2):本次没出现的覆盖行/EXDATE 一律删掉 ——
只增不减的话,用户在客户端"把某次改回来",服务器上那条记录永远删不掉。
⚠️ EXDATE 通常写在主事件上,但 RFC 允许写在任意一个 VEVENT 里 ⇒ 全部并起来。 */
$skipDates = (array)($v['skip_dates'] ?? []);
foreach ($ovrs as $one) {
$skipDates = array_merge($skipDates, (array)($one['skip_dates'] ?? []));
}
// 「RECURRENCE-ID + STATUS:CANCELLED」折算出来的排除日(见上面分组处)
foreach (array_keys($cancelDays) as $cd) {
$skipDates[] = $cd;
}
$skipDates = array_values(array_unique($skipDates));
// 是不是"改已有的":**优先按资源名(uid)找**(客户端 PUT 到 <uid>.ics),
// 再按正文里的 UID 找,最后兼容老式数字-id 资源名。
// ⚠️ 三种都要有,缺一种就会出现"客户端改完变成两条"或"改了但旧的那条还在"。
$existingId = 0;
if ($evSeg !== '') {
$bySeg = CalDav::oneResource($cal, $familyId, $me, $evSeg);
if ($bySeg && (int)($bySeg['id'] ?? 0) > 0) {
$existingId = (int)$bySeg['id'];
}
}
if ($existingId === 0 && $uidIn !== '') {
$st = Database::get()->prepare(
'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? LIMIT 1'
);
$st->execute([(int)$cal['id'], $uidIn]);
$existingId = (int)$st->fetchColumn();
}
// 乐观锁:If-Match 给了就必须与当前 ETag 相符(不符 = 别人改过了 → 412)
if ($existingId > 0) {
$cur = CalDav::oneEvent((int)$cal['id'], $existingId, [$familyId, $me]);
$curEt = $cur ? CalDav::resourceOf($cur, (string)$cal['name'])['etag'] : '';
$ifMatch = trim((string)($_SERVER['HTTP_IF_MATCH'] ?? ''));
if ($ifMatch !== '' && $ifMatch !== '*' && $curEt !== '' && $ifMatch !== $curEt) {
header('ETag: ' . $curEt);
dav_fail(412, '这条日程已经被改过了,请重新拉取后再试。');
}
// 归属校验:mime 前面已用 `Calendar::canWrite($cal, $me)` 判过"这本能不能写",
// 而 `$existingId` 这条日程必然是**这本里**的(上面按 calendar_id 查的),
// 所以无需再判一次 —— 只读共享的本在函数开头就被 403 拦掉了。
Calendar::updateEvent($existingId, $v);
// 保 UID 不被清空(老客户端可能不带 UID 就改:那就沿用原来的)
if ($uidIn === '') {
Database::get()->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?')
->execute([CalDav::segmentOf($cur ?: ['id' => $existingId]), $existingId]);
}
/* 重复系列的「例外」(v52b):`EXDATE` = 哪几次被取消、
`RECURRENCE-ID` 覆盖行 = 哪几次被改到别的时间。
⚠️ 必须在算 ETag **之前**落库 —— ETag 由最终正文算出,
否则客户端拿到的 ETag 对应的还是旧正文,下轮 If-Match 会莫名 412。 */
Calendar::setSkips($existingId, $skipDates);
Calendar::replaceOverrides($existingId, $ovrs);
$after = CalDav::oneEvent((int)$cal['id'], $existingId, [$familyId, $me]);
$et = CalDav::resourceOf($after ?: $cur, (string)$cal['name'])['etag'];
http_response_code(204);
header('ETag: ' . $et);
exit;
}
// 新建
$newId = Calendar::importEvent((int)$cal['id'], $v, $me) === 'added'
? (int)Database::get()->lastInsertId() : 0;
$row = $newId > 0 ? CalDav::oneEvent((int)$cal['id'], $newId, [$familyId, $me]) : null;
if (!$row && $uidIn !== '') {
$st = Database::get()->prepare(
'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? LIMIT 1');
$st->execute([(int)$cal['id'], $uidIn]);
$rid = (int)$st->fetchColumn();
$row = $rid > 0 ? CalDav::oneEvent((int)$cal['id'], $rid, [$familyId, $me]) : null;
}
/* 重复系列的「例外」:落库后再算 ETag(原因见上面更新那一段的注释)。 */
$exId = $row ? (int)$row['id'] : $newId;
if ($exId > 0) {
Calendar::setSkips($exId, $skipDates);
Calendar::replaceOverrides($exId, $ovrs);
}
$et = $row ? CalDav::resourceOf($row, (string)$cal['name'])['etag'] : '';
http_response_code(201);
if ($et !== '') {
header('ETag: ' . $et);
}
if ($row) {
header('Location: ' . CalDav::bookHref($bookId) . CalDav::segmentOf($row) . '.ics');
}
exit;
}
// ---- 7c. DELETE:删一条日程 ----
//
// 照参照与 RFC:成功 **204 No Content**;不存在 **404**;本不可写 **403**。
// ⚠️ 删除的是**整条资源**(含重复系列的全部实例)—— CalDAV 里重复系列就是
// 一个资源,客户端要"只删某一次"会先 PUT 改 RRULE + EXDATE(那条路径我们已经
// 支持了,见 7b 的 `skip_dates` / 覆盖行),我们不能再替他做决定。
// 这样语义最简单,也与参照一致。
// ⚠️ 覆盖行与 EXDATE 都是 `calendar_events` 的子表(FK ON DELETE CASCADE),
// 所以这一句 DELETE 会把它们一并带走,不需要额外清理。
if ($method === 'DELETE') {
if (TaskDav::isTaskPath($path)) {
$tHit = TaskDav::parse($path);
$tBook = TaskDav::resolveBook($familyId, $me, (string)($tHit['seg'] ?? ''));
if ($tBook === null) {
dav_fail(404, '没有这本待办。');
}
$tTaskId = TaskDav::idOfSegment((string)($tHit['sub'] ?? ''));
if ($tTaskId <= 0) {
dav_fail(400, '要指定是哪一条待办(如 /dav/tasks-3.ics/task-5.ics)。');
}
if (!TaskDav::delete($familyId, $me, $tTaskId)) {
dav_fail(404, '没有这条待办,或你没有删除权限。');
}
http_response_code(204);
exit;
}
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
if (!Calendar::canWrite($cal, $me) || Calendar::isReadonlyKind((string)$cal['kind'])) {
dav_fail(403, '这本日历不能写入/删除。');
}
if ($evSeg === '') {
dav_fail(404, '要删哪条?没给出资源名。');
}
// 🔴 v45:资源名现在**是 UID**(见 CalDav::segmentOf)—— 用 oneResource 解析,
// 它同时兼容老的数字-id 资源名。
// ⚠️ 但**只能删"真的有实体"的那种**(派生条目是算出来的、删不掉),
// 所以解析出来的行必须带正的 calendar_events.id。
$one = CalDav::oneResource($cal, $familyId, $me, $evSeg);
if (!$one || (int)($one['id'] ?? 0) <= 0) {
dav_fail(404, '没有这条日程(或它是系统派生的只读条目,删不了)。');
}
Calendar::deleteEvent((int)$one['id']);
http_response_code(204);
exit;
}
// ---- 7d. MKCALENDAR:新建一个日历本(参照支持;Thunderbird 会用它)----
//
// ⚠️ 我们**只允许建普通本**(kind=custom)。理由:观影/旅程/农历那些是
// 系统派生的只读本,客户端建不出来也不该建。
if ($method === 'MKCALENDAR') {
$bodyRaw = (string)file_get_contents('php://input');
$name = '';
if (preg_match('#<[^>]*displayname[^>]*>([^<]*)<#i', $bodyRaw, $m)) {
$name = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
}
if ($name === '') {
$name = '新日历本';
}
$color = '#4A7C82';
if (preg_match('#<[^>]*calendar-color[^>]*>([^<]*)<#i', $bodyRaw, $m)) {
$c = trim($m[1]);
if (preg_match('/^#[0-9A-Fa-f]{6}$/', substr($c, 0, 7))) {
$color = substr($c, 0, 7);
}
}
$newCal = Calendar::create($familyId, $me, mb_substr($name, 0, 60, 'UTF-8'), $color, 'custom');
http_response_code(201);
header('Location: ' . CalDav::bookHref($newCal));
exit;
}
// ---- 8. 其它方法一律 405(并告知支持哪些)----
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
dav_fail(405, '不支持这个请求方法。');