v24 第 22 批:名册加 CardDAV 同步 + 一把通用 DAV 口令(日历与通讯录共用)
用户 2 条原话:
①「名册,增加cardav同步功能。」
②「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令来同步,
避免在第三方客户端进行同步时自己的密码泄露。」
用户拍板的两个岔路(AskUserQuestion):
· 口令范围 = **一把通用 DAV 口令**(日历 + 通讯录共用),不是两个 ——
手机上填一次就都通;两把会让用户要在两个页面各配一遍、还容易只配一半。
· 同步范围 = 与**名册页完全一致**(Person::roster(kind='member')),不另发明判据。
════ 一、 口令统一:caldav_credentials → dav_credentials(Schema v23)════
表**改名**(不是新建一张 carddav_credentials):
· 老站 RENAME → 已配好的口令一字不动,**所有设备不用重配**;
· 新装时两表都不在 → 跳过(直接 RENAME 一张不存在的表会抛异常、把升级打断);
· 两表并存(上次升级跑一半 / 有人手工建过)→ 搬行(INSERT IGNORE)+ DROP 旧表。
⚠️ 迁移**必须**是 RENAME + 存在性守卫(新增 Schema::hasTable),
只改建表语句里的名字的话,老库会**再建一张空表**、旧表留在原地没人读 →
用户所有已配好的同步设备立刻全部 401(而且改密码也修不好:新口令写进了新表)。
Auth:caldavIssue/Revoke/Auth/hasCalDav → davIssue/davRevoke/davAuth/hasDav,
新增 davLastUsed()(UI 上显示"最近使用")。
口令管理入口搬到**个人设置 → DAV 同步**(它管的是"我的凭据",同时服务日历与通讯录;
放在日历页会让只想同步通讯录的人在日程里找半天)。
· 旧路由 calendar/caldav_token|caldav_revoke 保留为 302 → 老书签不 404;
· 日历本页只留地址 + 一个指路按钮(密码类的东西只该有一个管理入口,
两处各放一套按钮迟早对不上:这页重置了、那页还显示"已配置")。
════ 二、 CardDAV(全新)════
· app/Vcard.php —— 名册 → vCard **3.0**(iOS/DAVx⁵ 推荐;4.0 在 iOS 上会被降级丢字段)。
FN 必须有(缺了 iOS 会**整张卡丢掉**)、UID 用 person id(稳定,不随改名变)、
标签能对上固定词就走 TYPE(手机上显示成"手机/工作"),对不上就原样放进
X-ABLabel(**不猜**成 HOME —— 猜错了用户看到"家"其实是"奶奶家")。
· app/CardDav.php —— books/rows/one/cardOf/etag/parsePath;ROOT='/carddav/'。
· carddav.php —— OPTIONS / PROPFIND(0,1) / GET / REPORT;**写方法一律 403**。
只读是与 CalDAV 同样的取舍:与其"半支持"(客户端以为改成功了、服务端却没落库),
不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
· **地址空间分离、鉴权同一套**:/carddav/ 与 /dav/ 各自独立(资源类型与命名空间
完全不同,混在一棵树里要靠猜客户端意图,猜错的表现是"连上了但一本都看不到");
但两者调**同一个** Auth::davAuth、读**同一张** dav_credentials 表 ——
这正是"一把口令管两种同步"的落点,也保证不会出现"日历能连、通讯录连不上"。
· 路由三处:router.php(本地 php -S)+ .htaccess(书面来源)+ 部署脚本 5c 段。
⚠️ 线上用**独立标记块** `# >>> FamilyBook CardDAV`:老站已经有
`# >>> FamilyBook CalDAV`,把新规则并进那一段会被"标记在不在"的幂等判断
**整段跳过** → /carddav/ 永远 404,而本地(走 router.php)完全正常。
· 打包白名单加 carddav.php(站点根下的独立入口最容易漏 —— dav.php / upload_stage.php
都漏过一次,症状都是"线上那个功能 404、本地一切正常")。
════ 三、 🔴 三个真 bug(全是"本地校验全绿"型,靠 vCard 往返测试抓出来)════
把 App\Vcard(导出)与 App\ContactImport(导入)各自单测都是绿的,
但**导出→导入跑一遍**立刻翻出三个:
① Vcard::fold **漏了"续行补前导空格"**(照抄 Ics::fold 时抄漏了最后一步)。
后果:折出来的那几段变成**独立的属性行** → 客户端把半个名字/半个地址当垃圾丢掉。
而"每行 ≤75 字节""确实折行了"全是绿的 —— 只有「**折行可无损还原**」这条断言能抓。
② ContactImport 用 `explode(';')` 给 ADR / N 分段 —— PHP 的 explode **不认识转义**,
值里写了 `\;`(字面分号)照样在那里拆开 → 地址「青岛市,市南区;某某路1号」被
从中间切断,后半截(含省名)跑到别的段上;导进来的人地址只剩前半句、
而且看着像"省没填"。手机通讯录里带分号的地址("XX路1号;3单元")不算罕见。
→ 新增 `splitVcardSegments()`:**按未转义的分号**切,切完再逐段 unescape。
⚠️ "先 unescape 再分段"是同一个错的另一种写法(本轮第一版就这么改,往返照样红)。
③ Vcard::adrField 的**分号数量对、但位置错一位**(省落到了"邮编"格)。
第一版 `;;` + 街道 + `;;;` + 省 + `;` —— 总数也是 6,可多出来的那个挤在前面 →
解析器读段 4 拿到空串。**段数对、总数对、只有位置错一位**,
所以"数分号个数"的检查发现不了它。
════ 四、 顺带修的一个 ETag 一致性 bug ════
同一张卡在 PROPFIND(清单)与 REPORT(取正文)里原本算出**两个不同的 ETag**
(一个用不带结尾 CRLF 的正文、一个用带 CRLF 的)→ 客户端认为这张卡一直在变、
**反复重拉**(同步永远不收敛),而服务端看不出任何异常(两边都合法)。
→ 新增 `CardDav::cardOf()`,把**正文与 ETag 成对返回**,结尾 CRLF 只加在正文上、
不参与 ETag。修法不是"两处改成一样",而是**让它们没有机会不一样**。
════ 五、 UI ════
· 个人设置页「DAV 同步」卡:两个地址(/dav/ 与 /carddav/)+ 用户名预填 + 一次性明文
(只显示这一次,走一次性会话槽位)+ 生成/重置/撤销 + "最近使用"。
· 名册页 CardDAV 卡:地址 + "这 N 个人"(人数与名册页一致,用户当场能对上)+
指路口令的按钮。**不放**生成表单。
· 日历本页改口径:只留 /dav/ 地址 + 指路链接。
════ 六、 验证 ════
- e2e **1106/0**(本批新增约 45 条:CardDAV 全协议、越权 404、只读 403、
一把口令开两把锁、撤销后两处**同时**失效、口令一次性/不落库、ETag 一致性)
- **vCard 规格体检 36/36**(新工具 `_fb_b22_vcf_spec.php`:转义/折行/多值/标签/
空名兜底 + **导出→导入往返一致**)
- **真机探针 28/0**(新工具 `_fb_b22_ui.py`:两块 UI 渲染、**在浏览器里点按钮**生成
口令、刷新后明文消失但状态留下、名册页人数对得上、日历本页改口径、
撤销后两处 401、全程零 JS 报错)
- 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0 · 结构升级 149/0
- 部署静态断言空跑 **307/0** · 线上部署 134 项自检 0 失败
- **反向验证 14/14**(`_fb_b22_falsify.py`):迁移不干活 / 读旧表 / 断入口 /
去掉 addressbook 声明 / 只读守卫失效 / 不校验口令 / 越权不再查名册 /
折行不补空格 / 分段改回 explode / ADR 错位 / UI 卡拿掉 / 明文不再一次性 /
撤销不删凭据 —— 逐条改回旧行为,对应断言都变红,还原后复绿。
════ 七、 🔴🔴 线上实测挖出的**最重要**的一条:DAV 鉴权在 Apache 上一直是坏的 ════
部署后在线上以 **Web 用户(www)** 身份做端到端验收时,DAV 全部 401 ——
而本地 e2e 全绿。追下去发现两层原因:
① **测试假象**:我把 DAV 请求放进了一个**临时探针文件**里,用 `curl` 打它。
但 Apache 请求 `.php` 文件时**不套用站点根 .htaccess 的 URL 重写** ——
所以探针里的请求根本没被转给 dav.php/carddav.php,拿到的是**探针自己的 401**,
不是 DAV 的。第一轮"全 401"有一半是探针自己造的假象。
② **真问题**:Apache 默认**不把 `Authorization` 头交给 PHP-FPM** ——
`$_SERVER['PHP_AUTH_USER']` 与 `HTTP_AUTHORIZATION` **全是 null**。
实测(这台服务器 Apache 2.4.53,直接监听 80/443,**没有 nginx**):
基线 : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
`CGIPassAuth On`: {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
⇒ **线上 CalDAV 从来没被真实客户端连上过**(服务端代码是对的、环境少了这一行)。
本轮 e2e 用 `php -S`,那里 `PHP_AUTH_USER` 是**自己填好的**,所以一路全绿 ——
又一个"本地好好的、线上不行"。
修法:部署脚本 5c 段追加**独立标记块** `# >>> FamilyBook DAV auth`,内容
`CGIPassAuth On`(Apache **2.4.13+ 的核心指令**,不需要额外模块、**不用动宝塔面板**)。
仓库 `.htaccess` 同步写上(它是那段规则唯一的书面来源)。
修完后线上端到端**全部通过**(以 www 身份、真实 HTTP 服务、真实数据):
· CalDAV:`PROPFIND → 207`、**8 本日历本**、`PUT → 403`
· CardDAV:根 `207` + `<card:addressbook/>`;清单 `207`、**1089 张卡**、1089 个 ETag;
整本 vcf `200`、1089 张、全是 3.0、`text/vcard`;**最长行 75 字节**(真实数据上
折行真的发生了);`REPORT → 207` 带 `address-data`;`PUT → 403`;
`OPTIONS → 200 DAV: 1, 2, 3, addressbook`
· 导出的卡数 **1089 == 名册人数 1089**(口径一致)
· 口令错 → `401`
临时探针用完即删并复验 404;临时插入的口令用完即清(不动用户已配的凭据)。
════ 八、 关于 CalDAV「本来是好的」这件事 ════
严格说:**服务端实现一直是对的**,缺的是 Apache 那一行 `CGIPassAuth`。
也就是说这个仓库里的 CalDAV 之前**上线即不可用**(只是没人用真实客户端试过)。
本批顺手把它一起修好了 —— 现在 /dav/ 与 /carddav/ 都能被 iOS / DAVx⁵ 正常连上。
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
# 原因:线上站点根那份 .htaccess 是**宝塔面板生成的**(PHP 版本段、防盗链、
|
||||
# 缓存段、伪静态…),整份覆盖会把面板的东西清空,而且面板下次重写还会改回来,
|
||||
# 变成"时好时坏"。
|
||||
# /dav/ 的重写规则由 `familybook_deploy.sh` 第 5c 段**追加**到线上那份里
|
||||
# /dav/ 与 /carddav/ 的重写规则由 `familybook_deploy.sh` 第 5c 段**追加**到线上那份里
|
||||
# (带 `# >>> FamilyBook CalDAV` 标记,幂等)。
|
||||
#
|
||||
# 那这份文件是干什么用的?
|
||||
@@ -12,7 +12,7 @@
|
||||
# · 它是那段规则的**唯一书面来源**,push 脚本会断言部署脚本里的规则串
|
||||
# 与它完全一致(防两处漂移)。
|
||||
#
|
||||
# 只做一件事:把 /dav/ 开头的 URL 交给 dav.php。
|
||||
# 只做一件事:把 /dav/ 与 /carddav/ 开头的 URL 分别交给 dav.php / carddav.php。
|
||||
# 其余一律不动 —— index.php 那套 ?r=xxx 路由不依赖重写,
|
||||
# 少写规则就少一处"上线才发现 404"的地方。
|
||||
|
||||
@@ -21,6 +21,25 @@
|
||||
|
||||
# CalDAV:/dav/、/dav/3.ics/、/dav/3.ics/12.ics 全部交给 dav.php
|
||||
RewriteRule ^dav(/.*)?$ dav.php [L,QSA]
|
||||
|
||||
# CardDAV(v24 第 22 批):/carddav/、/carddav/1.vcf/、/carddav/1.vcf/12.vcf 交给 carddav.php
|
||||
# ⚠️ 两条规则**顺序无关但都必须有**:它们的前缀(dav vs carddav)互不包含,
|
||||
# 不会互相抢;写成 `^dav` 在前是因为历史顺序,真要调也不能写成 `^dav(/|$)` 之外的
|
||||
# 宽松形式 —— 那会把 /dav2/ 之类也吞进来。
|
||||
RewriteRule ^carddav(/.*)?$ carddav.php [L,QSA]
|
||||
</IfModule>
|
||||
|
||||
# ⚠️⚠️ DAV 的 Basic 鉴权**必须**靠这一行才能在 Apache 上工作:
|
||||
# Apache 默认**不把 `Authorization` 头交给 PHP-FPM** → `$_SERVER['PHP_AUTH_USER']`
|
||||
# 和 `HTTP_AUTHORIZATION` 都是 null → /dav/ 与 /carddav/ 无论口令填得多正确都 401。
|
||||
# 线上实测(Apache 2.4.53,直接监听 80/443):
|
||||
# 没有这行 : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
|
||||
# 加上这行 : {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
|
||||
# `CGIPassAuth` 是 Apache **2.4.13+ 的核心指令**,不需要额外模块。
|
||||
# (本地 `php -S` 会自己填好 PHP_AUTH_USER,所以 e2e 全绿而线上全 401 ——
|
||||
# 典型的"本地好好的、线上不行"。部署脚本也会把这一段追加到线上那份里。)
|
||||
<IfModule mod_authz_core.c>
|
||||
CGIPassAuth On
|
||||
</IfModule>
|
||||
|
||||
# 备份/临时文件永远不给出去(这条线上很要紧:*.php.bak 曾经可被直接下载)
|
||||
|
||||
+40
-16
@@ -38,27 +38,47 @@ final class Auth
|
||||
return $_SESSION['name'] ?? '';
|
||||
}
|
||||
|
||||
// ---------------- CalDAV 专用口令(v24 第 7 条后半) ----------------
|
||||
// ---------------- DAV 专用口令(v24 第 7 条后半 → 第 22 批升成通用) ----------------
|
||||
|
||||
/**
|
||||
* 这个人有没有配过 CalDAV 口令。
|
||||
* 这个人有没有配过 DAV 口令。
|
||||
*
|
||||
* ⚠️ 为什么不用登录口令:CalDAV 客户端(iOS 日历 / DAVx⁵ / Thunderbird)
|
||||
* ⚠️ 为什么不用登录口令:DAV 客户端(iOS 日历/通讯录、DAVx⁵、Thunderbird)
|
||||
* 会把这串凭据**长期存在设备上**并反复自动发送。让它是登录口令,
|
||||
* 等于把主密码复制到每一台设备、且改密码就要每台重配。
|
||||
* 所以另发一个只用于日历订阅的口令,随时可换、与登录互不影响。
|
||||
* 等于把主密码复制到每一台设备、且改密码就要每台重配;
|
||||
* 更糟的是手机上那个"账号"往往还有别的用途 —— 泄露面比日历本大得多。
|
||||
* 这正是用户第 22 批的原话:「避免在第三方客户端进行同步时自己的密码泄露」。
|
||||
*
|
||||
* ⚠️ **一把口令管两种同步**(用户 2026-10-06 第 22 批确认):
|
||||
* 日历(CalDAV)与通讯录(CardDAV)共用同一串 —— 手机上填一次就都通了。
|
||||
* 所以下面这几个方法一律叫 dav* 而不是 caldav*(表也从 caldav_credentials
|
||||
* 改名成了 dav_credentials,见 Schema 版本 23)。
|
||||
*/
|
||||
public static function hasCalDav(int $personId): bool
|
||||
public static function hasDav(int $personId): bool
|
||||
{
|
||||
$st = Database::get()->prepare('SELECT COUNT(*) FROM caldav_credentials WHERE person_id = ?');
|
||||
$st = Database::get()->prepare('SELECT COUNT(*) FROM dav_credentials WHERE person_id = ?');
|
||||
$st->execute([$personId]);
|
||||
return (int)$st->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
/** 这个人最后一次用 DAV 同步是什么时候(NULL = 从没用过;UI 上显示出来) */
|
||||
public static function davLastUsed(int $personId): ?string
|
||||
{
|
||||
$st = Database::get()->prepare(
|
||||
'SELECT last_used_at FROM dav_credentials WHERE person_id = ? LIMIT 1'
|
||||
);
|
||||
$st->execute([$personId]);
|
||||
$v = $st->fetchColumn();
|
||||
return ($v === false || $v === null || $v === '') ? null : (string)$v;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成(或重置)CalDAV 口令,**返回明文**(只在这一刻能看到,库里只有哈希)。
|
||||
* 生成(或重置)DAV 口令,**返回明文**(只在这一刻能看到,库里只有哈希)。
|
||||
*
|
||||
* ⚠️ 重置 = 换一串新的,**设备上的旧口令立刻失效** —— 这是"口令外泄了怎么办"
|
||||
* 的唯一出路,所以 UI 上按钮就写"重置"、并且要有一句确认。
|
||||
*/
|
||||
public static function caldavIssue(int $personId): string
|
||||
public static function davIssue(int $personId): string
|
||||
{
|
||||
// 去掉容易读错的字符(0/O、1/l/I),因为用户要手打进手机
|
||||
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||
@@ -67,15 +87,15 @@ final class Auth
|
||||
$plain .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
||||
}
|
||||
Database::get()->prepare(
|
||||
'INSERT INTO caldav_credentials (person_id, token_hash) VALUES (?,?)
|
||||
'INSERT INTO dav_credentials (person_id, token_hash) VALUES (?,?)
|
||||
ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), created_at = NOW()'
|
||||
)->execute([$personId, hash('sha256', $plain)]);
|
||||
return $plain;
|
||||
}
|
||||
|
||||
public static function caldavRevoke(int $personId): void
|
||||
public static function davRevoke(int $personId): void
|
||||
{
|
||||
Database::get()->prepare('DELETE FROM caldav_credentials WHERE person_id = ?')
|
||||
Database::get()->prepare('DELETE FROM dav_credentials WHERE person_id = ?')
|
||||
->execute([$personId]);
|
||||
}
|
||||
|
||||
@@ -84,8 +104,12 @@ final class Auth
|
||||
*
|
||||
* 用户名可以是手机号,也可以是姓名 —— 手机上打姓名比打 11 位数字省事得多。
|
||||
* 两者都走同一套节流(失败计数保存在会话里,与登录共用)。
|
||||
*
|
||||
* ⚠️ 这套鉴权**同时服务 CalDAV 与 CardDAV**(dav.php 与 carddav.php 都调它)
|
||||
* —— 这正是"一把口令管两种同步"的落点:任何一处改了规则,两处一起生效,
|
||||
* 不会出现"日历能连、通讯录连不上"的裂缝。
|
||||
*/
|
||||
public static function caldavAuth(?string $user, ?string $pass): ?array
|
||||
public static function davAuth(?string $user, ?string $pass): ?array
|
||||
{
|
||||
$user = trim((string)$user);
|
||||
$pass = (string)$pass;
|
||||
@@ -106,7 +130,7 @@ final class Auth
|
||||
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
|
||||
FROM accounts a
|
||||
JOIN persons p ON p.id = a.person_id
|
||||
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
|
||||
LEFT JOIN dav_credentials cd ON cd.person_id = p.id
|
||||
WHERE a.status = "active" AND p.phone_hash = ? LIMIT 1'
|
||||
);
|
||||
$st->execute([Crypto::hash($user)]);
|
||||
@@ -116,7 +140,7 @@ final class Auth
|
||||
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
|
||||
FROM accounts a
|
||||
JOIN persons p ON p.id = a.person_id
|
||||
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
|
||||
LEFT JOIN dav_credentials cd ON cd.person_id = p.id
|
||||
WHERE a.status = "active" AND p.name = ?
|
||||
ORDER BY p.id LIMIT 1'
|
||||
);
|
||||
@@ -132,7 +156,7 @@ final class Auth
|
||||
}
|
||||
|
||||
unset($_SESSION['cd_fail']);
|
||||
$db->prepare('UPDATE caldav_credentials SET last_used_at = NOW() WHERE person_id = ?')
|
||||
$db->prepare('UPDATE dav_credentials SET last_used_at = NOW() WHERE person_id = ?')
|
||||
->execute([(int)$row['person_id']]);
|
||||
return [
|
||||
'person_id' => (int)$row['person_id'],
|
||||
|
||||
+6
-1
@@ -18,7 +18,12 @@
|
||||
* GET /dav/<book>.ics/ → 整本导出成一个 ics(很多客户端走这条)
|
||||
*
|
||||
* 鉴权用 **HTTP Basic**(DAVx⁵ / iOS 都原生支持),口令不是登录口令:
|
||||
* 见 Auth::caldavToken() —— 登录口令绝不该在客户端里被反复存着。
|
||||
* 见 Auth::davAuth() —— 登录口令绝不该在客户端里被反复存着。
|
||||
*
|
||||
* ⚠️ 口令是**日历与通讯录共用**的一把(用户 2026-10-06 第 22 批):
|
||||
* 同一个用户名 + 同一串口令,既连 /dav/(本文件)也连 /carddav/(App\CardDav)。
|
||||
* 两个入口各自独立鉴权、互不依赖 —— 「日历能连、通讯录连不上」这种半通状态
|
||||
* 在协议层就不可能出现(同一套 davAuth、同一张 dav_credentials 表)。
|
||||
*
|
||||
* ⚠️ 只读:所有写方法(PUT/DELETE/PROPPATCH)一律 403。
|
||||
* 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
<?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;
|
||||
|
||||
final class CardDav
|
||||
{
|
||||
/** 资源前缀(URL 里的路径)。与 CalDav::ROOT 并列、互不包含 */
|
||||
public const ROOT = '/carddav/';
|
||||
|
||||
/** 默认那一本通讯录的 id。家庭场景只有一本,用固定 id 让 URL 稳定可记 */
|
||||
public const BOOK_ID = 1;
|
||||
|
||||
/**
|
||||
* 当前用户可见的"通讯录"。
|
||||
*
|
||||
* ⚠️ 家庭场景只有**一本**:你的名册。所以这里不查库、直接回一本固定的,
|
||||
* 是为了让 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);
|
||||
// 我叫他什么 → 当作"小名"写进 NICKNAME(手机上就能看到"老爸"而不是"杨父")
|
||||
$nick = Nickname::display($personId, $r);
|
||||
$r['nickname'] = ($nick !== '' && $nick !== (string)$r['name']) ? $nick : '';
|
||||
// 补充联系方式(多个电话/邮箱/地址/网址)
|
||||
$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), '家庭名册');
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张卡:**正文与 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
|
||||
{
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/xml; charset=utf-8');
|
||||
}
|
||||
|
||||
public static function x(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
}
|
||||
+66
-8
@@ -137,10 +137,16 @@ final class ContactImport
|
||||
break;
|
||||
case 'N': // 结构化姓名:姓;名;中间名;前缀;后缀 → 拼成"名 姓"更符合中文习惯
|
||||
if ($cur['name'] === '') {
|
||||
$parts = explode(';', $val);
|
||||
/* 🔴 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] ?? ''));
|
||||
$up2 = strtoupper(trim(explode(';', $head)[1] ?? ''));
|
||||
// ⚠️ 中文 vCard 里 N 的姓/名顺序也是"姓;名",但显示要"姓名"连写,
|
||||
// 不加空格 —— 加了会变成"杨 永全"。
|
||||
$joined = $fam . $giv;
|
||||
@@ -190,12 +196,24 @@ final class ContactImport
|
||||
所以这里不再拼成一整串 —— 直接拆成国家/省/市/街道四段存下来,
|
||||
再由 ContactImporter 拿它们去对标准行政区划名。
|
||||
⚠️ 区县(第 8 段)几乎没人填,剩下那段街道再交给 Region::parse()
|
||||
去认区 —— 它本来就是干这个的。 */
|
||||
$p = explode(';', $val);
|
||||
$street = trim((string)($p[2] ?? ''));
|
||||
$city = trim((string)($p[3] ?? ''));
|
||||
$prov = trim((string)($p[4] ?? ''));
|
||||
$ctry = trim((string)($p[6] ?? ''));
|
||||
去认区 —— 它本来就是干这个的。
|
||||
🔴 v24 第 22 批修的真 bug:分段必须走 `splitVcardSegments()`
|
||||
(**认识转义**),不能用 `explode(';', ...)`。
|
||||
详见那个函数的注释:PHP 的 explode 不认识转义,
|
||||
值里写了 `\;` 照样在那里拆开,地址就会被从中间切断
|
||||
(后半截含省名跑到别的段上)。
|
||||
⚠️ 也不要"先 unescape 再分段":那是同一个错的另一种写法
|
||||
(本轮第一版就这么改,往返测试照样红)。
|
||||
这是靠"导出→导入往返一致"那条断言才抓出来的 ——
|
||||
单看解析器自己、或用不含分号的样本测,永远是绿的。 */
|
||||
$segments = [];
|
||||
foreach (self::splitVcardSegments(substr($ln, $pos + 1)) as $seg) {
|
||||
$segments[] = self::unescapeVcard($seg);
|
||||
}
|
||||
$street = trim((string)($segments[2] ?? ''));
|
||||
$city = trim((string)($segments[3] ?? ''));
|
||||
$prov = trim((string)($segments[4] ?? ''));
|
||||
$ctry = trim((string)($segments[6] ?? ''));
|
||||
$cur['addr_country'] = $ctry;
|
||||
$cur['addr_province'] = $prov;
|
||||
$cur['addr_city'] = $city;
|
||||
@@ -325,6 +343,46 @@ final class ContactImport
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按**未被转义的**分号把值切成段(vCard 的 N / ADR 用分号分段)。
|
||||
*
|
||||
* 🔴 v24 第 22 批修的真 bug:原来直接用 `explode(';', $val)`,而 PHP 的 explode
|
||||
* **不认识转义** —— 值里写了 `\;`(字面分号)照样在它上面拆开。
|
||||
* 后果:地址「青岛市,市南区;某某路1号」被从中间切断,后半截(含省名)
|
||||
* 跑到别的段上;导进来的人地址只剩前半句,而且看着像"省没填"。
|
||||
* 症状极隐蔽 —— 手机通讯录里带分号的地址("XX路1号;3单元")不算罕见,
|
||||
* 而这类解析错误只在**恰好含分号的字段**上出现,量少了会被当成"个别数据问题"。
|
||||
*
|
||||
* ⚠️ 与「先 unescape 再分段」是两个不同的错,别只修一个:
|
||||
* 先 unescape → `\;` 变成真 `;`,再 explode 必然在那里断开(同一个后果)。
|
||||
* 正确做法是**这一步就不拆转义过的分号**,拆完再逐段 unescape。
|
||||
* (本轮第一版就是这么改的,往返测试照样红 —— 所以才有了这个函数。)
|
||||
* ⚠️ `\\` 要当成一个整体跳过两个字符,否则 `\\;`(转义反斜杠 + 真分段符)
|
||||
* 会被误当成"转义过的分号"而漏掉那个真正的分段点。
|
||||
*/
|
||||
private static function splitVcardSegments(string $v): array
|
||||
{
|
||||
$out = [];
|
||||
$cur = '';
|
||||
$len = strlen($v);
|
||||
for ($i = 0; $i < $len; $i++) {
|
||||
$ch = $v[$i];
|
||||
if ($ch === '\\' && $i + 1 < $len) { // 转义序列整体跳过(`\\` 与 `\;` 都算)
|
||||
$cur .= $ch . $v[$i + 1];
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
if ($ch === ';') { // 只有**未转义**的分号才是分段符
|
||||
$out[] = $cur;
|
||||
$cur = '';
|
||||
continue;
|
||||
}
|
||||
$cur .= $ch;
|
||||
}
|
||||
$out[] = $cur;
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** vCard 文本转义还原:\, \; \n \\ */
|
||||
private static function unescapeVcard(string $v): string
|
||||
{
|
||||
|
||||
+67
-6
@@ -82,6 +82,21 @@ final class Schema
|
||||
* 在**保存行程时**由 `App\Geo` 解析好存下来,读的时候直接去重。
|
||||
* 为什么不每次读时现算:解析要调高德接口(有配额、有网络),
|
||||
* 而城市是"写一次看很多次"的字段 —— 现算会让列表页变慢且可能超配额。
|
||||
* 23 → **DAV 同步(用户 2026-10-06 第 22 批)**:名册加 CardDAV,
|
||||
* 并把原来的 CalDAV 口令升成**一把通用 DAV 口令**(日历与通讯录共用)。
|
||||
* 用户原话:「名册,增加cardav同步功能。caldav和cardav,都增加一个创建口令的
|
||||
* 功能,这样,就使用用户名和口令来同步,避免在第三方客户端进行同步时
|
||||
* 自己的密码泄露。」
|
||||
* → `caldav_credentials` **改名为** `dav_credentials`(表结构一点没动,
|
||||
* 只换名字与注释)。⚠️ 为什么是改名而不是新建一张 carddav_credentials:
|
||||
* 用户的诉求是"用口令替代密码",**一把口令覆盖两种同步**最省事 ——
|
||||
* 手机上填一次,日历本和通讯录一起进来;改成两把会让用户在两个页面
|
||||
* 各配一遍、还容易只配一半(日历能用、通讯录一直说密码错)。
|
||||
* ⚠️ 迁移用 RENAME TABLE 且**带存在性守卫**,不能只在建表语句里改名字:
|
||||
* 老库里那张表已经存在,`CREATE TABLE IF NOT EXISTS dav_credentials`
|
||||
* 会**另建一张空表** —— 用户已配好的 CalDAV 口令看起来就"消失"了
|
||||
* (所有设备上的同步立刻 401)。而且必须先判旧表在不在:
|
||||
* 全新安装时没有旧表,直接 RENAME 会报错把整段升级打断。
|
||||
*/
|
||||
/**
|
||||
* 结构版本(**人读**用:日志、设置页上显示"库结构是哪一版")。
|
||||
@@ -89,7 +104,7 @@ final class Schema
|
||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
||||
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
|
||||
*/
|
||||
public const VERSION = '22';
|
||||
public const VERSION = '23';
|
||||
|
||||
/**
|
||||
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
|
||||
@@ -193,17 +208,17 @@ CREATE TABLE IF NOT EXISTS `accounts` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `caldav_credentials` (
|
||||
CREATE TABLE IF NOT EXISTS `dav_credentials` (
|
||||
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`person_id` INT UNSIGNED NOT NULL,
|
||||
`token_hash` CHAR(64) NOT NULL COMMENT 'CalDAV 专用口令的 sha256;口令本身不落库',
|
||||
`token_hash` CHAR(64) NOT NULL COMMENT 'DAV 专用口令的 sha256;口令本身不落库',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`last_used_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_cd_person` (`person_id`),
|
||||
CONSTRAINT `fk_cd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
UNIQUE KEY `uk_dc_person` (`person_id`),
|
||||
CONSTRAINT `fk_dc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='CalDAV 专用口令(与登录口令分开:客户端会把它长期存着,不能是登录口令)'
|
||||
COMMENT='DAV 专用口令(日历 CalDAV + 通讯录 CardDAV 共用一把;与登录口令分开)'
|
||||
SQL,
|
||||
<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS `relations` (
|
||||
@@ -1574,6 +1589,41 @@ CREATE TABLE IF NOT EXISTS `ledger_entry_snapshots` (
|
||||
COMMENT='账目金额的历史快照(盘点记录,只增不改)'
|
||||
SQL);
|
||||
|
||||
// ---- DAV 口令:CalDAV 专用 → 日历/通讯录共用的通用口令(版本 23)----
|
||||
// 🔴 用户 2026-10-06 第 22 批:「caldav和cardav,都增加一个创建口令的功能,
|
||||
// 这样,就使用用户名和口令来同步,避免在第三方客户端进行同步时
|
||||
// 自己的密码泄露。」→ 一把口令同时管日历与通讯录。
|
||||
//
|
||||
// ⚠️⚠️ 这一步**必须**是 RENAME 而不是"改建表语句里的名字":
|
||||
// 建表用的是 `CREATE TABLE IF NOT EXISTS dav_credentials` —— 老库里那张
|
||||
// 表还叫 caldav_credentials,于是它会**再建一张空的** dav_credentials,
|
||||
// 而真正装着口令的旧表被留在原地没人读 → 用户所有已配好的同步设备
|
||||
// 立刻全部 401(而且是"改了密码也修不好"的那种,因为新口令写进了新表、
|
||||
// 老设备用旧表的哈希去比 —— 其实两边都对不上)。
|
||||
// ⚠️ 守卫顺序也很要紧:
|
||||
// ① 旧表在 & 新表不在 → RENAME(正常的老库升级路径);
|
||||
// ② 两张都在 → 说明上一次升级跑到一半、或有人手工建过:**保留新表**,
|
||||
// 把旧表里的行补过去(INSERT IGNORE,person 唯一键天然去重),再删旧表。
|
||||
// 不这么做的话,那张旧表会永远留在库里当垃圾,而且下次升级又会走到这里。
|
||||
// ③ 只有新表 → 什么都不做(全新安装,或已经升过的库)。
|
||||
// ④ 两张都没有 → 也不做(紧随其后的 statements() 会建出新表)。
|
||||
self::tryStep('DAV 口令表改名(caldav_credentials → dav_credentials)',
|
||||
static function () use ($db) {
|
||||
$hasOld = self::hasTable($db, 'caldav_credentials');
|
||||
$hasNew = self::hasTable($db, 'dav_credentials');
|
||||
if ($hasOld && !$hasNew) {
|
||||
$db->exec('RENAME TABLE `caldav_credentials` TO `dav_credentials`');
|
||||
return;
|
||||
}
|
||||
if ($hasOld && $hasNew) {
|
||||
$db->exec('INSERT IGNORE INTO `dav_credentials`
|
||||
(person_id, token_hash, created_at, last_used_at)
|
||||
SELECT person_id, token_hash, created_at, last_used_at
|
||||
FROM `caldav_credentials`');
|
||||
$db->exec('DROP TABLE `caldav_credentials`');
|
||||
}
|
||||
});
|
||||
|
||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||
if (self::$stepErrors) {
|
||||
@@ -1954,6 +2004,17 @@ SQL);
|
||||
}
|
||||
}
|
||||
|
||||
/** 表在不在。(改名 / 迁移的前置判断用 —— 直接 RENAME 一张不存在的表会抛异常) */
|
||||
private static function hasTable(\PDO $db, string $table): bool
|
||||
{
|
||||
$st = $db->prepare(
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'
|
||||
);
|
||||
$st->execute([$table]);
|
||||
return (int)$st->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private static function hasColumn(\PDO $db, string $table, string $column): bool
|
||||
{
|
||||
$st = $db->prepare(
|
||||
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
/**
|
||||
* vCard 3.0 导出(RFC 2426)—— 名册 → 通讯录卡片。
|
||||
*
|
||||
* 用户原话(2026-10-06 第 22 批):「名册,增加cardav同步功能。」
|
||||
* 手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)拉到的就是这里的文本。
|
||||
*
|
||||
* ⚠️ 为什么导出 **3.0** 而不是 4.0:
|
||||
* iOS 与 DAVx⁵ 都推荐 3.0(4.0 在 iOS 上会被"降级兼容",个别字段干脆丢),
|
||||
* 而且 3.0 的 TEL/EMAIL 参数写法最简单 —— 少写兼容分支就少一处出错。
|
||||
* 真正要紧的两条 3.0 规矩:
|
||||
* · `FN` **必须**有(3.0 里 FN 是必需属性,没有它 iOS 会整张卡丢掉);
|
||||
* · `N` 也要有(缺了 iOS 通讯录会按"无名"排序,找不着人)。
|
||||
*
|
||||
* ⚠️ 与「导入」是同一份字段表(App\ContactImport::parseVcard 的反向):
|
||||
* 导进来的卡再导出去必须还是同一个人(往返不丢字段)。
|
||||
* 那边的解析分支就是这边的书写清单,改动时要**两边一起看**。
|
||||
*
|
||||
* ⚠️ 三件必须做对、否则客户端会静默丢数据的事:
|
||||
* ① **转义**:文本值里的 `\` `;` `,` 换行都要转(RFC 2426 3.4)——
|
||||
* "青岛市,市南区"这种地址不转义,客户端会把逗号当**分段符**,地址后半截跑到别的字段;
|
||||
* ② **折行**:一行最多 75 **字节**、续行以一个空格开头。中文一个字 3 字节,
|
||||
* 按字符数切会切出半个 UTF-8 字 → 客户端解出来是乱码。这里直接复用 Ics 的实现思路。
|
||||
* ③ **CSV 式的逗号列表**:N 与 ADR 是"用分号连接的分段",而每段内部还要再转义。
|
||||
* 顺序不能反(先转义后拼分号,否则真分段符也会被转义掉)。
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
final class Vcard
|
||||
{
|
||||
/**
|
||||
* 把一批"人"导出成一张 .vcf 文本(多张卡拼在一起,这是 CardDAV 资源的通行形态:
|
||||
* 一本通讯录 = 一个 vcf 文件,里面 N 张卡)。
|
||||
*
|
||||
* @param array<int,array> $people 每人一行(含 contacts 附件信息,见 rosterCards())
|
||||
*/
|
||||
public static function export(array $people, string $bookName = '家庭名册'): string
|
||||
{
|
||||
$out = [];
|
||||
foreach ($people as $p) {
|
||||
$out[] = self::card($p);
|
||||
}
|
||||
// ⚠️ CRLF 行尾是规范要求(RFC 2426 3.3 明确写了 lines 以 CRLF 分隔)。
|
||||
// 用 \n 的话,iOS 大多能忍,但 Outlook 那一路会整张卡读不出来。
|
||||
return implode("\r\n", $out) . "\r\n";
|
||||
}
|
||||
|
||||
/** 一张卡(一个 BEGIN:VCARD … END:VCARD 块),已折行、已转义 */
|
||||
public static function card(array $p): string
|
||||
{
|
||||
$name = trim((string)($p['name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$name = '(未命名)'; // FN 必须非空,否则 iOS 会整张丢掉
|
||||
}
|
||||
$lines = [];
|
||||
$lines[] = 'BEGIN:VCARD';
|
||||
$lines[] = 'VERSION:3.0';
|
||||
|
||||
// ---- 姓名 ----------------------------------------------------
|
||||
// ⚠️ UID 必须**稳定**:客户端靠它判断"这是新卡还是同一张卡改了"。
|
||||
// 用 `person-<id>@<域>`(不能用"姓名"——同名的人会互相覆盖,
|
||||
// 而且改名之后客户端会当成"删一张加一张")。
|
||||
$lines[] = 'UID:' . self::esc(self::uid($p));
|
||||
$lines[] = 'FN:' . self::esc($name);
|
||||
$lines[] = 'N:' . self::nField($name);
|
||||
if (!empty($p['nickname'])) {
|
||||
$lines[] = 'NICKNAME:' . self::esc((string)$p['nickname']);
|
||||
}
|
||||
|
||||
// ---- 电话 ----------------------------------------------------
|
||||
// ⚠️ 主手机号在 persons.phone_encrypted 里是**密文**(AES),
|
||||
// 调用方(CardDav::rows)已经解好放在 $p['phone'],这里只管写。
|
||||
// 为什么不在这一层解密:导出类是"文本生成器",不该碰密钥;
|
||||
// 而且一处解密、两处用(vcf 与页面)才不会出现"页面看得到、同步看不到"。
|
||||
$mainPhone = trim((string)($p['phone'] ?? ''));
|
||||
if ($mainPhone !== '') {
|
||||
$lines[] = 'TEL;TYPE=CELL:' . self::esc($mainPhone);
|
||||
}
|
||||
foreach (self::contactRows($p, 'phone') as $row) {
|
||||
$t = trim((string)($row['value'] ?? ''));
|
||||
if ($t === '' || $t === $mainPhone) {
|
||||
continue; // 与主手机号重复就不再写一遍(客户端会显示两条一样的)
|
||||
}
|
||||
$lines[] = self::telLine($t, (string)($row['label'] ?? ''));
|
||||
}
|
||||
|
||||
// ---- 邮箱 ----------------------------------------------------
|
||||
$mainMail = trim((string)($p['email'] ?? ''));
|
||||
if ($mainMail !== '') {
|
||||
$lines[] = 'EMAIL;TYPE=INTERNET:' . self::esc($mainMail);
|
||||
}
|
||||
foreach (self::contactRows($p, 'email') as $row) {
|
||||
$t = trim((string)($row['value'] ?? ''));
|
||||
if ($t === '' || $t === $mainMail) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = self::mailLine($t, (string)($row['label'] ?? ''));
|
||||
}
|
||||
|
||||
// ---- 网址 ----------------------------------------------------
|
||||
foreach (self::contactRows($p, 'url') as $row) {
|
||||
$t = trim((string)($row['value'] ?? ''));
|
||||
if ($t !== '') {
|
||||
$lines[] = 'URL:' . self::esc($t);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 地址 ----------------------------------------------------
|
||||
// ⚠️ 主地址有两个来源:persons.address(自由文本,可能是"XX小区3号楼"这种
|
||||
// 纯街道)与 person_contacts 里 kind=address 的行。两者都写会重复,
|
||||
// 所以:contacts 里没有 address 行时才写 persons.address。
|
||||
$addrRows = self::contactRows($p, 'address');
|
||||
foreach ($addrRows as $row) {
|
||||
$t = trim((string)($row['value'] ?? ''));
|
||||
if ($t !== '') {
|
||||
$lines[] = 'ADR;TYPE=HOME:' . self::adrField($t, (string)($p['province'] ?? ''));
|
||||
}
|
||||
}
|
||||
if (!$addrRows && trim((string)($p['address'] ?? '')) !== '') {
|
||||
$lines[] = 'ADR;TYPE=HOME:' . self::adrField((string)$p['address'], (string)($p['province'] ?? ''));
|
||||
}
|
||||
|
||||
// ---- 生日 ----------------------------------------------------
|
||||
// ⚠️ vCard 3.0 的 BDAY 允许 `YYYY-MM-DD`,但**不允许只有月日**(那要写 `--MM-DD`)。
|
||||
// 没年份的人(只知道月日)按 3.0 的写法给 `--MM-DD`;
|
||||
// 两者都没有就整条不写 —— 写个空 BDAY: 反而会让 iOS 认成一个"无效日期"。
|
||||
$bday = self::bday((string)($p['birthday'] ?? ''));
|
||||
if ($bday !== '') {
|
||||
$lines[] = 'BDAY:' . $bday;
|
||||
}
|
||||
|
||||
// ---- 单位 / 职务 --------------------------------------------
|
||||
if (trim((string)($p['org'] ?? '')) !== '') {
|
||||
$lines[] = 'ORG:' . self::esc((string)$p['org']);
|
||||
}
|
||||
if (trim((string)($p['title'] ?? '')) !== '') {
|
||||
$lines[] = 'TITLE:' . self::esc((string)$p['title']);
|
||||
}
|
||||
|
||||
// ---- 与我的关系 / 备注 --------------------------------------
|
||||
// ⚠️ 关系写进 CATEGORIES 而不是自己造一个 X- 字段:
|
||||
// iOS 通讯录会把 CATEGORIES 当成"群组/标签"显示,用户一眼能看出谁是家人。
|
||||
// 写 X-FAMILY-RELATION 那种私有字段的话,手机上什么都看不到
|
||||
// (导出了但等于没导出)。
|
||||
$rel = trim((string)($p['relation_word'] ?? ''));
|
||||
if ($rel !== '') {
|
||||
$lines[] = 'CATEGORIES:' . self::esc($rel);
|
||||
}
|
||||
if (trim((string)($p['note'] ?? '')) !== '') {
|
||||
$lines[] = 'NOTE:' . self::esc((string)$p['note']);
|
||||
}
|
||||
|
||||
// REV:客户端的"这条改过没有"判据。用 updated_at(拿不到就用现在)
|
||||
$lines[] = 'REV:' . self::stamp((string)($p['updated_at'] ?? ''));
|
||||
$lines[] = 'END:VCARD';
|
||||
|
||||
// 逐行折行(折行要在**转义之后**:先折再转义会把续行前导空格也转义掉)
|
||||
$flat = [];
|
||||
foreach ($lines as $ln) {
|
||||
foreach (self::fold($ln) as $piece) {
|
||||
$flat[] = $piece;
|
||||
}
|
||||
}
|
||||
return implode("\r\n", $flat);
|
||||
}
|
||||
|
||||
/** UID:稳定且跨系统唯一(改名、改电话都不该让它变) */
|
||||
public static function uid(array $p): string
|
||||
{
|
||||
$id = (int)($p['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
return 'person-' . $id . '@familybook';
|
||||
}
|
||||
// 兜底:没有 id 的行(理论上不会)用一个姓名哈希,至少内部一致
|
||||
return 'person-' . substr(sha1((string)($p['name'] ?? '')), 0, 12) . '@familybook';
|
||||
}
|
||||
|
||||
/** 从 contacts 数组里取某一类(kind)的行 */
|
||||
private static function contactRows(array $p, string $kind): array
|
||||
{
|
||||
$all = $p['contacts'] ?? [];
|
||||
if (!is_array($all)) {
|
||||
return [];
|
||||
}
|
||||
$rows = $all[$kind] ?? [];
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 电话行:尽量带上"手机/住宅/工作"。
|
||||
*
|
||||
* ⚠️ label 是我们自己的中文标注(「奶奶家」「工作」),vCard 3.0 的 TYPE
|
||||
* 只认一组固定词(CELL/HOME/WORK/VOICE/FAX…)。做法:
|
||||
* · label 能对上固定词(手机/座机/家/单位…)→ 写成 TYPE,
|
||||
* 这样手机上会显示成"手机"/"工作",是用户期待的样子;
|
||||
* · 对不上 → 不猜,原样放进 X-ABLabel(苹果认这个私有字段,
|
||||
* 会把它当自定义标签显示出来)。猜错成 HOME 比不写更糟:
|
||||
* 用户会看到"家"却其实是"奶奶家"。
|
||||
*/
|
||||
private static function telLine(string $phone, string $label): string
|
||||
{
|
||||
$map = [
|
||||
'手机' => 'CELL', '移动' => 'CELL', 'cell' => 'CELL', 'mobile' => 'CELL',
|
||||
'家' => 'HOME', '住宅' => 'HOME', 'home' => 'HOME',
|
||||
'工作' => 'WORK', '单位' => 'WORK', '公司' => 'WORK', 'work' => 'WORK',
|
||||
'传真' => 'FAX', 'fax' => 'FAX',
|
||||
];
|
||||
$l = trim($label);
|
||||
$type = $map[mb_strtolower($l, 'UTF-8')] ?? ($map[$l] ?? '');
|
||||
if ($type !== '') {
|
||||
return 'TEL;TYPE=' . $type . ':' . self::esc($phone);
|
||||
}
|
||||
if ($l !== '') {
|
||||
return 'TEL;TYPE=VOICE;X-ABLabel=' . self::escParam($l) . ':' . self::esc($phone);
|
||||
}
|
||||
return 'TEL:' . self::esc($phone);
|
||||
}
|
||||
|
||||
/** 邮箱行(与电话同样的思路:能对上固定词就用 TYPE,否则原样当标签) */
|
||||
private static function mailLine(string $mail, string $label): string
|
||||
{
|
||||
$map = ['家' => 'HOME', '住宅' => 'HOME', 'home' => 'HOME',
|
||||
'工作' => 'WORK', '单位' => 'WORK', '公司' => 'WORK', 'work' => 'WORK'];
|
||||
$l = trim($label);
|
||||
$type = $map[mb_strtolower($l, 'UTF-8')] ?? ($map[$l] ?? '');
|
||||
if ($type !== '') {
|
||||
return 'EMAIL;TYPE=INTERNET,' . $type . ':' . self::esc($mail);
|
||||
}
|
||||
if ($l !== '') {
|
||||
return 'EMAIL;TYPE=INTERNET;X-ABLabel=' . self::escParam($l) . ':' . self::esc($mail);
|
||||
}
|
||||
return 'EMAIL;TYPE=INTERNET:' . self::esc($mail);
|
||||
}
|
||||
|
||||
/**
|
||||
* N 字段(结构化姓名)。
|
||||
*
|
||||
* ⚠️ 中文名不能按西文拆:"杨永全" 拆成 姓=杨 / 名=永全 是**猜**,
|
||||
* 猜错(复姓"欧阳"拆成"欧"+"阳")比不拆更糟。
|
||||
* 所以:纯中文名字**整体放"姓"那一格、名留空** —— 显示出来仍是"杨永全",
|
||||
* 排序也按拼音走得通(iOS 用 FN 显示、用 N 排序)。
|
||||
* ⚠️ 空格分得开的西文名("John Smith")才按空格拆成 名 / 姓。
|
||||
*/
|
||||
private static function nField(string $name): string
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return ';;;;';
|
||||
}
|
||||
$hasCjk = (bool)preg_match('/[\x{4e00}-\x{9fff}]/u', $name);
|
||||
if ($hasCjk) {
|
||||
// 姓=整名,名=空。分号是分段符,末尾四个空段按 3.0 补齐。
|
||||
return self::esc($name) . ';;;;';
|
||||
}
|
||||
$parts = preg_split('/\s+/u', $name) ?: [];
|
||||
if (count($parts) >= 2) {
|
||||
$family = array_pop($parts); // 最后一段当姓("John Smith" → Smith)
|
||||
$given = implode(' ', $parts);
|
||||
return self::esc($family) . ';' . self::esc($given) . ';;;';
|
||||
}
|
||||
return self::esc($name) . ';;;;';
|
||||
}
|
||||
|
||||
/**
|
||||
* ADR 字段:PO盒;扩展;街道;城市;省;邮编;国家(3.0 共 7 段)。
|
||||
*
|
||||
* ⚠️ 我们**没有**可靠的城市/省/邮编拆分(persons.address 是自由文本)。
|
||||
* 所以整串塞进"街道"那一格,其余留空。
|
||||
* 为什么不去猜:把"青岛市市南区XX路"硬拆成市/区×邮编,猜错了手机上
|
||||
* 会显示成一张地址错乱的卡(而整串在街道格子里至少读起来是对的)。
|
||||
*/
|
||||
private static function adrField(string $addr, string $province): string
|
||||
{
|
||||
/* ADR 有 **7 段**(PO;扩展;街道;城市;省;邮编;国家),所以需要 **6 个分号**。
|
||||
要落位的是:段2=街道、段4=省、其余留空 →
|
||||
'' ; '' ; 街道 ; '' ; 省 ; '' ; ''
|
||||
即 `;;` + 街道 + `;;` + 省 + `;;`。
|
||||
🔴 这里第一版写成了 `;;` + 街道 + `;;;` + 省 + `;` —— 分号总数也是 6,
|
||||
但**多出来的那一个挤在前面**,于是省落到了"邮编"那一段
|
||||
(解析器读段4 拿到空串 → 省看起来"没填")。
|
||||
症状非常隐蔽:段数对、总数对、只有**位置**错一位。
|
||||
这是靠"导出→导入往返一致"那条断言抓出来的(见 _fb_b22_vcf_spec.php 第 6 节):
|
||||
只数分号个数的检查发现不了它。 */
|
||||
return ';;' . self::esc($addr) . ';;' . self::esc(trim($province)) . ';;';
|
||||
}
|
||||
|
||||
/**
|
||||
* BDAY:优先真实阳历生日(有年份),否则只给月日(3.0 的 `--MM-DD`)。
|
||||
*
|
||||
* ⚠️ 为什么不能直接把 "2026-03-05" 写进 BDAY 就完事:
|
||||
* 本项目的 birthday 是 **DATETIME**(精确到时分秒)。写全会被某些客户端
|
||||
* 当成"生日有具体时刻",iOS 会显示成 00:00 —— 无害但多余,所以只取日期段。
|
||||
*/
|
||||
private static function bday(string $birthday): string
|
||||
{
|
||||
$birthday = trim($birthday);
|
||||
// ⚠️ 本项目的 birthday 是 **DATETIME**(精确到时分秒),所以只取日期段:
|
||||
// 写全会被客户端当成"生日有具体时刻",iOS 上显示成 00:00(无害但多余)。
|
||||
if ($birthday !== '' && preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $birthday, $m)) {
|
||||
return $m[1] . '-' . $m[2] . '-' . $m[3];
|
||||
}
|
||||
// 只有月日(`--MM-DD`)是 3.0 的合法写法,但**本项目不产出**:
|
||||
// 库里存的阳历生日一定有年份(没有年份是"农历"那一路的事,
|
||||
// 而 vCard 没有农历字段 —— 硬塞会让手机上多一句看不懂的话,见 CardDav::rows)。
|
||||
// 这里留着分支只为"万一将来某条数据真的只有月日"时不至于写出非法 BDAY。
|
||||
if (preg_match('/^(\d{2})-(\d{2})$/', $birthday, $m)) {
|
||||
return '--' . $m[1] . '-' . $m[2];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** REV 用的时间戳(UTC,3.0 要求 ISO8601 基本型) */
|
||||
private static function stamp(string $dt): string
|
||||
{
|
||||
$dt = trim($dt);
|
||||
$t = $dt === '' ? time() : (strtotime($dt) ?: time());
|
||||
return gmdate('Ymd\THis\Z', $t);
|
||||
}
|
||||
|
||||
/** 文本值转义(RFC 2426 3.4):`\` `;` `,` 与换行 */
|
||||
public static function esc(string $v): string
|
||||
{
|
||||
$v = str_replace('\\', '\\\\', $v);
|
||||
$v = str_replace(';', '\;', $v);
|
||||
$v = str_replace(',', '\,', $v);
|
||||
return str_replace(["\r\n", "\r", "\n"], '\n', $v);
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数值转义(RFC 2426:参数里的 `,` `;` `:` 要加反斜杠或用引号)。
|
||||
* 用双引号包起来最简单,也不会踩"中文标点"的坑。
|
||||
*/
|
||||
private static function escParam(string $v): string
|
||||
{
|
||||
return '"' . str_replace(['"', '\\'], ['', '\\\\'], $v) . '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* 折行:一行最多 75 **字节**,续行以一个空格开头,且不切在多字节字符中间。
|
||||
*
|
||||
* ⚠️ 与 App\Ics::fold 是同一套算法。**刻意复制而不是共用**:
|
||||
* Ics 的 fold 是 private、而且它按"iCalendar 不折参数行"的经验调过;
|
||||
* 把两个协议的折行强行抽成一个公共函数,将来任一方按自己的规范微调
|
||||
* 都会波及另一边(vCard 与 iCalendar 的折行规则虽然像,但不是同一段规范)。
|
||||
* 复制一份二十行,换两边互不牵连。
|
||||
*/
|
||||
private static function fold(string $line): array
|
||||
{
|
||||
if (strlen($line) <= 75) {
|
||||
return [$line];
|
||||
}
|
||||
$out = [];
|
||||
$cur = '';
|
||||
$len = 0;
|
||||
$bytes = strlen($line);
|
||||
$i = 0;
|
||||
while ($i < $bytes) {
|
||||
$o = ord($line[$i]);
|
||||
$n = 1;
|
||||
if ($o >= 0xF0) { $n = 4; }
|
||||
elseif ($o >= 0xE0) { $n = 3; }
|
||||
elseif ($o >= 0xC0) { $n = 2; }
|
||||
$tok = substr($line, $i, $n);
|
||||
$i += $n;
|
||||
$room = $out === [] ? 75 : 74; // 续行要多一个前导空格
|
||||
if ($len + $n > $room) {
|
||||
$out[] = $cur;
|
||||
$cur = '';
|
||||
$len = 0;
|
||||
}
|
||||
$cur .= $tok;
|
||||
$len += $n;
|
||||
}
|
||||
if ($cur !== '') {
|
||||
$out[] = $cur;
|
||||
}
|
||||
// 🔴 续行**必须**补前导空格(RFC 2426 3.2:换行后以一个空格开头表示"这是上一行的延续")。
|
||||
// ⚠️ 漏掉这一步的后果极隐蔽:折行切出来的那几段会变成**独立的属性行**,
|
||||
// 客户端读到的就是半个名字 / 半个地址("青岛市\,市南区\;某某路1号 这是一个特别特"
|
||||
// 后面那两段被当成垃圾属性丢掉),而且服务端看不出任何异常 ——
|
||||
// 本地校验"每行 ≤75 字节""折行确实发生了"全是绿的。
|
||||
// 这是本轮唯一一处靠"折行能否无损还原"这条断言才抓出来的 bug
|
||||
// (见 _fb_b22_vcf.py 的"折行可无损还原"),所以那条断言必须留着。
|
||||
$res = [];
|
||||
foreach ($out as $k => $v) {
|
||||
$res[] = $k === 0 ? $v : ' ' . $v;
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
@@ -3900,3 +3900,21 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b
|
||||
============================================================ */
|
||||
.cal-modal:not([hidden]) { display: flex; }
|
||||
.cal-modal[hidden] { display: none !important; }
|
||||
|
||||
/* ============================================================
|
||||
v24 第 22 批:CardDAV 卡的窄屏收口
|
||||
------------------------------------------------------------
|
||||
⚠️ 为什么必须补这一段:`.node-badge` 是 `white-space: nowrap`(徽标本来就不该折行,
|
||||
全站都在用),而这一卡的按钮行里放了一句说明性徽标 + 一个长地址输入框 ——
|
||||
在 360/375/390px 上直接把整页**撑出横向滚动**
|
||||
(真机几何实测:scrollW 412 > clientW 360,越界元素 = 这句徽标)。
|
||||
手机上"整页能左右拖"是最刺眼的一类问题,所以:
|
||||
· 窄屏让 `.btn-row` 能换行(否则按钮 + 徽标永远挤在一行);
|
||||
· 徽标允许折行(它在这张卡里是**说明**,不是标签)。
|
||||
只作用于这张卡(`.card[id="carddav"]`),不动全站的 `.node-badge` ——
|
||||
改全站会把所有徽标都变成可折行,那些地方正是要靠 nowrap 保持一行。
|
||||
============================================================ */
|
||||
@media (max-width: 480px) {
|
||||
#carddav .btn-row { flex-wrap: wrap; }
|
||||
#carddav .node-badge { white-space: normal; }
|
||||
}
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
<?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, '不支持这个请求方法。');
|
||||
@@ -7,7 +7,9 @@
|
||||
*
|
||||
* 地址:https://<站点>/dav/
|
||||
* 账号:手机号或姓名(同登录)
|
||||
* 口令:**CalDAV 专用口令**(在「日程 → 日历本」页生成,不是登录口令)
|
||||
* 口令:**DAV 专用口令**(在「个人设置 → DAV 同步」页生成,不是登录口令)
|
||||
* ⚠️ 这把口令**同时**给 CalDAV 与 CardDAV 用(用户 2026-10-06 第 22 批),
|
||||
* 所以手机上填一次就够,不必在日历与通讯录里各配一遍。
|
||||
*
|
||||
* ⚠️ 为什么单独一个文件而不是塞进 index.php:
|
||||
* CalDAV 用的是 HTTP 方法名(PROPFIND / REPORT / OPTIONS / PUT),
|
||||
@@ -47,10 +49,10 @@ if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
}
|
||||
}
|
||||
}
|
||||
$who = Auth::caldavAuth($user, $pass);
|
||||
$who = Auth::davAuth($user, $pass);
|
||||
if (!$who) {
|
||||
header('WWW-Authenticate: Basic realm="FamilyBook CalDAV"');
|
||||
dav_fail(401, '需要 CalDAV 口令(在「日程 → 日历本」页生成,不是登录密码)。');
|
||||
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
|
||||
dav_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。');
|
||||
}
|
||||
$familyId = (int)$who['family_id'];
|
||||
$me = (int)$who['person_id'];
|
||||
|
||||
@@ -267,7 +267,20 @@ function render_me_settings(array $person, array $errors = []): void
|
||||
'weatherLat' => (string)UserSetting::get($me, 'weather_lat', ''),
|
||||
'weatherLon' => (string)UserSetting::get($me, 'weather_lon', ''),
|
||||
'weatherAt' => Home::weatherLocation($me, $familyId),
|
||||
// v24 第 22 批:DAV 通用口令(日历 CalDAV + 通讯录 CardDAV 共用一把)。
|
||||
// ⚠️ 明文只从**一次性会话槽位**取、取完立刻 unset —— 用户刷新页面就看不到了,
|
||||
// 这是刻意的:口令只该在那一次被复制走(页面上也这么写)。
|
||||
'davHas' => Auth::hasDav($me),
|
||||
'davPlain' => (string)($_SESSION['dav_plain'] ?? ''),
|
||||
'davLastUsed' => Auth::davLastUsed($me),
|
||||
'davBase' => rtrim((string)fb_config('base_url', ''), '/'),
|
||||
// 通讯录同步范围要和名册页**完全一致**(用户 2026-10-06 第 22 批确认):
|
||||
// 名册页看到谁就同步谁。这里把人数带过去,用户当场就能对上。
|
||||
'davRosterN' => PersonRoster::countFor($familyId, $me),
|
||||
], 'settings');
|
||||
// ⚠️ 取完即清:明文口令只该被看到一次(刷新页面就没了)。
|
||||
// 放在 View::render **之后** —— 早了就拿不到值了。
|
||||
unset($_SESSION['dav_plain']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -889,6 +902,10 @@ switch ($r) {
|
||||
// 家里已经有人、但还不在我名册里 → 给一个「直接挑」的入口,
|
||||
// 免得为了同一个人又录一遍(录两遍会变成两条记录,账号与亲属关系各存一份)
|
||||
'pickable' => $q === '' ? count(PersonRoster::pickable($familyId, $me)) : 0,
|
||||
// v24 第 22 批:CardDAV 那张卡要知道"口令配了没有"(决定显示"去生成"还是"去管理")。
|
||||
// ⚠️ 这里**不传明文** —— 明文只走一次性会话槽位、只在「个人设置」那页显示一次;
|
||||
// 名册页能反复看到明文就等于口令多了一个泄露面。
|
||||
'davHas' => \App\Auth::hasDav($me),
|
||||
], 'people');
|
||||
break;
|
||||
|
||||
@@ -3876,25 +3893,49 @@ switch ($r) {
|
||||
break;
|
||||
|
||||
/**
|
||||
* CalDAV 专用口令(v24 第 7 条后半):生成 / 重置 / 撤销。
|
||||
* 旧地址 → 新地址(v24 第 22 批)。
|
||||
*
|
||||
* 口令从「CalDAV 专用」升成「DAV 通用」(日历 + 通讯录共用一把,见
|
||||
* Schema 版本 23 与 Auth::davIssue),管理入口也一并搬到**个人设置**页
|
||||
* —— 它本来就是"我的凭据",跟着日历本页走会让只想要通讯录的人找不着。
|
||||
*
|
||||
* ⚠️ 这两条**必须留着**:老书签、以及此前在页面里渲染过的表单地址都在这里,
|
||||
* 直接删掉的话点旧按钮会 404,用户看到的现象是"改了之后按钮没反应了"。
|
||||
* 用 302 跳过去,两边都活着。
|
||||
*/
|
||||
case 'calendar/caldav_token':
|
||||
case 'calendar/caldav_revoke':
|
||||
// ⚠️ 用第三个参数(anchor)而不是塞进 query:`redirect` 的签名是
|
||||
// (route, query, anchor),把 '#dav' 当 query 传会变成 `?%23dav=`。
|
||||
redirect('settings/me', [], 'dav');
|
||||
break;
|
||||
|
||||
/**
|
||||
* DAV 通用口令(v24 第 22 批):生成 / 重置 / 撤销。
|
||||
*
|
||||
* 用户原话:「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令
|
||||
* 来同步,避免在第三方客户端进行同步时自己的密码泄露。」
|
||||
*
|
||||
* ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用),
|
||||
* 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露),
|
||||
* 要么用户永远看不到自己刚设的花口令。
|
||||
* ⚠️ 生成与撤销共用 `settings/dav_plain` 这个槽位:撤销时**必须把它清掉**,
|
||||
* 否则"刚撤销完还显示着上一串口令",用户会以为撤销没生效。
|
||||
*/
|
||||
case 'calendar/caldav_token':
|
||||
case 'calendar/caldav_revoke':
|
||||
case 'settings/dav_token':
|
||||
case 'settings/dav_revoke':
|
||||
$me = (int)Auth::personId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
if ($r === 'calendar/caldav_token') {
|
||||
$_SESSION['caldav_plain'] = Auth::caldavIssue($me);
|
||||
flash('ok', 'CalDAV 口令已生成(旧口令立即失效,请在客户端里更新)。');
|
||||
if ($r === 'settings/dav_token') {
|
||||
$_SESSION['dav_plain'] = Auth::davIssue($me);
|
||||
flash('ok', 'DAV 口令已生成(日历与通讯录共用这一把;旧口令立即失效,请在客户端里更新)。');
|
||||
} else {
|
||||
Auth::caldavRevoke($me);
|
||||
flash('ok', 'CalDAV 口令已撤销,所有已订阅的客户端会失去访问。');
|
||||
Auth::davRevoke($me);
|
||||
unset($_SESSION['dav_plain']);
|
||||
flash('ok', 'DAV 口令已撤销,所有已同步的客户端(日历与通讯录)都会失去访问。');
|
||||
}
|
||||
redirect('calendar/calendars');
|
||||
redirect('settings/me');
|
||||
break;
|
||||
|
||||
case 'calendar/calendars':
|
||||
@@ -3914,10 +3955,12 @@ switch ($r) {
|
||||
foreach ($mine as $c) {
|
||||
$shares[(int)$c['id']] = Calendar::shares((int)$c['id']);
|
||||
}
|
||||
// v24 第 7 条后半:CalDAV 专用口令的状态(明文只在刚生成那一次给用户看)
|
||||
$cdHas = Auth::hasCalDav($me);
|
||||
$cdPlain = (string)($_SESSION['caldav_plain'] ?? '');
|
||||
unset($_SESSION['caldav_plain']);
|
||||
// v24 第 22 批:口令改成**通用 DAV 口令**(日历 + 通讯录共用),
|
||||
// 管理入口也搬到了「个人设置 → DAV 同步」。所以这一页**不再处理口令表单**
|
||||
// —— 只显示它的状态与入口(点过去配置)。口令的明文仍走一次性会话槽位,
|
||||
// 但那一页在 settings/me,这一页读不到(也没必要读:这里只提示"去那边配")。
|
||||
$cdHas = Auth::hasDav($me);
|
||||
$cdPlain = '';
|
||||
$editing = null;
|
||||
$editId = (int)($_GET['edit'] ?? 0);
|
||||
if ($editId > 0) {
|
||||
|
||||
+10
@@ -7,6 +7,7 @@
|
||||
* · 本地 `php -S` **不读 .htaccess**,所以同一个地址会被当成"文件不存在"直接 404,
|
||||
* 于是「CalDAV 在本地测不了、只能上线验证」——这正是最容易上线才发现问题的写法。
|
||||
* 这个文件把那几条重写规则用 PHP 重述一遍,本地与线上一模一样的路径行为。
|
||||
* (v24 第 22 批加了 CardDAV,同样两条:/dav/ 与 /carddav/。)
|
||||
*
|
||||
* ⚠️ 里面**不写任何业务**:只决定"交给哪个脚本",所有业务仍在 index.php / dav.php / media.php。
|
||||
* 线上部署不该带它(Apache 走 .htaccess),但带着也无害 —— 它不会被执行。
|
||||
@@ -20,6 +21,15 @@ if (preg_match('#^/dav(/.*)?$#', $path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CardDAV:/carddav/ 及其子路径(v24 第 22 批)
|
||||
// ⚠️ 这条必须在下面那条"真实文件"判断**之前**:carddav.php 本身是个真文件,
|
||||
// 但我们要的是把 /carddav/1.vcf/ 这种**不存在的路径**交给它处理;
|
||||
// 顺序反了的话 /carddav/ 会去要一个不存在的目录 → 404。
|
||||
if (preg_match('#^/carddav(/.*)?$#', $path)) {
|
||||
require __DIR__ . '/carddav.php';
|
||||
return true;
|
||||
}
|
||||
|
||||
// 真实存在的文件(静态资源、media.php 等)→ 交给内置服务器自己处理
|
||||
$file = __DIR__ . $path;
|
||||
if ($path !== '/' && is_file($file)) {
|
||||
|
||||
@@ -107,6 +107,10 @@ $backView = $curView;
|
||||
<div class="cal-set-links">
|
||||
<a href="<?= e(url('calendar/calendars')) ?>"<?= $setOn('books') ?>><?= $setIcon('books') ?><span>日历本 · 颜色 / 共享 / 导入</span></a>
|
||||
<a href="<?= e(url('calendar/calendars')) ?>#caldav"><?= $setIcon('dav') ?><span>CalDAV 订阅(在手机日历里看)</span></a>
|
||||
<?php /* v24 第 22 批:口令改成通用 DAV 口令(日历 + 通讯录共用)。这条不再直接跳
|
||||
日历本页那个卡片,而是跳到**口令管理**页 —— 用户从这里点进来十有八九
|
||||
是"要配同步",那就该落到能生成口令的地方(日历本页现在只有地址)。 */ ?>
|
||||
<a href="<?= e(url('settings/me', [], 'dav')) ?>"><?= $setIcon('dav') ?><span>DAV 口令(日历 + 通讯录)</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -199,17 +199,21 @@ $canWrite = \App\Auth::canWrite();
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php /* v24 第 7 条后半:CalDAV —— 把我的日历本订阅到手机/别的日历程序里 */ ?>
|
||||
<div class="card card-pad">
|
||||
<?php /* v24 第 7 条后半:CalDAV —— 把我的日历本订阅到手机/别的日历程序里。
|
||||
v24 第 22 批:口令改成**通用 DAV 口令**(日历 + 通讯录共用一把),
|
||||
管理入口也搬到了「个人设置 → DAV 同步」—— 这里只留地址与一个指路按钮。
|
||||
⚠️ 这一页**不再有**生成/撤销表单:同一个口令在两处各放一套按钮,
|
||||
迟早出现"在这里重置了、那页还显示已配置"的对不上(而且密码类的东西
|
||||
有两个入口本身就是坏味道)。 */ ?>
|
||||
<div class="card card-pad" id="caldav">
|
||||
<h2 class="cinema-h2">
|
||||
CalDAV 订阅
|
||||
<span class="hint">在手机日历 / DAVx⁵ / Thunderbird 里订阅你的日历本</span>
|
||||
</h2>
|
||||
<p class="small muted" style="margin:0 0 12px">
|
||||
填下面这个地址 + 你的<b>姓名或手机号</b> + <b>CalDAV 口令</b>,就能把「我的日历本」
|
||||
填下面这个地址 + 你的<b>姓名或手机号</b> + <b>DAV 口令</b>,就能把「我的日历本」
|
||||
同步到别的日历程序里。这是<b>只读</b>订阅的 —— 在手机上改日程请回到本站来改。
|
||||
<br>口令与登录密码是<b>分开的</b>:订阅凭据会被客户端长期存着,所以另发一个,
|
||||
随时可换、不影响登录。
|
||||
<br>口令与登录密码是<b>分开的</b>,而且<b>通讯录同步也用同一把</b>(不用配两遍)。
|
||||
</p>
|
||||
<div class="field">
|
||||
<label>服务器地址</label>
|
||||
@@ -217,32 +221,12 @@ $canWrite = \App\Auth::canWrite();
|
||||
value="<?= e(rtrim((string)fb_config('base_url', ''), '/') . '/dav/') ?>">
|
||||
<span class="tip">在日历客户端里填这一条即可,会列出你所有的日历本。</span>
|
||||
</div>
|
||||
<?php if (!empty($cdPlain)): ?>
|
||||
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
|
||||
<b>这是你的新口令,只显示这一次,请立刻复制走:</b>
|
||||
<br><code class="mono" style="font-size:15px"><?= e($cdPlain) ?></code>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if ($canWrite): ?>
|
||||
<div class="btn-row">
|
||||
<form method="post" action="<?= e(url('calendar/caldav_token')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-sm" type="submit">
|
||||
<?= !empty($cdHas) ? '重置 CalDAV 口令' : '生成 CalDAV 口令' ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php if (!empty($cdHas)): ?>
|
||||
<form method="post" action="<?= e(url('calendar/caldav_revoke')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-ghost btn-sm" type="submit"
|
||||
onclick="return confirm('撤销后所有已订阅的客户端都会失去访问。')">撤销口令</button>
|
||||
</form>
|
||||
<span class="node-badge">口令已配置</span>
|
||||
<?php else: ?>
|
||||
<span class="node-badge">还没配置口令</span>
|
||||
<?php endif; ?>
|
||||
<a class="btn btn-sm" href="<?= e(url('settings/me', [], 'dav')) ?>">
|
||||
<?= !empty($cdHas) ? 'DAV 口令已配置(去管理 / 重置)' : '去生成 DAV 口令' ?>
|
||||
</a>
|
||||
<span class="node-badge">口令在「个人设置 → DAV 同步」里管理</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($shared): ?>
|
||||
|
||||
@@ -326,6 +326,46 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== CardDAV 同步(v24 第 22 批)====================
|
||||
用户原话:「名册,增加cardav同步功能。caldav和cardav,都增加一个创建口令的功能,
|
||||
这样,就使用用户名和口令来同步,避免在第三方客户端进行同步时自己的密码泄露。」
|
||||
|
||||
⚠️ 口令在**个人设置**里生成(一把通用 DAV 口令,日历与通讯录共用),
|
||||
这里只给地址 + 一个指路按钮 —— 密码类的东西只该有一个管理入口,
|
||||
两处各放一套按钮迟早对不上(这页重置了、那页还显示"已配置")。
|
||||
⚠️ 同步范围 = **这一页看到的人**(同一个 Person::roster 口径):
|
||||
下面那句里的人数就是名册页的人数,用户当场能对上,不必猜"到底同步了谁"。 */ ?>
|
||||
<div class="card card-pad" style="margin-top:20px" id="carddav">
|
||||
<h2 class="section-title">
|
||||
通讯录同步(CardDAV)
|
||||
<span class="hint">把这 <?= (int)$total ?> 个人放进手机通讯录</span>
|
||||
</h2>
|
||||
<p class="small muted" style="margin:0 0 12px">
|
||||
在 iOS「通讯录」、安卓 <b>DAVx⁵</b>、Thunderbird、群晖 Contacts 里填下面这个地址 +
|
||||
<b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把<b>这份名册</b>同步过去。
|
||||
<br>同步的就是你在这一页看到的这些人(<b><?= (int)$total ?></b> 个)——
|
||||
谁加进你的名册、谁就会出现在手机通讯录里。
|
||||
<br>目前是<b>只读</b>同步:在手机上改的联系人<b>不会</b>写回家书,要改请回到本站来改。
|
||||
</p>
|
||||
<div class="field">
|
||||
<label>通讯录地址</label>
|
||||
<input class="input mono" readonly value="<?= e(rtrim((string)fb_config('base_url', ''), '/') . '/carddav/') ?>">
|
||||
<span class="tip">填这一条即可;点进去之后 iOS/DAVx⁵ 会自动发现这本通讯录。</span>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<a class="btn btn-sm" href="<?= e(url('settings/me', [], 'dav')) ?>">
|
||||
<?= !empty($davHas) ? 'DAV 口令已配置(去管理 / 重置)' : '去生成 DAV 口令' ?>
|
||||
</a>
|
||||
<?php /* ⚠️ 这句徽标原本写得很长("口令在「个人设置 → DAV 同步」里管理(日历也用同一把)"),
|
||||
而 `.node-badge` 是 `white-space: nowrap`、`.btn-row` 不换行 ——
|
||||
在 360/375/390px 上它把整页**撑出横向滚动**(真机几何实测:
|
||||
scrollW 412 > clientW 360,越界元素就是它)。
|
||||
页面变宽在手机上是最刺眼的问题(整页可以左右拖),
|
||||
所以文案压到八个字以内、并允许在窄屏折行。 */ ?>
|
||||
<span class="node-badge">日历与通讯录共用一把</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php /* ==================== 卡片动作的**共享浮层**(v24 第 10 批 #6)====================
|
||||
这里只有**一份**「分组」表单和**一份**「移出名册」表单,全页共用。
|
||||
每一行的按钮只带数据(data-pg-open / data-pm-open),点开时由 JS 把数据灌进来。
|
||||
|
||||
@@ -43,6 +43,96 @@ $rosterCount = $rosterCount ?? 0;
|
||||
② 「我怎么称呼家人」卡片(require _nicknames.php)
|
||||
下面直接接天气地点。改登录密码在右上角头像下拉里也有入口。 */ ?>
|
||||
|
||||
<!-- ==================== DAV 同步(v24 第 22 批)==================== -->
|
||||
<?php /* 用户原话:「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令
|
||||
来同步,避免在第三方客户端进行同步时自己的密码泄露。」
|
||||
|
||||
为什么这张卡在**个人设置**而不是「日程 → 日历本」:
|
||||
它管的是"我的凭据",同时服务**日历(CalDAV)与通讯录(CardDAV)**两件事 ——
|
||||
放在日历页会让只想同步通讯录的人在日程里找半天。
|
||||
|
||||
⚠️ 明文口令**只显示一次**(走一次性会话槽位,见 render_me_settings 末尾的 unset):
|
||||
设备上要填的是这串,页面上能反复看到等于把它变成"又一个可被偷看的密码"。
|
||||
⚠️ 口令与登录密码**必须**分开:第三方客户端会把这串长期存在设备上并反复自动发送,
|
||||
用登录密码就等于把主密码复制到每一台设备(这正是用户要避开的事)。 */ ?>
|
||||
<div class="card card-pad" id="dav" style="margin-top:20px">
|
||||
<h2 class="section-title">
|
||||
DAV 同步
|
||||
<span class="hint">日历本 + 通讯录,一个口令在手机 / 电脑上同步</span>
|
||||
</h2>
|
||||
<p class="small muted" style="margin:0 0 14px">
|
||||
在 iOS「日历/通讯录」、安卓 <b>DAVx⁵</b>、Thunderbird、群晖日历里,
|
||||
填下面这两个地址 + <b>你的姓名或手机号</b> + <b>DAV 口令</b>,就能把
|
||||
<b>我的日历本</b>和<b>我的名册</b>同步过去。
|
||||
<br><b>口令和登录密码是分开的</b> —— 第三方客户端会把凭据长期存在设备上并反复自动发送,
|
||||
用登录密码等于把主密码复制到每一台设备;所以这里单独发一个,随时可换、不影响登录。
|
||||
<br>目前是<b>只读</b>同步:在手机上改日程 / 加联系人<b>不会</b>写回家书,
|
||||
要改请回到本站来改。
|
||||
</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="field full">
|
||||
<label>日历(CalDAV)地址</label>
|
||||
<input class="input mono" readonly
|
||||
value="<?= e((string)$davBase . '/dav/') ?>">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label>通讯录(CardDAV)地址</label>
|
||||
<input class="input mono" readonly
|
||||
value="<?= e((string)$davBase . '/carddav/') ?>">
|
||||
<span class="tip">
|
||||
同步的是「我的名册」里那 <b><?= (int)$davRosterN ?></b> 个人 —— 和你名册页上看到的一致。
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>用户名</label>
|
||||
<input class="input mono" readonly value="<?= e((string)$person['name']) ?>">
|
||||
<span class="tip">也可以填手机号,两个都认。</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>口令</label>
|
||||
<?php /* ⚠️ 一行只能有一个 value:库里有的是 sha256,**取不回明文**,
|
||||
所以已经配过就显示一排圆点(表示"有,但不给你看"),
|
||||
没配过就写一句说明。真正的明文只在刚生成那一次显示(上面那块黄条)。 */ ?>
|
||||
<input class="input mono" readonly
|
||||
value="<?= !empty($davHas) ? '••••••••••••••••••••••••' : '还没生成,点下面的按钮' ?>">
|
||||
<span class="tip">生成后<b>只显示一次</b>,请先复制到设备里再关页面。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($davPlain)): ?>
|
||||
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:10px 12px;border-radius:8px">
|
||||
<b>这是你的新口令,只显示这一次,请立刻复制到设备的「密码」栏里:</b>
|
||||
<br><code class="mono" style="font-size:15px"><?= e((string)$davPlain) ?></code>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (\App\Auth::canWrite()): ?>
|
||||
<div class="btn-row">
|
||||
<form method="post" action="<?= e(url('settings/dav_token')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-sm" type="submit"><?= !empty($davHas) ? '重置 DAV 口令' : '生成 DAV 口令' ?></button>
|
||||
</form>
|
||||
<?php if (!empty($davHas)): ?>
|
||||
<form method="post" action="<?= e(url('settings/dav_revoke')) ?>" class="res-inline">
|
||||
<?= csrf_field() ?>
|
||||
<button class="btn btn-ghost btn-sm" type="submit"
|
||||
onclick="return confirm('撤销后,所有已同步的客户端(日历与通讯录)都会失去访问。确定吗?')">撤销口令</button>
|
||||
</form>
|
||||
<span class="node-badge">口令已配置<?= !empty($davLastUsed) ? ' · 最近使用 ' . e((string)$davLastUsed) : ' · 还没被用过' ?></span>
|
||||
<?php else: ?>
|
||||
<span class="node-badge">还没配置口令</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($davHas)): ?>
|
||||
<p class="small muted" style="margin:10px 0 0">
|
||||
「重置」会立刻换一串新口令:<b>已经配好的设备会连接失败</b>,
|
||||
需要把新口令重新填进去。怀疑口令外泄时就用它。
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 天气地点(用户 2026-10-03 第 1 条)==================== -->
|
||||
<?php /* 原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
|
||||
所以地点从系统设置搬到了这里 —— 而且改成**城市名**,不是经纬度:
|
||||
|
||||
Reference in New Issue
Block a user