diff --git a/.htaccess b/.htaccess index 04832f0..9582d8f 100644 --- a/.htaccess +++ b/.htaccess @@ -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] + + +# ⚠️⚠️ 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 —— +# 典型的"本地好好的、线上不行"。部署脚本也会把这一段追加到线上那份里。) + + CGIPassAuth On # 备份/临时文件永远不给出去(这条线上很要紧:*.php.bak 曾经可被直接下载) diff --git a/app/Auth.php b/app/Auth.php index c8c7eb5..00388d8 100644 --- a/app/Auth.php +++ b/app/Auth.php @@ -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'], diff --git a/app/CalDav.php b/app/CalDav.php index 311c8fc..d8f80e5 100644 --- a/app/CalDav.php +++ b/app/CalDav.php @@ -18,7 +18,12 @@ * GET /dav/.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。 * 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。 diff --git a/app/CardDav.php b/app/CardDav.php new file mode 100644 index 0000000..02f3e78 --- /dev/null +++ b/app/CardDav.php @@ -0,0 +1,210 @@ + 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] !== '') { + // 单张卡:.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'); + } +} \ No newline at end of file diff --git a/app/ContactImport.php b/app/ContactImport.php index 8386bca..e215f26 100644 --- a/app/ContactImport.php +++ b/app/ContactImport.php @@ -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 { diff --git a/app/Schema.php b/app/Schema.php index ac94d4b..e719c3a 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -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( diff --git a/app/Vcard.php b/app/Vcard.php new file mode 100644 index 0000000..d502fda --- /dev/null +++ b/app/Vcard.php @@ -0,0 +1,393 @@ + $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-@<域>`(不能用"姓名"——同名的人会互相覆盖, + // 而且改名之后客户端会当成"删一张加一张")。 + $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; + } +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 018daa5..02ab372 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; } +} diff --git a/carddav.php b/carddav.php new file mode 100644 index 0000000..b48921c --- /dev/null +++ b/carddav.php @@ -0,0 +1,250 @@ +/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[] = '' . CardDav::x(CardDav::hrefOf($b)) . '' + . '' + . '' + . '' . CardDav::x((string)$b['name']) . '' + . '' + . CardDav::x((string)$b['description']) . '' + . '' + . ''; + } + CardDav::multis(); + echo '' . "\n" + . '' + . implode('', $parts) . ''; + 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[] = '' + . CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '' + . '' + . '' . CardDav::x($card['etag']) . '' + . '' + . 'text/vcard; charset=utf-8' + . '' . strlen($card['body']) . '' + . ''; + } + CardDav::multis(); + echo '' . "\n" + . '' + . implode('', $parts) . ''; + exit; + } + + // Depth 0:这本通讯录自身的属性 + $book = null; + foreach (CardDav::books($familyId, $me) as $b) { + if ((int)$b['id'] === $bookId) { + $book = $b; + } + } + CardDav::multis(); + echo '' . "\n" + . '' + . '' . CardDav::x(CardDav::bookHref($bookId)) . '' + . '' + . '' + . '' . CardDav::x((string)($book['name'] ?? '家庭名册')) . '' + . '' + . ''; + 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 '' . "\n" + . '' + . '' + . CardDav::x(CardDav::bookHref($bookId) . $cardId . '.vcf') . '' + . '' + . '' . CardDav::x($card['etag']) . '' + . '' . CardDav::x($card['body'] . "\r\n") . '' + . ''; + 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[] = '' + . CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '' + . '' + . '' . CardDav::x($card['etag']) . '' + . '' . CardDav::x($card['body'] . "\r\n") . '' + . ''; + } + unset($bodyRaw); + CardDav::multis(); + echo '' . "\n" + . '' + . implode('', $parts) . ''; + exit; +} + +// ---- 8. 其它方法一律 405(并告知支持哪些)---- +header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT'); +cd_fail(405, '不支持这个请求方法。'); \ No newline at end of file diff --git a/dav.php b/dav.php index e4ea97d..1a69af3 100644 --- a/dav.php +++ b/dav.php @@ -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']; diff --git a/index.php b/index.php index 560f5a3..f762c1d 100644 --- a/index.php +++ b/index.php @@ -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) { diff --git a/router.php b/router.php index 129d6a0..20eac26 100644 --- a/router.php +++ b/router.php @@ -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)) { diff --git a/templates/calendar/_settings_menu.php b/templates/calendar/_settings_menu.php index b004111..6e5f376 100644 --- a/templates/calendar/_settings_menu.php +++ b/templates/calendar/_settings_menu.php @@ -107,6 +107,10 @@ $backView = $curView; diff --git a/templates/calendar/calendars.php b/templates/calendar/calendars.php index ffe0ee8..f9c5e8d 100644 --- a/templates/calendar/calendars.php +++ b/templates/calendar/calendars.php @@ -199,17 +199,21 @@ $canWrite = \App\Auth::canWrite(); - -
+ +

