用户提供参照服务器 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 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
1214 lines
65 KiB
PHP
1214 lines
65 KiB
PHP
<?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, '不支持这个请求方法。'); |