2026-10-06 17:33:48 +08:00
|
|
|
|
<?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。
|
|
|
|
|
|
* 手机上加/改/删联系人**不会**回写到家书 —— 与其让用户以为同步了,不如明说。
|
2026-10-08 20:05:01 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
|
* 🔴 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 判定。
|
2026-10-06 17:33:48 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
|
|
|
|
|
|
$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') {
|
2026-10-11 18:35:03 +08:00
|
|
|
|
/* 🔴🔴 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: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: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;
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
http_response_code(301);
|
2026-10-11 18:35:03 +08:00
|
|
|
|
$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/'));
|
2026-10-08 20:05:01 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$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'];
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/** 统一的尾斜杠形式,供"发现链路径"比对(`/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);
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$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 空列表:客户端会以为"这本是空的")----
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// ⚠️ 发现链节点(principal / addressbooks)**不是通讯录 id**,
|
|
|
|
|
|
// parsePath 会把它们解析成 0 —— 必须先把它们放行,否则下面这句把客户端挡在门外。
|
|
|
|
|
|
$isDiscovery = $isRoot || $isPrincipal || $isHome;
|
|
|
|
|
|
if (!$isDiscovery && $bookId > 0
|
|
|
|
|
|
&& !in_array($bookId, array_column(CardDav::books($familyId, $me), 'id'), true)) {
|
2026-10-06 17:33:48 +08:00
|
|
|
|
cd_fail(404, '没有这本通讯录。');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$vcfOfBook = static function (int $bid) use ($familyId, $me): string {
|
|
|
|
|
|
// 家庭场景只有一本,任何合法 id 都回同一份内容 —— 但**必须校验 id 合法**,
|
|
|
|
|
|
// 否则 /carddav/999.vcf/ 也能拉到数据,客户端会记下一个"错的地址"。
|
|
|
|
|
|
return CardDav::vcf($familyId, $me);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$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:发现链 + 清单 / 属性 / 资源列表 ----
|
2026-10-06 17:33:48 +08:00
|
|
|
|
if ($method === 'PROPFIND') {
|
|
|
|
|
|
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// 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: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:propstat></d:response>',
|
|
|
|
|
|
]);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5c. home-set(以及 `/carddav/` 本身):列出这个人可见的通讯录。
|
|
|
|
|
|
// ⚠️ `/carddav/` 也走这条路:用户与老客户端都直接把 /carddav/ 当 home 用。
|
|
|
|
|
|
if ($isHome || $bookId === 0) {
|
2026-10-11 10:12:11 +08:00
|
|
|
|
// 🔴🔴 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:propstat></d:response>',
|
|
|
|
|
|
]);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$parts = [];
|
2026-10-09 08:45:22 +08:00
|
|
|
|
// 🔴 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:propstat></d:response>';
|
|
|
|
|
|
// 首次请求 `/carddav/`(不是 home 路径)时,另加一条指向 `/carddav/` 自身的响应,
|
|
|
|
|
|
// 客户端会拿它当后续请求的 base。
|
2026-10-08 20:05:01 +08:00
|
|
|
|
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:propstat></d:response>';
|
|
|
|
|
|
}
|
2026-10-06 17:33:48 +08:00
|
|
|
|
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>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. $readOnlyPriv
|
2026-10-06 17:33:48 +08:00
|
|
|
|
. '</d:prop></d:propstat></d:response>';
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit($parts);
|
2026-10-06 17:33:48 +08:00
|
|
|
|
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:propstat></d:response>';
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit($parts);
|
2026-10-06 17:33:48 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Depth 0:这本通讯录自身的属性
|
|
|
|
|
|
$book = null;
|
|
|
|
|
|
foreach (CardDav::books($familyId, $me) as $b) {
|
|
|
|
|
|
if ((int)$b['id'] === $bookId) {
|
|
|
|
|
|
$book = $b;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit([
|
|
|
|
|
|
'<d:response><d:href>' . CardDav::x(CardDav::bookHref($bookId)) . '</d:href>'
|
2026-10-06 17:33:48 +08:00
|
|
|
|
. '<d:propstat><d:prop>'
|
|
|
|
|
|
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
|
|
|
|
|
|
. '<d:displayname>' . CardDav::x((string)($book['name'] ?? '家庭名册')) . '</d:displayname>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. $readOnlyPriv
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>',
|
|
|
|
|
|
]);
|
2026-10-06 17:33:48 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// ---- 6. GET / HEAD:整本导出成一个 vcf,或单张卡 ----
|
2026-10-06 17:33:48 +08:00
|
|
|
|
if ($method === 'GET' || $method === 'HEAD') {
|
|
|
|
|
|
if ($bookId === 0) {
|
|
|
|
|
|
cd_fail(404, '没有这本通讯录。');
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 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(老客户端走这条)
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$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');
|
|
|
|
|
|
|
2026-10-09 08:45:22 +08:00
|
|
|
|
/* 🔴 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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
// 单张卡请求(/carddav/1.vcf/12.vcf)
|
|
|
|
|
|
if ($cardId > 0) {
|
|
|
|
|
|
$one = CardDav::one($familyId, $me, $cardId);
|
|
|
|
|
|
if (!$one) {
|
|
|
|
|
|
cd_fail(404, '没有这张联系人卡片。');
|
|
|
|
|
|
}
|
|
|
|
|
|
/* ⚠️ 正文与 ETag 都从 cardOf 取(**同一处**算),别在这里另算一次 ——
|
2026-10-08 20:05:01 +08:00
|
|
|
|
v24 第 22 批实测:PROPFIND 用不带结尾 CRLF 的正文算 ETag、这里用带 CRLF 的算,
|
2026-10-06 17:33:48 +08:00
|
|
|
|
同一张卡两个 ETag → 客户端认为它一直在变、反复重拉(同步不收敛)。
|
2026-10-09 08:45:22 +08:00
|
|
|
|
结尾那条 CRLF 只加在**正文**(数据属性要有完整的 vCard 文本),
|
|
|
|
|
|
不参与 ETag。
|
|
|
|
|
|
|
|
|
|
|
|
🔴 v33:属性名由 `$cardDataXml` 按客户端点名决定(见上面的闭包)。 */
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$card = CardDav::cardOf($one);
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit([
|
|
|
|
|
|
'<d:response><d:href>'
|
2026-10-06 17:33:48 +08:00
|
|
|
|
. CardDav::x(CardDav::bookHref($bookId) . $cardId . '.vcf') . '</d:href>'
|
|
|
|
|
|
. '<d:propstat><d:prop>'
|
|
|
|
|
|
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
|
2026-10-09 08:45:22 +08:00
|
|
|
|
. $cardDataXml($card)
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. '</d:prop></d:propstat></d:response>',
|
|
|
|
|
|
]);
|
2026-10-06 17:33:48 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ⚠️ multiget 时客户端会点明要哪几张(href 里带 id)。
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$rows = CardDav::rows($familyId, $me);
|
|
|
|
|
|
$parts = [];
|
|
|
|
|
|
foreach ($rows as $r) {
|
|
|
|
|
|
$rid = (int)($r['id'] ?? 0);
|
|
|
|
|
|
if ($rid <= 0) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
if ($wantsMulti && $wantIds && !isset($wantIds[$rid])) {
|
|
|
|
|
|
continue; // multiget:只要点名的
|
|
|
|
|
|
}
|
|
|
|
|
|
$card = CardDav::cardOf($r);
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$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>'
|
2026-10-09 08:45:22 +08:00
|
|
|
|
. $cardDataXml($card)
|
2026-10-06 17:33:48 +08:00
|
|
|
|
. '</d:prop></d:propstat></d:response>';
|
|
|
|
|
|
}
|
|
|
|
|
|
unset($bodyRaw);
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit($parts);
|
2026-10-06 17:33:48 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 8. 其它方法一律 405(并告知支持哪些)----
|
|
|
|
|
|
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
|
|
|
|
|
cd_fail(405, '不支持这个请求方法。');
|