/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 → c:calendar-home-set → 拿到"日历本们的容器" * ④ PROPFIND Depth:1 → 列出各日历本 * ⑤ PROPFIND 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 = [ '/dav/' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . '', ]; foreach (CalDav::books($wkFamily, $wkMe) as $wkC) { $wkParts[] = '' . CalDav::x(CalDav::hrefOf($wkC)) . '' . '' . '' . '' . CalDav::x('家书 · ' . (string)$wkC['name']) . '' . '' . ''; } CalDav::multis(); echo '' . "\n" . '' . implode('', $wkParts) . ''; 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 对可写本给的是 `` + 一整串 write 相关权限; * 原来我们**恒给 read**,于是 SyncCalendar 判「只读」(见它 * `entry.writable = /write/i.test(privSet)`),Thunderbird 也不让改。 * ⇒ 能写就声明 ``(`write-content` / `bind` / `unbind` 一并给, * 它们正是"能建/能删资源"的语义,参照也是这么给的); * 只读则只给 ``。 */ $privOf = static function (bool $writable): string { if ($writable) { return '' . '' . '' . '' . '' . '' . '' . ''; } return '' . ''; }; /** 某个本子我能不能写(照 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 '' . "\n" . '' . implode('', $parts) . ''; }; // ---- 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"', [ '/' . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . '', ]); 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"', [ '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . $readOnlyPriv . '', ]); 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"', [ '' . CalDav::x($baseHref) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . CalDav::x(CalDav::CARD_HOME_PATH) . '' . $readOnlyPriv . '', ]); 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[] = '' . CalDav::x($href) . '' . '' . '' . '' . CalDav::x($dispName($c)) . '' . 'httpd/unix-directory' . '' . CalDav::x('家书 · ' . (string)$c['name']) . '' . '' . CalDav::x((string)$c['color']) . '' . '' . '' . CalDav::x($ctag) . '' . '' . CalDav::x($ctag) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . $privOf($w) . ''; } // 🔴 v33:**始终**先输出 home 集合自身。 // RFC 4918 规定 PROPFIND 响应**必须包含被请求的资源本身**,Depth:1 也是。 // 原来只在 `!$isHome`(请求 `/dav/`)时才输出,于是 // `PROPFIND /dav/calendars/`(Thunderbird 走的就是这条)只回子本、没有自己 // ⇒ 严格客户端判定"这个 home 不存在" ⇒ 日历空。 $parts[] = '' . CalDav::x(CalDav::HOME_PATH) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . $readOnlyPriv . ''; // 首次请求 `/dav/`(bookId=0 且不是 home 路径)时,把发现链的三个答案一并给出: // 有些客户端在 /dav/ 上**同时**问 current-user-principal —— 少给一个它就又断了。 if (!$isHome) { array_unshift($parts, '' . CalDav::x($baseHref) . '' . '' . '' . '' . CalDav::x('家书日历') . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . '' . CalDav::x(CalDav::HOME_PATH) . '' . ''); } $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[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' . '' . '' . CalDav::x($etag) . '' . '' . 'text/calendar; charset=utf-8' . ''; } $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/"', [ '' . CalDav::x(CalDav::bookHref($bookId)) . '' . '' . '' . '' . CalDav::x($dispName($cal)) . '' . 'httpd/unix-directory' . '' . CalDav::x('家书 · ' . (string)$cal['name']) . '' . '' . CalDav::x((string)$cal['color']) . '' . '' . '' . CalDav::x($ct) . '' . '' . CalDav::x($ct) . '' . '' . CalDav::x(CalDav::PRINCIPAL_PATH) . '' . $privOf($cw) . '', ]); 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//` → `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//.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('#]*start="([^"]+)"#i', $bodyRaw, $m)) { $from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2); } if (preg_match('#]*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"', [ '' . CalDav::x(CalDav::bookHref($bookId) . $evSeg . '.ics') . '' . '' . '' . CalDav::x($res['etag']) . '' . '' . CalDav::x($res['ics']) . '' . '', ]); 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[] = '' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '' . '' . '' . CalDav::x($res['etag']) . '' . '' . CalDav::x($res['ics']) . '' . ''; } $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); // · 资源名由客户端决定(`.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 到 .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, '不支持这个请求方法。');