Files
laoyang fec898c18d v46:CardDAV 通讯录 —— 显示名用称呼 + 档案进备注 + 单位进工作地址 + 头像内嵌
用户 2026-10-13 四条(含一条安全确认):

① 【安全确认,无需改代码】「加日历时要输 Token,加通讯录时不再要了,这权限不对吧?」
   实测线上:匿名 PROPFIND/GET /carddav/ → 401;错口令 → 401;正确 DAV 口令 → 207。
   **CardDAV 与 CalDAV 共用同一把 DAV 口令、且都强制鉴权**,不存在绕过。
   用户没被再问一次是因为 Thunderbird 会复用**同一主机**上已存的 Basic 凭据
   (realm 都是 "FamilyBook DAV")—— 那是客户端的设计,不是服务端的洞。

② 【头像】「通讯录同步都没有头像,但咱们是有头像的……清晰度调到最高」
   · 新增 PHOTO 行:`PHOTO;ENCODING=b;TYPE=JPEG:<base64>`(vCard 3.0 内嵌,原字节不二次压缩)
   · 库里那张已是上传时重采样的 512×512 JPEG q88 ⇒ 清晰度已到顶;
     仅当 >300KB 时才等比缩到 384(shrinkImage)
   · 🔴 字节**必须走 Storage::read**:线上 storage_driver=oss,
     实测 20 个有头像的人里**只有 5 个在本地 uploads/、15 个只在 OSS**
     ⇒ 只读本地会静默漏掉 15 个人的头像。已实测 photoBytes 取到 20/20。
   ⚠️ 已实测告知:**Thunderbird 不会显示**这张照片 —— TB 157 的
      `modules/VCardUtils.sys.mjs` typeMap 里**没有 photo**;
      但 iOS 通讯录 / DAVx⁵ / 群晖 Contacts 支持,所以照写。

③ 【显示名 + 备注】「名字用我们给他起的称呼,把原名/关系/我写的备注都放进备注」
   · `FN` = 称呼(`nicknames`,viewer 维度;没起过则回退真名)
   · `N`   = 真名(iOS 靠它排序/搜索;缺了会按"无名"排)
   · `NICKNAME` 只在"显示名≠小名"时写(同步时 FN 已是小名 ⇒ 不重复;
     备份导出时 FN=真名 ⇒ NICKNAME 保住小名,往返不丢)
   · `NOTE` 富化:原名 / 与我的关系 / **我写的私人备注**(person_notes)/
     自我介绍 / 现单位 / 现职称 / 工作经历(多段,从早到晚,末段标"现在")

④ 【单位 + 工作地址】「职称和单位只写现在的;工作经历也放备注;单位放到工作地址栏」
   · ORG/TITLE 只写当前(persons.org/title 本就是 PersonJob::syncCurrent 的当前快照)
   · 新增 `ADR;TYPE=WORK:;;<当前单位>;;;;`(用户明确"就是工作单位,不需要单位地址";
     该栏在 TB 的 typeMap 里有 `adr.work`,是它通讯录**会显示**的字段)

🔴 一条关键设计约束:**富化只在"同步模式"下做**(`$display !== $real`)。
   备份导出那条路径(显示名=真名)NOTE 只放原有备注、不写 ADR;TYPE=WORK ——
   否则既有 e2e 的**往返一致**守卫会红:导出的富化备注会被当备注再存一份,
   越导越多且不可逆。第一版只给"自我介绍"打了门禁、其余照拼,往返断言当场变红。

🔴 配套改 `ContactImport`:姓名改成 **N(真名)只要非空就覆盖 FN(显示名)**
   (原来是 FN 优先)。否则我们的卡(FN=称呼、N=真名)导回来会
   **让一个人被自己的称呼改掉名字**。安全性已逐条确认:我们自己的卡字段顺序固定、
   标准客户端只写 FN、老手机只写 N —— 三种情况结果都正确。

验证(新增两个套件):
  · `_fb_v46_carddav.py`(29 条):造"信息最全"夹具(称呼/私人备注/自我介绍/当前单位职称/
    三段经历/真 JPEG 头像)→ 真发 CardDAV 请求拉回逐字段断言 + 鉴权四态。**29/0**
  · `_b46_carddav_falsify.py`(9 个注入):FN 改回真名 / 备注少项 / 不写 PHOTO /
    头像数据源切断 / 放行匿名 —— **9/9 全抓住**
  · `_fb_b22_vcf_spec.php`(vCard 规格 + 往返一致):**40/0**
  · 全量 e2e **1510/0**;TB 探针 44/0;DAV 诊断 28/0;v45 双向写 24/0
  · 全流水线 **PUSH_RC=0**(含部署静态断言"第46批形态")
  · **线上终验 13/0**:真公网拉 801 张卡;FN='胖胖' / N='杨永全' /
    NOTE='原名:杨永全;与我的关系:丈夫;现单位:中国海洋大学;现职称:副教授;
    工作经历:中国海洋大学(副教授)';20 张卡带 PHOTO 且 base64 可解回真图;
    匿名/错口令 401

