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:
+27
-18
@@ -132,27 +132,36 @@ final class ContactImport
|
||||
$val = self::unescapeVcard($val);
|
||||
|
||||
switch ($name) {
|
||||
case 'FN': // 格式化姓名(首选)
|
||||
case 'FN': // 格式化姓名(**显示名**;见下面的优先规则)
|
||||
$cur['name'] = $val;
|
||||
break;
|
||||
case 'N': // 结构化姓名:姓;名;中间名;前缀;后缀 → 拼成"名 姓"更符合中文习惯
|
||||
if ($cur['name'] === '') {
|
||||
/* 🔴 v24 第 22 批:与 ADR 同一个坑 —— 分段必须走 `splitVcardSegments()`
|
||||
(**认识转义**)。用 `explode(';', ...)` 的话,
|
||||
姓名里含分号的人(我们导出时把整名塞进"姓"格,
|
||||
而名字是「张三,李四;王五」这种)会被从那里切断。 */
|
||||
$parts = [];
|
||||
foreach (self::splitVcardSegments(substr($ln, $pos + 1)) as $seg) {
|
||||
$parts[] = self::unescapeVcard($seg);
|
||||
}
|
||||
$fam = trim((string)($parts[0] ?? ''));
|
||||
$giv = trim((string)($parts[1] ?? ''));
|
||||
// ⚠️ 中文 vCard 里 N 的姓/名顺序也是"姓;名",但显示要"姓名"连写,
|
||||
// 不加空格 —— 加了会变成"杨 永全"。
|
||||
$joined = $fam . $giv;
|
||||
if ($joined !== '' && !preg_match('/[\x{4e00}-\x{9fa5}]/u', $joined)) {
|
||||
$joined = trim($giv . ' ' . $fam); // 英文名习惯"名 姓"
|
||||
}
|
||||
/* 🔴 v46(用户 2026-10-13):**真名优先于显示名**。
|
||||
我们的 vCard 有三个名字位:
|
||||
· `N` —— 真名(`persons.name`)
|
||||
· `FN` —— 显示名:走 CardDAV 同步时是**我叫他什么**
|
||||
(如「老爸」),**往返导入/备份导出**时是真名
|
||||
· `NICKNAME` —— 小名(只在显示名≠小名时才写)
|
||||
TB 之类的标准客户端**只写 FN**(不写 N,或 FN 与 N 等价),
|
||||
所以"FN 优先、N 兜底"的老规则在那边照样成立;
|
||||
而**我们自己的卡**(FN=称呼、N=真名)若按老规则走,
|
||||
导回来姓名会变成「老爸」—— 一个人被自己的称呼改掉了名字。
|
||||
⇒ 改成:N 只要**有内容就覆盖** FN。两者的先后在文件里是固定的
|
||||
(见 `Vcard::card()`:UID → FN → N → …),
|
||||
所以"后出现的 N 覆盖先出现的 FN"是稳定的,不依赖遍历顺序的巧合。 */
|
||||
$parts = [];
|
||||
foreach (self::splitVcardSegments(substr($ln, $pos + 1)) as $seg) {
|
||||
$parts[] = self::unescapeVcard($seg);
|
||||
}
|
||||
$fam = trim((string)($parts[0] ?? ''));
|
||||
$giv = trim((string)($parts[1] ?? ''));
|
||||
// ⚠️ 中文 vCard 里 N 的姓/名顺序也是"姓;名",但显示要"姓名"连写,
|
||||
// 不加空格 —— 加了会变成"杨 永全"。
|
||||
$joined = $fam . $giv;
|
||||
if ($joined !== '' && !preg_match('/[\x{4e00}-\x{9fa5}]/u', $joined)) {
|
||||
$joined = trim($giv . ' ' . $fam); // 英文名习惯"名 姓"
|
||||
}
|
||||
if ($joined !== '') {
|
||||
$cur['name'] = $joined;
|
||||
}
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user