Files
FamilyBook/app/CardDav.php
T
laoyang feff31baad v45b 修复:Thunderbird「找不到日历/联系人」的真根因
用户 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 个通讯录。
2026-10-13 00:29:55 +08:00

239 lines
11 KiB
PHP
Raw 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
/**
* CardDAV 服务端(v24 第 22 批)
*
* 用户原话:「名册,增加cardav同步功能。」
* 手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)拉到的就是这里。
*
* 与 CalDav 是**并列的两个入口**,刻意不合并成一个 /dav/ 前缀:
* · /dav/ → 日历(本类旁边的 App\CalDav)
* · /carddav/ → 通讯录(本类)
* ⚠️ 为什么不共用 /dav/ 一棵树:两边的"资源类型"(calendar vs addressbook)、
* 命名空间(caldav vs carddav)与属性名完全不同,混在一棵树里要靠
* PROPFIND 的请求体去猜客户端想要哪种 —— 猜错的表现是"客户端连上了但一本都看不到"。
* 分开之后各自只有一种语义,iOS/DAVx⁵ 都是探测到哪个地址就用哪套。
* ⚠️ 但**鉴权是同一套**(Auth::davAuth + dav_credentials 表):一把口令管两种同步,
* 这正是用户要的"用用户名和口令来同步",也省得在两个页面各配一遍。
*
* 客户端的请求顺序(必须按这个最小集实现):
* OPTIONS /carddav/ → DAV: 1, addressbook
* PROPFIND /carddav/ (Depth: 0) → 当前用户的通讯录清单(通常只有一本)
* PROPFIND /carddav/1.vcf/ (Depth: 1) → 这本里所有卡片的资源清单(getetag)
* REPORT /carddav/1.vcf/ → addressbook-query / multiget,取正文
* GET /carddav/1.vcf/ → 整本导出成一个 vcf(很多客户端走这条)
*
* ⚠️ 只读:写方法(PUT/DELETE/PROPPATCH)一律 403。
* 与 CalDav 同样的取舍 —— 与其"半支持"(客户端以为改成功了、服务端却没落库),
* 不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
*/
declare(strict_types=1);
namespace App;
final class CardDav
{
/** 资源前缀(URL 里的路径)。与 CalDav::ROOT 并列、互不包含 */
public const ROOT = '/carddav/';
/** 默认那一本通讯录的 id。家庭场景只有一本,用固定 id 让 URL 稳定可记 */
public const BOOK_ID = 1;
/**
* 客户端**发现链**用的固定路径(RFC 6352 §6 / RFC 6764)。
*
* 🔴 v31 修的真 bug:与 CalDAV 同一个病 —— 这两个入口原来**完全没实现**。
* 实测线上:`PROPFIND /carddav/` 问 current-user-principal 回的是"通讯录清单"
* (一个 principal 都没有)→ 客户端判定"这不是个 CardDAV 服务器" →
* 建账号那一步就失败,一张卡片都同步不出来。
*
* ⚠️ 与 CalDav 刻意**同名同义**(PRINCIPAL_PATH / HOME_PATH 语义一一对应),
* 两边漂移的话,手机上"日历能连、通讯录不能连"这类半通状态会很难查。
*/
public const PRINCIPAL_PATH = '/carddav/principal/';
/**
* 🔴🔴 v41(用户 2026-10-10 第 11 条:「thunderbird 返回的结果是,找不到联系人,
* 找不到日历本」)——真根因与 CalDAV 那边**一模一样**(见 CalDav::HOME_PATH 的长注释):
* 原来是 `/carddav/addressbooks/`,而通讯录自己的 href 是 `/carddav/1.vcf/`
* ⇒ **通讯录不在 home-set 之下** ⇒ Thunderbird 在 home-set 里一本都找不到
* ⇒ 报「找不到联系人」。
* 修法:home-set 就指向 `/carddav/` 自己(成员关系成立,且所有既有 URL 不变)。
*/
public const HOME_PATH = '/carddav/';
/**
* 当前用户可见的"通讯录"。
*
* ⚠️ 家庭场景只有**一本**:你的名册。所以这里不查库、直接回一本固定的,
* 是为了让 URL(/carddav/1.vcf/)稳定 —— 客户端配好之后,
* 哪怕名册里的人数变了,地址也不会变(变了就要重新配)。
* ⚠️ 口径必须与名册页**完全一致**(用户 2026-10-06 第 22 批确认):
* 名册页看到谁就同步谁。所以名册页怎么取的(Person::roster + kind='member'),
* 这里就怎么取(见 rows()),不另发明一套可见性判据 ——
* 否则手机上少几个人、网页上有,用户会以为"同步漏了"。
*/
public static function books(int $familyId, int $personId): array
{
return [[
'id' => self::BOOK_ID,
'name' => '家庭名册',
'description' => '家人与联系人(与网页上的名册一致)',
'count' => count(self::rows($familyId, $personId)),
]];
}
public static function bookHref(int $bookId): string
{
return self::ROOT . $bookId . '.vcf/';
}
public static function hrefOf(array $book): string
{
return self::bookHref((int)$book['id']);
}
/** 从 URL 段解出「这是哪一本 / 要哪一张卡」 */
public static function parsePath(string $path): array
{
$p = (string)parse_url($path, PHP_URL_PATH);
if (strpos($p, self::ROOT) !== 0) {
return ['book' => 0, 'card' => 0];
}
$rest = trim(substr($p, strlen(self::ROOT)), '/');
if ($rest === '') {
return ['book' => 0, 'card' => 0];
}
$parts = explode('/', $rest);
$bookSeg = (string)$parts[0];
$book = (int)preg_replace('/\.vcf$/', '', $bookSeg);
$card = 0;
if (isset($parts[1]) && $parts[1] !== '') {
// 单张卡:<id>.vcf —— 我们用的是 persons.id
$card = (int)preg_replace('/\.vcf$/', '', (string)$parts[1]);
}
return ['book' => $book, 'card' => $card];
}
/**
* 这本通讯录里的所有"卡"(一个人一张),并且**已经把字段备齐**——
* 电话解密、关系称呼、我叫他什么都在这里算好,Vcard 那层只管写文本。
*
* ⚠️ 解密(persons.phone_encrypted 是 AES 密文)为什么放在这一层:
* Vcard 是纯文本生成器,不该碰密钥;而且"取数据"这一步与网页名册
* 走同一个 PersonQuery::phonePlain(**同一套判据**),
* 不会出现"页面上显示得出手机号、同步出去却是空的"。
*/
public static function rows(int $familyId, int $personId): array
{
if ($familyId <= 0 || $personId <= 0) {
return [];
}
$rows = Person::roster($familyId, $personId, 'member', '');
$people = Person::map($familyId);
$graph = Kinship::graph($familyId);
$out = [];
foreach ($rows as $r) {
$pid = (int)$r['id'];
$r['phone'] = PersonQuery::phonePlain($r);
// 生日:走 Birthday::solarOf 取**真实阳历**(本项目里 birthday 列就是这个语义,
// 生日表单字段叫 birthday_solar,两者是两个键名、同一个东西 —— 见该函数注释)。
// ⚠️ 不写农历:vCard 没有农历字段,硬塞进 NOTE 会让手机上出现一句
// 看不懂的"农历三月廿一",而过年过生日那件事日历那边已经管了。
// 也只有"有年份"的阳历生日才写(Vcard 那边会把 "YYYY-MM-DD 00:00:00"
// 截成日期段)。
$r['birthday'] = Birthday::solarOf($r);
// 与我的关系(名册页上那句"我该怎么称呼他")
$r['relation_word'] = Kinship::label($graph, $personId, $pid, $people);
// 我叫他什么 → 当作"小名"写进 NICKNAME(手机上就能看到"老爸"而不是"杨父")
$nick = Nickname::display($personId, $r);
$r['nickname'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : '';
// 补充联系方式(多个电话/邮箱/地址/网址)
$r['contacts'] = Person::contactsOf($pid);
$out[] = $r;
}
return $out;
}
/**
* 单张卡(按 persons.id)。
*
* ⚠️ 必须**先判断这张卡在不在这个人的名册里**,而不是直接按 id 查 persons:
* 直接查的话,任何人拿到一个 id 就能把全家的信息拉走
* (CardDAV 走的是 Basic 口令、无会话,越权在这里尤其危险)。
*/
public static function one(int $familyId, int $personId, int $cardId): ?array
{
if ($cardId <= 0) {
return null;
}
foreach (self::rows($familyId, $personId) as $r) {
if ((int)$r['id'] === $cardId) {
return $r;
}
}
return null;
}
/** 整本导出成 vcf 文本(GET 与 REPORT 都用它) */
public static function vcf(int $familyId, int $personId): string
{
return Vcard::export(self::rows($familyId, $personId), '家庭名册');
}
/**
* 一张卡:**正文与 ETag 成对返回**。
*
* 🔴 v24 第 22 批修的真 bug:这两个值原本在 carddav.php 里各算一次,
* 于是 PROPFIND 用 `etag(Vcard::card($r))`、REPORT 用
* `etag(Vcard::card($r) . "\r\n")` —— **同一张卡算出两个不同的 ETag**。
* 客户端的典型行为是:先 PROPFIND 拿清单(记下 ETag A),
* 再 REPORT 取正文(拿到 ETag B),发现"对不上" → 认为这张卡一直在变,
* 于是**反复重拉**(同步永远不收敛),或者干脆把本地那份标成脏的。
* 症状是"手机通讯录一直在转圈同步",而服务端日志里一切正常。
*
* ⚠️ 修法不是"两处都改成一样",而是**让它们没有机会不一样**:
* 正文里的结尾 CRLF(REPORT 的 address-data 需要一个完整的 vCard 文本)
* 由调用方按需加,ETag **只由这一处**从**不带结尾 CRLF** 的卡算出来。
* 以后再加第三条路径时,它也只能走这里。
*
* @return array{body:string, etag:string}
*/
public static function cardOf(array $row): array
{
$body = Vcard::card($row);
return ['body' => $body, 'etag' => self::etag($body)];
}
/**
* ETag:客户端靠它判断"这张卡改过没有"。
*
* ⚠️ 用**内容**哈希而不是 updated_at 的最大值:名册的可见性还受"关系/分享"影响
* (同一个人没改,但别人把他分享给了我 → 我这本的内容变了,而 updated_at 没变)。
* 内容哈希对这种"看不见的改动"也敏感,是这里唯一稳的判据。
*/
public static function etag(string $vcf): string
{
return '"' . md5($vcf) . '"';
}
// ---------------- 应答辅助(与 CalDav 同名同义,刻意保持一致) ----------------
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: text/xml; charset="utf-8"');
header('DAV: 1, 2, 3, addressbook', false);
header('DAV: extended-mkcol', false);
}
public static function x(string $s): string
{
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
}
}