用户 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"。
313 lines
15 KiB
PHP
313 lines
15 KiB
PHP
<?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');
|
||
}
|
||
} |