Files
FamilyBook/carddav.php
T

250 lines
11 KiB
PHP
Raw Normal View History

<?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。
* 手机上加/改/删联系人**不会**回写到家书 —— 与其让用户以为同步了,不如明说。
*/
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);
}
}
}
$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'];
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/carddav/'), PHP_URL_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 空列表:客户端会以为"这本是空的")----
if ($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);
};
// ---- 5. PROPFIND:清单 / 属性 / 资源列表 ----
if ($method === 'PROPFIND') {
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
if ($bookId === 0) {
// 根:列出这个人可见的通讯录
$parts = [];
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>'
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
. '</d:prop></d:propstat></d:response>';
}
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>';
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>';
}
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>';
exit;
}
// Depth 0:这本通讯录自身的属性
$book = null;
foreach (CardDav::books($familyId, $me) as $b) {
if ((int)$b['id'] === $bookId) {
$book = $b;
}
}
CardDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">'
. '<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>'
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
. '</d:prop></d:propstat></d:response></d:multistatus>';
exit;
}
// ---- 6. GET / HEAD:整本导出成一个 vcf(不少客户端就走这一条)----
if ($method === 'GET' || $method === 'HEAD') {
if ($bookId === 0) {
cd_fail(404, '没有这本通讯录。');
}
$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 取(**同一处**算),别在这里另算一次 ——
本轮实测:PROPFIND 用不带结尾 CRLF 的正文算 ETag、这里用带 CRLF 的算,
同一张卡两个 ETag → 客户端认为它一直在变、反复重拉(同步不收敛)。
结尾那条 CRLF 只加在**正文**(address-data 要有完整的 vCard 文本),
不参与 ETag。 */
$card = CardDav::cardOf($one);
CardDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">'
. '<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>'
. '<card:address-data>' . CardDav::x($card['body'] . "\r\n") . '</card:address-data>'
. '</d:prop></d:propstat></d:response></d:multistatus>';
exit;
}
// ⚠️ multiget 时客户端会点明要哪几张(href 里带 id)。
// 我们**整本给**、由客户端自己按 href 挑 —— 家庭场景一本最多几百张,
// 整本回的体积完全可接受,而"解析客户端要哪些"那套逻辑
// (href 可能是绝对 URL、可能带前缀)是出错率最高的地方,不值得写。
$rows = CardDav::rows($familyId, $me);
$parts = [];
foreach ($rows as $r) {
$rid = (int)($r['id'] ?? 0);
if ($rid <= 0) {
continue;
}
$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>'
. '<card:address-data>' . CardDav::x($card['body'] . "\r\n") . '</card:address-data>'
. '</d:prop></d:propstat></d:response>';
}
unset($bodyRaw);
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>';
exit;
}
// ---- 8. 其它方法一律 405(并告知支持哪些)----
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
cd_fail(405, '不支持这个请求方法。');