CalDAV 订阅 在手机日历 / DAVx⁵ / Thunderbird 里订阅你的日历本

- 填下面这个地址 + 你的姓名或手机号 + CalDAV 口令,就能把「我的日历本」 + 填下面这个地址 + 你的姓名或手机号 + DAV 口令,就能把「我的日历本」 同步到别的日历程序里。这是只读订阅的 —— 在手机上改日程请回到本站来改。 -
口令与登录密码是分开的:订阅凭据会被客户端长期存着,所以另发一个, - 随时可换、不影响登录。 +
口令与登录密码是分开的,而且通讯录同步也用同一把(不用配两遍)。

@@ -217,32 +221,12 @@ $canWrite = \App\Auth::canWrite(); value=""> 在日历客户端里填这一条即可,会列出你所有的日历本。
- -

- 这是你的新口令,只显示这一次,请立刻复制走: -
-

- - -
-
- - -
- -
- - -
- 口令已配置 - - 还没配置口令 - -
- +
+ + + + 口令在「个人设置 → DAV 同步」里管理 +
diff --git a/templates/people/index.php b/templates/people/index.php index 0a1ab32..93470e7 100644 --- a/templates/people/index.php +++ b/templates/people/index.php @@ -326,6 +326,46 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
+ +
+

+ 通讯录同步(CardDAV) + 把这 个人放进手机通讯录 +

+

+ 在 iOS「通讯录」、安卓 DAVx⁵、Thunderbird、群晖 Contacts 里填下面这个地址 + + 你的姓名或手机号 + DAV 口令,就能把这份名册同步过去。 +
同步的就是你在这一页看到的这些人( 个)—— + 谁加进你的名册、谁就会出现在手机通讯录里。 +
目前是只读同步:在手机上改的联系人不会写回家书,要改请回到本站来改。 +

+
+ + + 填这一条即可;点进去之后 iOS/DAVx⁵ 会自动发现这本通讯录。 +
+
+ + + + clientW 360,越界元素就是它)。 + 页面变宽在手机上是最刺眼的问题(整页可以左右拖), + 所以文案压到八个字以内、并允许在窄屏折行。 */ ?> + 日历与通讯录共用一把 +
+
+ + + +
+

+ DAV 同步 + 日历本 + 通讯录,一个口令在手机 / 电脑上同步 +

+

+ 在 iOS「日历/通讯录」、安卓 DAVx⁵、Thunderbird、群晖日历里, + 填下面这两个地址 + 你的姓名或手机号 + DAV 口令,就能把 + 我的日历本和我的名册同步过去。 +
口令和登录密码是分开的 —— 第三方客户端会把凭据长期存在设备上并反复自动发送, + 用登录密码等于把主密码复制到每一台设备;所以这里单独发一个,随时可换、不影响登录。 +
目前是只读同步:在手机上改日程 / 加联系人不会写回家书, + 要改请回到本站来改。 +

+ +
+
+ + +
+
+ + + + 同步的是「我的名册」里那 个人 —— 和你名册页上看到的一致。 + +
+
+ + + 也可以填手机号,两个都认。 +
+
+ + + + 生成后只显示一次,请先复制到设备里再关页面。 +
+
+ + +

+ 这是你的新口令,只显示这一次,请立刻复制到设备的「密码」栏里: +
+

+ + + +
+
+ + +
+ +
+ + +
+ 口令已配置 + + 还没配置口令 + +
+ +

+ 「重置」会立刻换一串新口令:已经配好的设备会连接失败, + 需要把新口令重新填进去。怀疑口令外泄时就用它。 +

+ + +
+