Files
laoyang ffde47d476 v45c 修复:Thunderbird「找不到日历/联系人」真根因 —— propstat 缺 <d:status>
用户 2026-10-13 再次反馈「还是不行,仍然提示找不到日历」。
按用户要求,在线上装了临时请求日志器(记录 TB 发来的每个请求与我们的每条响应)。
但真正的突破口是**读 Thunderbird 自己的源码**:TB 就在本机,
解开 C:/Program Files\Mozilla Thunderbird\omni.ja,拿到 modules/caldav/*.sys.mjs。

TB 157 的 PROPFIND 解析器(modules/caldav/CalDavRequest.sys.mjs,
PropfindResponse.get data)逐字是:

    for (const propStat of response.querySelectorAll(":scope > propstat")) {
      const status = propStat.querySelector(":scope > status").textContent;   // ← 无 null 保护
      ...
    }

我们的每个 <d:propstat> **都没有 <d:status> 子节点**(RFC 4918 要求有),
于是 querySelector 返回 null → null.textContent 抛 TypeError →
detectCollection() 整条发现链中断 → 客户端报「找不到日历 / 联系人也没有」。
**CalDAV 与 CardDAV 同型**,所以用户看到的「日历和联系人都没有」是同一个 bug。

为什么历史验证全都没抓到(三层叠加):
  1. 老 DAV 探针只做 XML 正则匹配,从不校验 RFC 结构;
  2. curl 不会因为缺 status 报错 —— 响应看着完全正常(207 + 属性齐全);
  3. 只有「把客户端的解析器真跑一遍」才现形。

改动:
  · dav.php 12 处 + carddav.php 12 处
    `</d:prop></d:propstat>` → `</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>`

验证(含新增的永久守护):
  · 新增 `_tb_parse_repro.js`:用 jsdom 逐字复刻 TB 那段解析逻辑喂真实响应。
    修复前:线上 10/10 个 propstat 全部 TypeError;修复后:0 个异常。
  · 新增 `_tb_detect_sim.js`:完整复刻 TB 的发现算法(detectCollection /
    handlePrincipal / handleHomeSet 三函数照抄)并真发请求。
    修复前线上三条路径全部抛异常 ⇒ 0 本日历(与用户症状一致);
    修复后线上 **发现 8 本日历**,CardDAV 1 个通讯录。
  · `_fb_dav_tb.py` 新增 ⑨「TB 解析器结构守护」5 条断言(纯 Python,不依赖 Node)
    → 探针 39 → 44 项,全绿。
  · `_b45_dav_falsify.py` 新增 2 个注入(dav/carddav 各去掉 status)证明断言会红
    → 反向验证 7 → 9 项,全绿。
  · `familybook_deploy.sh` 新增静态断言(`</d:prop></d:propstat>` 出现次数必须为 0)。
  · 本地 e2e 1510/0;TB 探针 44/0;部署脚本跑通。

⚠️ 顺带修一个反向验证自身的坑:`patch()` 原来只替换第一处出现,
   而 `</d:prop><d:status>…</d:propstat>` 在文件里有 12 处、第一处在 /.well-known 分支,
   导致注入没落到被测路径上 ⇒ 断言假绿。已改为替换全部出现。
2026-10-13 08:20:07 +08:00

522 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 家书 FamilyBook · CardDAV 入口(v24 第 22 批)
*
* 用户原话:「名册,增加cardav同步功能。」
* 把「我的名册」放进手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)。
*
* 地址:https://<站点>/carddav/
* 账号:手机号或姓名(同登录)
* 口令:**DAV 通用口令**(在「个人设置 → DAV 同步」生成,不是登录口令)
* ⚠️ 与 CalDAV(/dav/)**共用同一把**(用户 2026-10-06 确认):
* 手机上填一次,日历本与通讯录一起进来。
*
* ⚠️ 为什么是独立文件、独立前缀(/carddav/ 而不是 /dav/card/):
* 见 App\CardDav 的类注释 —— 两种资源类型混在一棵树里要靠猜客户端意图,
* 猜错的表现是"连上了但一本都看不到",比连不上更难查。
* 二者共用的是**鉴权**(Auth::davAuth)与**口令表**(dav_credentials),
* 那才是用户关心的"用用户名和口令来同步"。
*
* ⚠️ 与 dav.php 一样,这里不做"半支持":写方法一律 403。
* 手机上加/改/删联系人**不会**回写到家书 —— 与其让用户以为同步了,不如明说。
*
* ---------------------------------------------------------------------------
* 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。
*
* 与 CalDAV 是**同一个根因**:发现链(discovery)整段缺失。
* 标准客户端建账号时按 RFC 6764 走:
* ① GET /.well-known/carddav → 301 到 /carddav/principal/
* ② PROPFIND /carddav/ → d:current-user-principal
* ③ PROPFIND <principal> → card:addressbook-home-set
* ④ PROPFIND <home-set> Depth:1 → 列出通讯录
* ⑤ PROPFIND <book> Depth:1 / REPORT → 取每张卡
*
* 实测线上:① 404、②③ 回空 ⇒ 客户端在"填完账号点保存"那一刻就失败。
*
* ⚠️ 本文件**同时**补了一个"路径被当成通讯录 id"的坑:
* CardDav::parsePath('/carddav/principal/') 会把 `principal` 段去 `.vcf` 后缀后
* 转成 int → **0**,于是 principal 路径会被当成"根"、走进"列通讯录"那条路,
* 客户端就永远问不到 addressbook-home-set。所以发现链节点必须先于 parsePath 判定。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Auth;
use App\CardDav;
/** 统一的失败出口(DAV 客户端只认状态码,正文给点人话便于排查) */
function cd_fail(int $code, string $msg): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $msg;
exit;
}
// ---- 1. 鉴权:HTTP Basic(与 CalDAV 同一套 —— 一把口令管两种同步)----
$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'] ?? '/carddav/'), PHP_URL_PATH);
/**
* ⚠️ `/.well-known/carddav` 是**唯一免鉴权**的入口(RFC 6764 §6.1)。
* 在这里就 401 的话,很多客户端会直接报"服务器不支持 CardDAV",
* 而不是弹口令框 —— 所以这一跳必须在鉴权**之前**。
*
* ⚠️ 跳转目标选**上下文路径 `/carddav/`**(RFC 6764 原话是
* "redirect to the context path"):它的 PROPFIND 里同时带了
* current-user-principal、addressbook-home-set 与通讯录清单,
* 三种客户端一次都满足。
*/
if ($path === '/.well-known/carddav') {
/* 🔴🔴 v42(用户第 10 条):与 dav.php 完全同一套修法 ——
带鉴权的 PROPFIND 直接给发现内容(207),其余仍是 301 到**绝对 URL**。
(Thunderbird 建账号时常带鉴权 PROPFIND 这个地址,
只回 301 它不跟 ⇒ 「找不到通讯录」。) */
$cdWantPropfind = (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'PROPFIND');
$cdHasAuth = !empty($user) && $pass !== null && $pass !== '';
if ($cdWantPropfind && $cdHasAuth) {
$cdWho = Auth::davAuth($user, $pass);
if (!$cdWho) {
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
cd_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。');
}
$cdFamily = (int)$cdWho['family_id'];
$cdMe = (int)$cdWho['person_id'];
$cdParts = [
'<d:response><d:href>' . CardDav::x(CardDav::HOME_PATH) . '</d:href><d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
];
foreach (CardDav::books($cdFamily, $cdMe) as $cdB) {
$cdParts[] = '<d:response><d:href>' . CardDav::x(CardDav::hrefOf($cdB)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
. '<d:displayname>' . CardDav::x((string)$cdB['name']) . '</d:displayname>'
. '</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:card="urn:ietf:params:xml:ns:carddav" '
. 'xmlns:c="urn:ietf:params:xml:ns:caldav">'
. implode('', $cdParts) . '</d:multistatus>';
exit;
}
http_response_code(301);
$wkHost = (string)($_SERVER['HTTP_HOST'] ?? '');
$wkProto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
if ($wkProto === 'http' && !empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
$wkProto = (string)$_SERVER['HTTP_X_FORWARDED_PROTO'];
}
header('Location: ' . (($wkHost !== '') ? ($wkProto . '://' . $wkHost . '/carddav/') : '/carddav/'));
exit;
}
$who = Auth::davAuth($user, $pass);
if (!$who) {
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
cd_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。');
}
$familyId = (int)$who['family_id'];
$me = (int)$who['person_id'];
/** 统一的尾斜杠形式,供"发现链路径"比对(`/carddav` 与 `/carddav/` 视为同一个) */
$norm = '/' . trim($path, '/') . '/';
/**
* 发现链节点。
* ⚠️⚠️ `$isRoot` **只**指**站点根 `/`**,不含 `/carddav/` —— 与 dav.php 同一个坑
* (本批实测):把 `/carddav/` 也算成 root 的话,`PROPFIND /carddav/`
* 只回一行 principal、**一本通讯录都不列**,客户端仍然同步不出数据。
*/
$isRoot = ($path === '/' || $path === '');
$isPrincipal = ($norm === CardDav::PRINCIPAL_PATH);
$isHome = ($norm === CardDav::HOME_PATH);
$hit = CardDav::parsePath($path);
$bookId = (int)$hit['book'];
$cardId = (int)$hit['card'];
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
// ⚠️ ADDRESSBOOK(老客户端会发这个非标准方法)也一起挡掉:它没有定义,
// 放过去会落到最后的 405,不如在这里给一句明确的"我们只读"。
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCOL', 'POST', 'PATCH', 'ADDRESSBOOK'], true)) {
cd_fail(403, '家书的通讯录目前是只读同步:请在本站的名册里增删改联系人。');
}
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
if ($method === 'OPTIONS') {
http_response_code(200);
// addressbook 是 CardDAV 的能力声明(RFC 6352 §6.1);
// 带上 3, extended-mkcol 让个别客户端不再试写方法。
header('DAV: 1, 2, 3, addressbook');
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
header('MS-Author-Via: DAV');
exit;
}
// ---- 4. 找不到那一本 → 404(不要回 200 空列表:客户端会以为"这本是空的")----
// ⚠️ 发现链节点(principal / addressbooks)**不是通讯录 id**,
// parsePath 会把它们解析成 0 —— 必须先把它们放行,否则下面这句把客户端挡在门外。
$isDiscovery = $isRoot || $isPrincipal || $isHome;
if (!$isDiscovery && $bookId > 0
&& !in_array($bookId, array_column(CardDav::books($familyId, $me), 'id'), true)) {
cd_fail(404, '没有这本通讯录。');
}
$vcfOfBook = static function (int $bid) use ($familyId, $me): string {
// 家庭场景只有一本,任何合法 id 都回同一份内容 —— 但**必须校验 id 合法**,
// 否则 /carddav/999.vcf/ 也能拉到数据,客户端会记下一个"错的地址"。
return CardDav::vcf($familyId, $me);
};
$readOnlyPriv = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
$emit = static function (array $parts): void {
CardDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">'
. implode('', $parts) . '</d:multistatus>';
};
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
if ($method === 'PROPFIND') {
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal
// (RFC 6764 §6:客户端把它当"从这个域名开始找 CardDAV 服务"的起点)
if ($isRoot) {
$emit([
'<d:response><d:href>/</d:href><d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
// 5b. principal:客户端拿 addressbook-home-set 才知道"去哪列通讯录"
// ⚠️ 同样要回指向自己的 current-user-principal(理由见 dav.php 同段注释)。
if ($isPrincipal) {
$emit([
'<d:response><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:principal/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::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(以及 `/carddav/` 本身):列出这个人可见的通讯录。
// ⚠️ `/carddav/` 也走这条路:用户与老客户端都直接把 /carddav/ 当 home 用。
if ($isHome || $bookId === 0) {
// 🔴🔴 2026-10-11:与 dav.php 同一个协议隐患 —— **Depth:0 只应回资源自身**。
// 原来无论 depth 都把全部通讯录一起回,线上实测 `PROPFIND /carddav/ Depth:0`
// 第一条 response 是通讯录(不含 current-user-principal),
// 严格客户端可能判"拿不到 principal"。
// ⇒ Depth:0 只回 `/carddav/` 自身(principal / home-set 都在里面)。
if ($depth !== '1') {
$emit([
'<d:response><d:href>' . CardDav::x(CardDav::HOME_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::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;
}
$parts = [];
// 🔴 v33:**始终**先输出 home 集合自身。
// RFC 4918 规定:PROPFIND 的响应**必须包含被请求的资源本身**,
// 哪怕 Depth:1 也一样 —— 只有先有"这个集合存在"的证据,
// 客户端才会继续读它的成员。原来只在 `!$isHome`(即请求 `/carddav/`)时
// 才输出,于是 `PROPFIND /carddav/addressbooks/`(Thunderbird 走的就是这条)
// 只回子集、没有自己 ⇒ 严格客户端判定"这个 home 不存在" ⇒ 通讯录空。
// `/carddav/`(不是 home 路径)那一次附带发现链三件套(保持原样)。
$parts[] = '<d:response><d:href>' . CardDav::x(CardDav::HOME_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
// 首次请求 `/carddav/`(不是 home 路径)时,另加一条指向 `/carddav/` 自身的响应,
// 客户端会拿它当后续请求的 base。
if (!$isHome) {
$parts[] = '<d:response><d:href>/carddav/</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
foreach (CardDav::books($familyId, $me) as $b) {
$parts[] = '<d:response><d:href>' . CardDav::x(CardDav::hrefOf($b)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
. '<d:displayname>' . CardDav::x((string)$b['name']) . '</d:displayname>'
. '<card:addressbook-description>'
. CardDav::x((string)$b['description']) . '</card:addressbook-description>'
. $readOnlyPriv
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit($parts);
exit;
}
if ($depth === '1') {
// 某本里的卡片清单:客户端拿这个 ETag 判断"要不要重新拉"
$rows = CardDav::rows($familyId, $me);
$parts = [];
foreach ($rows as $r) {
$rid = (int)($r['id'] ?? 0);
if ($rid <= 0) {
continue;
}
$card = CardDav::cardOf($r);
// ⚠️ 资源名用 **persons.id**(不是 UID):URL 与正文 UID 可以不同,
// 但用 id 让 href 稳定、且与 CardDav::one 的入参一一对应。
$parts[] = '<d:response><d:href>'
. CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. '<d:resourcetype/>'
. '<d:getcontenttype>text/vcard; charset=utf-8</d:getcontenttype>'
. '<d:getcontentlength>' . strlen($card['body']) . '</d:getcontentlength>'
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
$emit($parts);
exit;
}
// Depth 0:这本通讯录自身的属性
$book = null;
foreach (CardDav::books($familyId, $me) as $b) {
if ((int)$b['id'] === $bookId) {
$book = $b;
}
}
$emit([
'<d:response><d:href>' . CardDav::x(CardDav::bookHref($bookId)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
. '<d:displayname>' . CardDav::x((string)($book['name'] ?? '家庭名册')) . '</d:displayname>'
. $readOnlyPriv
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
// ---- 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) {
header('Allow: OPTIONS, PROPFIND, REPORT');
cd_fail(405, 'GET 只能用于某一本通讯录或其中的卡片;列通讯录请用 PROPFIND。');
}
// 6a. 单张卡(/carddav/1.vcf/12.vcf)。
// 🔴 v31:与 CalDAV 同一个坑 —— 原来**没有**这个分支,
// 于是 `GET /carddav/1.vcf/12.vcf` 会落进下面的"整本导出",
// 一次回 801 张卡(162 KB)。客户端取一张卡却收到整本,
// 要么把整本当成这张卡的正文,要么因 UID 不匹配判定"资源不存在"。
if ($cardId > 0) {
$one = CardDav::one($familyId, $me, $cardId);
if (!$one) {
cd_fail(404, '没有这张联系人卡片。');
}
$card = CardDav::cardOf($one);
$etag = $card['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/vcard; charset=utf-8');
header('ETag: ' . $etag);
if ($method === 'GET') {
echo $card['body'] . "\r\n";
}
exit;
}
// 6b. 整本导出成一个 vcf(老客户端走这条)
$vcf = $vcfOfBook($bookId);
$etag = CardDav::etag($vcf);
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
exit;
}
http_response_code(200);
// text/vcard(RFC 6352 用这个 MIME;个别老客户端认 text/x-vcard)
header('Content-Type: text/vcard; charset=utf-8');
header('ETag: ' . $etag);
header('Cache-Control: private, max-age=0, must-revalidate');
header('Content-Disposition: inline; filename="familybook.vcf"');
if ($method === 'GET') {
echo $vcf;
}
exit;
}
// ---- 7. REPORT:addressbook-query(按条件) / addressbook-multiget(按 href 取)----
if ($method === 'REPORT') {
/* 🔴 v45b:与 dav.php 同一处对齐 —— REPORT 打到 home 容器上时回 403(不是 404)。 */
if ($bookId === 0) {
cd_fail(403, 'REPORT 要打到某一本通讯录上(如 /carddav/1.vcf/),不能打在容器上。');
}
$bodyRaw = (string)file_get_contents('php://input');
/* 🔴 v33:vCard 正文的属性名要**照客户端点名的那个**回。
· `<card:addressbook-data>` 是 **RFC 6352** 的正式名(Thunderbird / Evolution 要它);
· `<card:address-data>` 是早期草案名(老客户端可能还在用)。
原来只发旧名 → Thunderbird 收到 0 个数据块、**通讯录永远是空的**
(用户报「thunderbird 提示没有任何联系人」的真根因)。
⚠️ 别两个都无脑发:正文会重复一份,客户端侧"卡数"翻倍
(e2e 第22条 `BEGIN:VCARD` 计数当场抓到)。
⚠️ 客户端没点名时(如 `addressbook-query` 空 body)默认发**正式名**。 */
$cardDataXml = static function (array $card) use ($bodyRaw): string {
$wantAB = stripos($bodyRaw, 'addressbook-data') !== false;
$wantAD = stripos($bodyRaw, 'address-data') !== false;
if (!$wantAB && !$wantAD) {
$wantAB = true; // 没点名 → RFC 正式名
}
$body = CardDav::x($card['body'] . "\r\n"); // 结尾 CRLF 只加在正文,不参与 ETag
$xml = '';
if ($wantAB) {
$xml .= '<card:addressbook-data>' . $body . '</card:addressbook-data>';
}
if ($wantAD) {
$xml .= '<card:address-data>' . $body . '</card:address-data>';
}
return $xml;
};
// 单张卡请求(/carddav/1.vcf/12.vcf)
if ($cardId > 0) {
$one = CardDav::one($familyId, $me, $cardId);
if (!$one) {
cd_fail(404, '没有这张联系人卡片。');
}
/* ⚠️ 正文与 ETag 都从 cardOf 取(**同一处**算),别在这里另算一次 ——
v24 第 22 批实测:PROPFIND 用不带结尾 CRLF 的正文算 ETag、这里用带 CRLF 的算,
同一张卡两个 ETag → 客户端认为它一直在变、反复重拉(同步不收敛)。
结尾那条 CRLF 只加在**正文**(数据属性要有完整的 vCard 文本),
不参与 ETag。
🔴 v33:属性名由 `$cardDataXml` 按客户端点名决定(见上面的闭包)。 */
$card = CardDav::cardOf($one);
$emit([
'<d:response><d:href>'
. CardDav::x(CardDav::bookHref($bookId) . $cardId . '.vcf') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. $cardDataXml($card)
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>',
]);
exit;
}
// ⚠️ multiget 时客户端会点明要哪几张(href 里带 id)。
// v31 起**真的按 href 过滤**(原来"整本给、客户端自己挑")——
// href 现在完全确定(`/carddav/<book>/<persons.id>.vcf`),
// 只要最后一段就能反查,不必猜"绝对 URL / 前缀 / 编码"。
$wantsMulti = stripos($bodyRaw, 'addressbook-multiget') !== false;
$wantIds = [];
if ($wantsMulti && 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('/\.vcf$/', '', (string)basename(rtrim($h, '/')));
if ($seg !== '' && ctype_digit($seg)) {
$wantIds[(int)$seg] = true;
}
}
}
$rows = CardDav::rows($familyId, $me);
$parts = [];
foreach ($rows as $r) {
$rid = (int)($r['id'] ?? 0);
if ($rid <= 0) {
continue;
}
if ($wantsMulti && $wantIds && !isset($wantIds[$rid])) {
continue; // multiget:只要点名的
}
$card = CardDav::cardOf($r);
$parts[] = '<d:response><d:href>'
. CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. $cardDataXml($card)
. '</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>';
}
unset($bodyRaw);
$emit($parts);
exit;
}
// ---- 8. 其它方法一律 405(并告知支持哪些)----
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
cd_fail(405, '不支持这个请求方法。');