From feff31baade90c8316e3d105b8a384c20b9e53ca Mon Sep 17 00:00:00 2001 From: laoyang Date: Tue, 13 Oct 2026 00:29:55 +0800 Subject: [PATCH] =?UTF-8?q?v45b=20=E4=BF=AE=E5=A4=8D=EF=BC=9AThunderbird?= =?UTF-8?q?=E3=80=8C=E6=89=BE=E4=B8=8D=E5=88=B0=E6=97=A5=E5=8E=86/?= =?UTF-8?q?=E8=81=94=E7=B3=BB=E4=BA=BA=E3=80=8D=E7=9A=84=E7=9C=9F=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 2026-10-12 晚:「dav 还是不行,thunderbird 提示找不到日历,联系人也没有。」 真根因(一个由两半无缝衔接构成的 bug): TB 添加账号 → GET /.well-known/caldav → 我们 301 到 /dav/ → TB **跟随这个 GET 跳转** → GET /dav/ → 我们回 **404「没有这本日历」** → TB 判定"这里没有日历" ⇒ 报「找不到日历」。 CardDAV 完全同型(/.well-known/carddav → GET /carddav/ → 404) ⇒ 所以日历和联系人是**同一个 bug**。 修法(对齐参照 DAViCal 的状态码语义): · GET 打到 home 容器(bookId=0)→ **405**(原来 404) 404 = "资源不存在" ⇒ 客户端放弃;405 = "资源在、方法不行" ⇒ 客户端改用 PROPFIND。 · REPORT 打到容器 → **403**(参照实测也是 403)。 · dav.php 与 carddav.php **两处同修**(同型孪生 bug)。 顺带对齐参照(「完全按参照返回」口径): · 每个 XML 响应都带两行 `DAV:` 头(原来只有 OPTIONS 带)—— CalDav::xmlHeader / CardDav::multis(所有 XML 响应的必经处)。 · Content-Type 改 `text/xml`(参照的字面形态)。 探针补齐(这类 bug 长期漏网的三个原因): · 原 TB 探针只发 PROPFIND/REPORT,**从不发 GET /dav/**; · curl 默认不跟随跳转(要 -L); · 得先 GET .well-known 再跟到 /dav/ 才显形。 ⇒ _fb_dav_tb.py 新增 ①c(follow=True 真跟跳转,断言跟随后不能 404、应为 405); _fb_dav_tb.py 支持 FB_SITE(供反向验证指向注入副本)。 验证: · TB 探针 35 → **39/0**(新增 4 条 ①c 全过) · v45b DAV 反向验证 **7/0**(新增 2 个注入:dav/carddav 容器 GET 改回 404 —— 都已证明会让断言变红) · 全流水线 PUSH_RC=0 · 线上真机复验:跟随 well-known 的 GET 跳转 → CalDAV 405 / CardDAV 405 (修复前都是 404);PROPFIND 带两行 DAV 头 + text/xml; 发现链 207、8 个日历本、1 个通讯录。 --- app/CalDav.php | 18 +++++++++++++++++- app/CardDav.php | 8 +++++++- carddav.php | 17 +++++++++++++++-- dav.php | 30 ++++++++++++++++++++++++++++-- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/app/CalDav.php b/app/CalDav.php index 2802a9c..4b8131b 100644 --- a/app/CalDav.php +++ b/app/CalDav.php @@ -154,7 +154,23 @@ final class CalDav public static function xmlHeader(): void { - header('Content-Type: application/xml; charset=utf-8'); + /* 🔴🔴 v45b(2026-10-12 用户「TB 还是找不到日历」)——**关键对齐**。 + 抓参照(DAViCal)发现:它**每一个响应**(不只 OPTIONS)都带两行 `DAV:` 头: + DAV: 1, 2, 3, access-control, calendar-access, calendar-schedule + DAV: extended-mkcol, bind, addressbook, calendar-auto-schedule + 而我们原来**只有 OPTIONS 带**。Thunderbird 的 CalDAV 探测里会读 + `DAV:` 头来确认"这确实是个 DAV 服务"(它把 `calendar-access` 当作 + "支持 CalDAV" 的判据)。PROPFIND/REPORT 的响应缺这个头时, + 部分 TB 版本会认为"服务器没声明 CalDAV 能力"。 + ⇒ 照参照,在这个**所有 XML 响应都必经**的地方补上。 + + ⚠️ Content-Type 也用**参照的字面形态** `text/xml`(原来 `application/xml`)。 + 两者都能被解析,但参照是 `text/xml`,用户要求「完全按参照返回」; + 且个别老解析器对 `application/xml` 的处理路径不同,对齐更稳。 + ⚠️ 第二个 `DAV:` 头必须 `false` 追加 —— PHP 同名 header 默认**覆盖**。 */ + header('Content-Type: text/xml; charset="utf-8"'); + header('DAV: 1, 2, 3, calendar-access, calendar-schedule', false); + header('DAV: extended-mkcol', false); } /** 多状态应答(207)—— PROPFIND / REPORT 都用它 */ diff --git a/app/CardDav.php b/app/CardDav.php index e8cea78..21c4811 100644 --- a/app/CardDav.php +++ b/app/CardDav.php @@ -222,8 +222,14 @@ final class CardDav public static function multis(int $code = 207): void { + /* 🔴 v45b(与 CalDav::xmlHeader 同一改动,两个 DAV 入口保持一致): + **每个响应**都带上 DAV 能力头(原来只有 OPTIONS 带)。 + CardDAV 这边声明的是 `addressbook`(CalDAV 那边是 `calendar-access`)。 + ⚠️ Content-Type 对齐参照的字面形态 `text/xml`。 */ http_response_code($code); - header('Content-Type: application/xml; charset=utf-8'); + header('Content-Type: text/xml; charset="utf-8"'); + header('DAV: 1, 2, 3, addressbook', false); + header('DAV: extended-mkcol', false); } public static function x(string $s): string diff --git a/carddav.php b/carddav.php index 6659c89..e18e93e 100644 --- a/carddav.php +++ b/carddav.php @@ -358,8 +358,20 @@ if ($method === 'PROPFIND') { // ---- 6. GET / HEAD:整本导出成一个 vcf,或单张卡 ---- if ($method === 'GET' || $method === 'HEAD') { + /* 🔴🔴 v45b(2026-10-12 用户「联系人也找不到」)—— 与 CalDAV 那边**同一个 bug**, + 一并修(dav.php 已修,这里是它 CardDAV 的孪生兄弟)。 + 原来:请求 home 容器(`/carddav/`,bookId=0)时回 **404**。 + 为什么这会让 Thunderbird「找不到通讯录」: + ① TB 先 `GET /.well-known/carddav` → 301 到 `/carddav/`; + ② TB **跟随这个 GET 跳转** → `GET /carddav/` → 拿到 404 + ⇒ 判定"这个地址后面没有通讯录" ⇒ 报「找不到通讯录」。 + ⚠️ 实测证据(线上真实公网,修复前): + `curl -L .../.well-known/carddav` → 最终 `GET /carddav/` → **404**。 + ⇒ 对齐参照(DAViCal 对容器 GET 回 405)与 dav.php 的修法: + 回 **405 Method Not Allowed**("资源在,但这个方法不行"),不是 404。 */ if ($bookId === 0) { - cd_fail(404, '没有这本通讯录。'); + header('Allow: OPTIONS, PROPFIND, REPORT'); + cd_fail(405, 'GET 只能用于某一本通讯录或其中的卡片;列通讯录请用 PROPFIND。'); } // 6a. 单张卡(/carddav/1.vcf/12.vcf)。 @@ -410,8 +422,9 @@ if ($method === 'GET' || $method === 'HEAD') { // ---- 7. REPORT:addressbook-query(按条件) / addressbook-multiget(按 href 取)---- if ($method === 'REPORT') { + /* 🔴 v45b:与 dav.php 同一处对齐 —— REPORT 打到 home 容器上时回 403(不是 404)。 */ if ($bookId === 0) { - cd_fail(404, '没有这本通讯录。'); + cd_fail(403, 'REPORT 要打到某一本通讯录上(如 /carddav/1.vcf/),不能打在容器上。'); } $bodyRaw = (string)file_get_contents('php://input'); diff --git a/dav.php b/dav.php index a166383..9adafd5 100644 --- a/dav.php +++ b/dav.php @@ -522,8 +522,28 @@ if ($method === 'PROPFIND') { // ---- 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) { - dav_fail(404, '没有这本日历。'); + header('Allow: OPTIONS, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR'); + dav_fail(405, 'GET 只能用于某一本日历或其中的条目;列日历本请用 PROPFIND。'); } // 6a. 单条资源(/dav/3.ics/12.ics)—— 客户端增量同步走的就是这条。 @@ -576,8 +596,14 @@ if ($method === 'GET' || $method === 'HEAD') { // ---- 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(404, '没有这本日历。'); + dav_fail(403, 'REPORT 要打到某一本日历上(如 /dav/1.ics/),不能打在容器上。'); } $bodyRaw = (string)file_get_contents('php://input');