/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 → card:addressbook-home-set * ④ PROPFIND Depth:1 → 列出通讯录 * ⑤ PROPFIND 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') { http_response_code(301); header('Location: /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 = '' . ''; $emit = static function (array $parts): void { CardDav::multis(); echo '' . "\n" . '' . implode('', $parts) . ''; }; // ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ---- if ($method === 'PROPFIND') { $depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0'); // 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal // (RFC 6764 §6:客户端把它当"从这个域名开始找 CardDAV 服务"的起点) if ($isRoot) { $emit([ '/' . '' . '' . CardDav::x(CardDav::PRINCIPAL_PATH) . '' . '', ]); exit; } // 5b. principal:客户端拿 addressbook-home-set 才知道"去哪列通讯录" // ⚠️ 同样要回指向自己的 current-user-principal(理由见 dav.php 同段注释)。 if ($isPrincipal) { $emit([ '' . CardDav::x(CardDav::PRINCIPAL_PATH) . '' . '' . '' . '' . CardDav::x('家书通讯录') . '' . '' . CardDav::x(CardDav::PRINCIPAL_PATH) . '' . '' . CardDav::x(CardDav::PRINCIPAL_PATH) . '' . '' . CardDav::x(CardDav::HOME_PATH) . '' . $readOnlyPriv . '', ]); exit; } // 5c. home-set(以及 `/carddav/` 本身):列出这个人可见的通讯录。 // ⚠️ `/carddav/` 也走这条路:用户与老客户端都直接把 /carddav/ 当 home 用。 if ($isHome || $bookId === 0) { $parts = []; // 首次请求 `/carddav/`(不是 home 路径)时,把发现链的答案一并给出 —— // 有些客户端在这里**同时**问 current-user-principal,少给一个它就又断了。 if (!$isHome) { $parts[] = '/carddav/' . '' . '' . '' . CardDav::x('家书通讯录') . '' . '' . CardDav::x(CardDav::PRINCIPAL_PATH) . '' . '' . CardDav::x(CardDav::PRINCIPAL_PATH) . '' . '' . CardDav::x(CardDav::HOME_PATH) . '' . ''; } foreach (CardDav::books($familyId, $me) as $b) { $parts[] = '' . CardDav::x(CardDav::hrefOf($b)) . '' . '' . '' . '' . CardDav::x((string)$b['name']) . '' . '' . CardDav::x((string)$b['description']) . '' . $readOnlyPriv . ''; } $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[] = '' . CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '' . '' . '' . CardDav::x($card['etag']) . '' . '' . 'text/vcard; charset=utf-8' . '' . strlen($card['body']) . '' . ''; } $emit($parts); exit; } // Depth 0:这本通讯录自身的属性 $book = null; foreach (CardDav::books($familyId, $me) as $b) { if ((int)$b['id'] === $bookId) { $book = $b; } } $emit([ '' . CardDav::x(CardDav::bookHref($bookId)) . '' . '' . '' . '' . CardDav::x((string)($book['name'] ?? '家庭名册')) . '' . $readOnlyPriv . '', ]); exit; } // ---- 6. GET / HEAD:整本导出成一个 vcf,或单张卡 ---- if ($method === 'GET' || $method === 'HEAD') { if ($bookId === 0) { cd_fail(404, '没有这本通讯录。'); } // 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') { if ($bookId === 0) { cd_fail(404, '没有这本通讯录。'); } $bodyRaw = (string)file_get_contents('php://input'); // 单张卡请求(/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 只加在**正文**(address-data 要有完整的 vCard 文本), 不参与 ETag。 */ $card = CardDav::cardOf($one); $emit([ '' . CardDav::x(CardDav::bookHref($bookId) . $cardId . '.vcf') . '' . '' . '' . CardDav::x($card['etag']) . '' . '' . CardDav::x($card['body'] . "\r\n") . '' . '', ]); exit; } // ⚠️ multiget 时客户端会点明要哪几张(href 里带 id)。 // v31 起**真的按 href 过滤**(原来"整本给、客户端自己挑")—— // href 现在完全确定(`/carddav//.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[] = '' . CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '' . '' . '' . CardDav::x($card['etag']) . '' . '' . CardDav::x($card['body'] . "\r\n") . '' . ''; } unset($bodyRaw); $emit($parts); exit; } // ---- 8. 其它方法一律 405(并告知支持哪些)---- header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT'); cd_fail(405, '不支持这个请求方法。');