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"。
This commit is contained in:
2026-10-13 11:53:58 +08:00
parent ffde47d476
commit fec898c18d
3 changed files with 368 additions and 29 deletions
+76 -2
View File
@@ -31,6 +31,8 @@ declare(strict_types=1);
namespace App;
use App\Storage\Storage;
final class CardDav
{
/** 资源前缀(URL 里的路径)。与 CalDav::ROOT 并列、互不包含 */
@@ -146,9 +148,31 @@ final class CardDav
$r['birthday'] = Birthday::solarOf($r);
// 与我的关系(名册页上那句"我该怎么称呼他")
$r['relation_word'] = Kinship::label($graph, $personId, $pid, $people);
// 我叫他什么 → 当作"小名"写进 NICKNAME(手机上就能看到"老爸"而不是"杨父")
// 🔴 v46(用户 2026-10-13):**同步出去的显示名 = 我叫他什么**,不是真名。
// 真名改由 N 承载,并连同关系/备注/履历写进 NOTE(见 Vcard::buildNote)。
// ⚠️ 这里不再往 `nickname` 里塞东西:FN 已经是称呼了,
// 再写一个 NICKNAME 会在手机上多出一行同名的"昵称"(纯噪音)。
$nick = Nickname::display($personId, $r);
$r['nickname'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : '';
$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;
@@ -182,6 +206,56 @@ final class CardDav
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 成对返回**。
*