顺带修掉一个**探针自身的偶发假红**(v45c 引入的,与 v46 无关):
  `_fb_b44_ui.py` 的 3b 用"浏览器此刻分钟"当基准,而页面是服务端在 10:07:59 渲染的
  ⇒ 时钟漂移 1 分钟;跨度 3 小时时 1 分钟=0.556% 又超 0.3% 容差 ⇒ 两条判据同时违反。
  改成用**服务端自报的 data-now-min 反算它自己该放的位置**(两端同源、零容差、不受漂移影响);
  `_b44_falsify.py` 的关键词同步更新。v44 反向验证恢复"基线全绿 + 19/19"。
2026-10-13 11:53:58 +08:00

313 lines
15 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
/**
* 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;
use App\Storage\Storage;
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);
// 🔴 v46(用户 2026-10-13):**同步出去的显示名 = 我叫他什么**,不是真名。
// 真名改由 N 承载,并连同关系/备注/履历写进 NOTE(见 Vcard::buildNote)。
// ⚠️ 这里不再往 `nickname` 里塞东西:FN 已经是称呼了,
// 再写一个 NICKNAME 会在手机上多出一行同名的"昵称"(纯噪音)。
$nick = Nickname::display($personId, $r);
$r['display_name'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : '';
$r['nickname'] = '';
// 🔴 v46:**我给他写的私人备注**(`person_notes`,只有写的人自己看得到)。
// 用户点名要的"我们是怎么认识的"就在这里。它按 viewer 维度取 ——
// 同步给谁,就带谁写的那一份(不是别人的)。
$r['my_note'] = PersonNote::get($pid, $personId);
// 🔴 v46:工作经历(多段)→ 拼进 NOTE。
// `PersonJob::list()` 已保证"现在那段"排最前、其余按开始时间倒序;
// Vcard 那边只反转,不重排(避免两处排序口径打架)。
$r['jobs'] = PersonJob::list($pid);
// 🔴 v46:头像**原始字节**(vCard 的 PHOTO 要 base64 内嵌)。
// ⚠️ 必须走 Storage::read:线上 `storage_driver=oss`,
// 实测有一半头像**不在本地** uploads/(早期的在 OSS 上)。
// 直接 file_get_contents(FB_UPLOADS/...) 会漏掉一半人。
// ⚠️ 读不到就当没有头像(不抛异常、不写半个 PHOTO)——
// 一个头像取不到不该让整本通讯录同步失败。
$r['photo_bin'] = self::photoBytes($familyId, (string)($r['avatar'] ?? ''));
// 补充联系方式(多个电话/邮箱/地址/网址)
$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), '家庭名册');
}
/**
* 头像的**原始字节**(给 vCard 的 PHOTO 内嵌用)。
*
* 「这是什么东西」与安全判据由**调用方**保证:
* · key 来自 `persons.avatar`(我们自己的上传器写的),不是用户随手传的路径;
* · 这个人**在不在 viewer 的名册里**由 `rows()` 已经筛过(越权在这里被挡掉)。
* 所以这里只做两件事:读字节 + 判"是不是图片"。
*
* ⚠️ 必须走 `Storage::read` 而不是 `file_get_contents`:线上存储后端是 OSS
* (`storage_driver=oss`),实测约一半头像**不在本地 uploads/**。
* 只读本地会让那些人同步出去"没有头像",而且无声无息。
* ⚠️ **绝不抛异常**:一个头像取不到,不该让整本通讯录同步失败。
* 取不到就返回 '',Vcard 那边当"这人没头像"处理。
*/
private static function photoBytes(int $familyId, string $key): string
{
$key = trim($key);
if ($key === '' || $familyId <= 0) {
return '';
}
// 外部绝对地址(历史数据):不跟着抓远端(同步路径不该发起外网请求)
if (preg_match('#^https?://#i', $key)) {
return '';
}
try {
$res = Storage::read($familyId, $key);
} catch (\Throwable $e) {
return '';
}
if (empty($res['ok']) || (int)($res['status'] ?? 0) !== 200) {
return '';
}
$bin = (string)($res['body'] ?? '');
if ($bin === '' || strlen($bin) > 4 * 1024 * 1024) {
return ''; // 空 / 大得离谱(不该发生)→ 当没有头像
}
// 用 finfo 认真实类型再放行:只会写图片格式,别的(被换成别的文件)一律不发。
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
if ($fi) {
$mime = (string)finfo_buffer($fi, $bin);
finfo_close($fi);
if (!preg_match('#^image/(jpeg|png|gif|webp)$#', $mime)) {
return '';
}
}
}
return $bin;
}
/**
* 一张卡:**正文与 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');
}
}