Files
FamilyBook/dav.php
T

865 lines
47 KiB
PHP
Raw Normal View History

<?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;
/** 统一的失败出口(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: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: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') {
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
// 客户端拿它当"从这个域名开始找 DAV 服务"的起点。
// 🔴 v33:同时给出 **两套** home-set(calendar + addressbook)——
// 站点根是 CalDAV/CardDAV **共用的**发现起点,客户端只会看自己要的那个属性。
// ⚠️ 光靠这段还不够:线上 Apache 会把 `PROPFIND /` 交给 index.php
// (→ 302 登录页),所以 `.htaccess` 里必须补一条"DAV 方法才转发"的规则。
if ($isRoot) {
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
'<d:response><d:href>/</d:href><d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
// 5b. principal:客户端拿 calendar-home-set 才能知道"去哪列日历本"。
// ⚠️ 还要回 `current-user-principal`(指向自己)—— 客户端**在 principal 上
// 也会再问一遍这个属性**(RFC 5397 里 principal 资源本就该有它)。
// 本批实测:不回的话,严格按 RFC 走的客户端会在这里判"拿不到 principal"
// 而中止建账号(探针的"照它报的地址再走一步"那两条正是抓这个的)。
if ($isPrincipal) {
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
'<d:response><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:principal/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
// 5c. home-set:列出这个人可见的全部日历本。
// ⚠️ `PROPFIND /dav/` 也走这条路(用户与老客户端都直接把 /dav/ 当 home 用),
// 差别只在 href 报哪个 —— 客户端会拿它当后续请求的 base,报错就 404。
if ($isHome || $bookId === 0) {
$baseHref = $isHome ? CalDav::HOME_PATH : '/dav/';
$parts = [];
// 🔴🔴 2026-10-11(本批线上实测发现的协议隐患,修之):
// RFC 4918 规定 **Depth:0 只描述「请求的那个资源本身」**,
// 而这里原来无论 depth 都把**所有日历本**一起回。
// 线上实测的坏结果:`PROPFIND /dav/ Depth:0` 的**第一条 response
// 是 `/dav/1.ics/`**(日历本自己,**不含 current-user-principal**)
// —— 严格客户端(Thunderbird / iOS)只读"与请求 URL 匹配的那条"或
// 第一条,就可能判"拿不到 principal"而中止。
// ⇒ 按 depth 分流:
// · Depth:0 → **只回 `/dav/` 自身**(principal、home-set 都在这一条里,
// 所以"把 /dav/ 当入口的老客户端"仍然一次就能往下走);
// · Depth:1 → 自身 + 各日历本。
if ($depth !== '1') {
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
'<d:response><d:href>' . CalDav::x($baseHref) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
foreach (CalDav::books($familyId, $me) as $c) {
$href = CalDav::hrefOf($c);
$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:propstat></d:response>';
}
// 🔴 v33:**始终**先输出 home 集合自身。
// RFC 4918 规定 PROPFIND 响应**必须包含被请求的资源本身**,Depth:1 也是。
// 原来只在 `!$isHome`(请求 `/dav/`)时才输出,于是
// `PROPFIND /dav/calendars/`(Thunderbird 走的就是这条)只回子本、没有自己
// ⇒ 严格客户端判定"这个 home 不存在" ⇒ 日历空。
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::HOME_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>';
// 首次请求 `/dav/`(bookId=0 且不是 home 路径)时,把发现链的三个答案一并给出:
// 有些客户端在 /dav/ 上**同时**问 current-user-principal —— 少给一个它就又断了。
if (!$isHome) {
array_unshift($parts, '<d:response><d:href>' . CalDav::x($baseHref) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
. '</d:href></c:calendar-home-set>'
. '</d:prop></d:propstat></d:response>');
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
. '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: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:propstat></d:response>',
]);
exit;
}
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
if ($method === 'GET' || $method === 'HEAD') {
/* 🔴🔴 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') {
/* 🔴 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:propstat></d:response>',
]);
exit;
}
$rows = CalDav::resources($cal, $familyId, $me);
$parts = [];
foreach ($rows as $r) {
$seg = CalDav::segmentOf($r);
if ($seg === '') {
continue;
}
if ($wantsMulti && !isset($wantHrefs[$seg])) {
continue; // multiget:只要点名的
}
$day = substr((string)($r['starts_at'] ?? ''), 0, 10);
if (!$wantsMulti) {
if ($from !== '' && $day !== '' && $day < $from) {
continue;
}
if ($to !== '' && $day !== '' && $day > $to) {
continue;
}
}
$res = CalDav::resourceOf($r, (string)$cal['name']);
$parts[] = '<d:response><d:href>'
. CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
. '</d:prop></d:propstat></d:response>';
}
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
exit;
}
// ---- 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') {
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。');
}
$v = $list[0]; // 一个资源 = 一个 VEVENT(重复系列整份在一个 VEVENT 里)
if (empty($v['starts_at'])) {
dav_fail(422, 'VEVENT 缺 DTSTART。');
}
if (trim((string)($v['title'] ?? '')) === '') {
$v['title'] = '(无标题日程)';
}
$uidIn = (string)($v['uid'] ?? '');
// 是不是"改已有的":**优先按资源名(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]);
}
$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;
}
$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(那是另一条路径,
// 我们不能再替他做决定)。这样语义最简单,也与参照一致。
if ($method === 'DELETE') {
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, '不支持这个请求方法。');