v31 第 31 批:DAV(CalDAV/CardDAV)同步修复 —— 发现链缺失 + ETag 潮汐 + 资源重复
用户原话:「carddav 和 caldav 好像都不好用,没有任何数据被同步出来。
同步的客户端,在 D:/Yongquan/code/SyncCalendar 中有,你可以用这个里面的代码试试同步一下。」
三处真根因**全部靠实测**拿到(不是"看代码猜"):写了 _fb_dav_diag.py,
逐字复刻 SyncCalendar 的 DavClient 请求序列(PROPFIND/REPORT/GET + 它那几个 XML 体),
打线上一次就复现出用户症状(15 条失败),修完线上 28/0。
1. 发现链(discovery)**整段缺失** ← 这就是"没有任何数据"的根因
标准客户端(DAVx⁵ / iOS / SyncCalendar)建账号按 RFC 6764 走:
GET /.well-known/caldav → 302 到 /dav/ ← 原来 404(落到 index.php 去登录页)
PROPFIND <上下文路径> → current-user-principal ← 原来回的是"日历本清单",没有 principal
PROPFIND <principal> → calendar-home-set ← 原来没这个属性
PROPFIND <home> Depth:1 / <book> / REPORT ← 这两步一直是通的
① 就断 ⇒ 客户端**在填完账号点保存那一刻**就失败,一条数据都拉不到。
为什么历次验收全绿:上批只验了 PROPFIND /dav/ 与 /carddav/ —— 恰好是**一直通**的那两步,
断点在其**前面**,从来没被测过。
改:两个入口补 PRINCIPAL_PATH/HOME_PATH + /.well-known/* 301(**在鉴权之前**)+
根/principal/列表三处都给 current-user-principal。.htaccess 用**独立标记块**追加
well-known 重写规则(本仓库第 4 次"独立入口必须独立块")。
2. DTSTAMP 取 time() → ETag 潮汐(增量同步**永远不收敛**)
App\Ics::stampOf() 原为"没给时间就 time()",而**派生条目**(观影/行程/生日/农历/
节假日/节气)没有 updated_at/created_at ⇒ 每生成一次 ICS 就写一个新"现在" ⇒ 正文变
⇒ ETag 变。线上实测:同一本连续两次 REPORT **每一条 ETag 都不同**,整本 GET 的 ETag
也在变 ⇒ 客户端把"增量同步"退化成"每轮全量重拉",且拉回来的对不上、永远不收敛。
改:缺时间写固定值 19700101T000000Z(确定优先于"诚实的当前时间")。
3. 派生本把"日历视图条目"当"CalDAV 资源"
Calendar::exportRows() 对派生本按**日历视图**取数(重复日程展开 N 次、跨天逐日、
生日/农历逐日派生)。线上 /dav/1.ics/ 实测 **2981 个 response / 只有 196 个不同 href**
(anniv-0_familybook.ics 自己重复 **2399** 次)。CalDAV 的资源是**一个 .ics 文件** ⇒
客户端要么把同一条反复入库,要么判定服务器异常而中止本轮同步。
改:CalDav::resources() 按 **UID 去重**(资源名本就是 UID)。
4. 连带两个真 bug(同一段代码顺出来的)
· exportRows() 派生分支**没按本过滤**:month() 里"任务""纪念日"两段不带 kind 判断
(它们 cal=0)⇒ 取"观影本"会把全家所有生日一起带出来(2981 条里绝大多数是 anniv)。
· CalDav::parsePath() 的 $event=(int)$evSeg:派生资源名(movie-100_familybook)
(int) 后全是 0 ⇒ 单条资源请求被当"整本",GET /dav/4.ics/lunar-...ics 实测回 **400 条**。
5. 顺带补全:系统日历本(农历/节假日/传统节日/节气)原来**导出是空的**
它们只走派生、没有 calendar_events 行,而 exportRows 只认 custom/movie/trip ⇒
手机上订阅这几个本一条都没有。改:按 [今年-1, 今年+5] 派生(**封顶**,农历每天一条,
130 年会有 4.7 万条)。实测耗时 lunarRange 31ms / traditional 51ms / Anniversary 30ms。
改:.htaccess、router.php、dav.php、carddav.php、app/CalDav.php、app/CardDav.php、
app/Calendar.php、app/Ics.php、templates/settings/me.php(补"或只填服务器地址"的提示)。
验证(全部 0 失败):DAV 探针 28/0(**线上修前 15 失败 → 修后 28/0**,同一套探针两处都跑)、
DAV 反向验证 8/0(7 处修复各自改回旧行为,断言全变红)、e2e 1478/0、升级 153/0、
城市 32/0、几何 428/0 + 移动 680/0 + 交互 92/0、b24 41/0、b27 34/0、b28 39/0、b29 38/0、
b30_import 26/0、b30_ui2 16/0、b30_notify 23/0;部署静态断言新增 12 条守卫并逐条证伪通过。
线上 7 个文件 SHA256 与本地一致;/.well-known/caldav→301 到 /dav/ 实测生效。
⚠️ 本轮在部署脚本上真踩到并修掉两个坑(已写进 pitfalls.md):
· router.php **不发到线上**,把它写进部署断言 → grep "No such file" → 部署中止在
"改了一半"的状态(迁到 push.sh 第 1 段)。
· `set -euo pipefail` 下 `X=$(... | grep -c ...)` 在 **0 匹配**时退出码 1 ⇒
**整个脚本静默退出**(不打 FAIL,日志停在上一行)。已给全部 7 处补 `|| true`。
This commit is contained in:
@@ -19,6 +19,15 @@
|
|||||||
<IfModule mod_rewrite.c>
|
<IfModule mod_rewrite.c>
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
|
|
||||||
|
# 🔴 v31:`/.well-known/*` 是 RFC 6764 规定的 DAV 发现入口,客户端**第一句**就问这里。
|
||||||
|
# ⚠️ 它必须排在 `^dav` / `^carddav` 之前 —— 这两条是前缀通配(`^dav(/.*)?$`),
|
||||||
|
# 而 `.well-known/caldav` 并不以 dav 开头,所以严格说互不干扰;
|
||||||
|
# 但放在最前面是"发现链优先于资源访问"的意图表达,后人加规则时不易搞错。
|
||||||
|
# 交给 dav.php / carddav.php 回 301(它们在**鉴权之前**处理这条路径:
|
||||||
|
# .well-known 是免鉴权入口,在这里 401 会让客户端以为"不支持 DAV")。
|
||||||
|
RewriteRule ^\.well-known/caldav$ dav.php [L,QSA]
|
||||||
|
RewriteRule ^\.well-known/carddav$ carddav.php [L,QSA]
|
||||||
|
|
||||||
# CalDAV:/dav/、/dav/3.ics/、/dav/3.ics/12.ics 全部交给 dav.php
|
# CalDAV:/dav/、/dav/3.ics/、/dav/3.ics/12.ics 全部交给 dav.php
|
||||||
RewriteRule ^dav(/.*)?$ dav.php [L,QSA]
|
RewriteRule ^dav(/.*)?$ dav.php [L,QSA]
|
||||||
|
|
||||||
@@ -28,6 +37,19 @@
|
|||||||
# 宽松形式 —— 那会把 /dav2/ 之类也吞进来。
|
# 宽松形式 —— 那会把 /dav2/ 之类也吞进来。
|
||||||
RewriteRule ^carddav(/.*)?$ carddav.php [L,QSA]
|
RewriteRule ^carddav(/.*)?$ carddav.php [L,QSA]
|
||||||
|
|
||||||
|
# 🔴 v31:`/.well-known/caldav` 与 `/.well-known/carddav`(RFC 6764)。
|
||||||
|
# 标准客户端(DAVx⁵ / iOS / Evolution)添加账号时**第一句**就试这里 ——
|
||||||
|
# 原来这里 404,客户端直接判定"域名下没有 DAV 服务",连口令框都不弹。
|
||||||
|
# 症状正是用户报的「同步不出任何数据」(账号根本建不起来)。
|
||||||
|
# ⚠️ 这两条必须**排在 `^dav` / `^carddav` 之前**吗?不需要 ——
|
||||||
|
# `.well-known/...` 不以 dav/carddav 开头,两条规则互不干扰。
|
||||||
|
# 但**必须**在站点根的"真实文件"判断之前:Apache 对 .htaccess 里的
|
||||||
|
# RewriteRule 天然就是先于文件查找生效的,所以这里无需额外处理。
|
||||||
|
# 交给 dav.php / carddav.php 自己回 301(它们在鉴权**之前**处理,
|
||||||
|
# 因为 .well-known 是免鉴权入口 —— 在这里 401 会让客户端以为"不支持")。
|
||||||
|
RewriteRule ^\.well-known/caldav$ dav.php [L,QSA]
|
||||||
|
RewriteRule ^\.well-known/carddav$ carddav.php [L,QSA]
|
||||||
|
|
||||||
# 导航展示页(v24 第 24 批 用户第 4 条):/nav/?<短语> —— 全站唯一免登录页面。
|
# 导航展示页(v24 第 24 批 用户第 4 条):/nav/?<短语> —— 全站唯一免登录页面。
|
||||||
# ⚠️ suggest 那条**必须排在前面**:`^nav(/.*)?$` 会把 /nav/suggest.php 也吞进去,
|
# ⚠️ suggest 那条**必须排在前面**:`^nav(/.*)?$` 会把 /nav/suggest.php 也吞进去,
|
||||||
# 顺序反了就变成"每次联想都去渲染展示页"(返回一页 HTML、前端拿到非 JSON)。
|
# 顺序反了就变成"每次联想都去渲染展示页"(返回一页 HTML、前端拿到非 JSON)。
|
||||||
|
|||||||
+161
-11
@@ -61,31 +61,69 @@ final class CalDav
|
|||||||
return self::ROOT . $calId . '.ics/';
|
return self::ROOT . $calId . '.ics/';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程" */
|
/**
|
||||||
|
* 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程"。
|
||||||
|
*
|
||||||
|
* 🔴 v31 修的真 bug:`$event` 原来是 `(int)$evSeg` —— 于是**派生条目**的
|
||||||
|
* 资源名(`movie-100_familybook`、`lunar-2026-10-01_familybook`)
|
||||||
|
* `(int)` 之后全是 **0**,被当成"没指定单条资源" → 整本导出。
|
||||||
|
* 实测:`GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 **400 条 VEVENT**。
|
||||||
|
*
|
||||||
|
* 这也正是**为什么不能只认数字 id**:资源名有两种(自定义日程用 id、
|
||||||
|
* 派生条目用净化后的 uid),`(int)` 会把后者静默压成 0。
|
||||||
|
* ⇒ 改为按 `$seg` 是否为空判断;`$event` 仍给**数字**那份(自定义日程用),
|
||||||
|
* 同时把原始段原样带出去(`seg`),让调用方按资源名精确匹配。
|
||||||
|
*
|
||||||
|
* @return array{book:int, event:int, seg:string}
|
||||||
|
*/
|
||||||
public static function parsePath(string $path): array
|
public static function parsePath(string $path): array
|
||||||
{
|
{
|
||||||
$p = (string)parse_url($path, PHP_URL_PATH);
|
$p = (string)parse_url($path, PHP_URL_PATH);
|
||||||
if (strpos($p, self::ROOT) !== 0) {
|
if (strpos($p, self::ROOT) !== 0) {
|
||||||
return ['book' => 0, 'event' => 0];
|
return ['book' => 0, 'event' => 0, 'seg' => ''];
|
||||||
}
|
}
|
||||||
$rest = substr($p, strlen(self::ROOT));
|
$rest = substr($p, strlen(self::ROOT));
|
||||||
$rest = trim($rest, '/');
|
$rest = trim($rest, '/');
|
||||||
if ($rest === '') {
|
if ($rest === '') {
|
||||||
return ['book' => 0, 'event' => 0];
|
return ['book' => 0, 'event' => 0, 'seg' => ''];
|
||||||
}
|
}
|
||||||
// 形如 3.ics 或 3.ics/<uid>.ics
|
// 形如 3.ics 或 3.ics/<seg>.ics
|
||||||
$parts = explode('/', $rest);
|
$parts = explode('/', $rest);
|
||||||
$bookSeg = $parts[0];
|
$bookSeg = $parts[0];
|
||||||
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
|
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
|
||||||
$event = 0;
|
$seg = '';
|
||||||
if (isset($parts[1]) && $parts[1] !== '') {
|
if (isset($parts[1]) && $parts[1] !== '') {
|
||||||
// 单条日程:<uid>.ics —— uid 里可能含点,所以只去掉结尾的 .ics
|
// 单条资源:<seg>.ics —— 段里可能含点/短横,所以只去掉结尾的 .ics
|
||||||
$evSeg = preg_replace('/\.ics$/', '', $parts[1]);
|
$seg = (string)preg_replace('/\.ics$/', '', $parts[1]);
|
||||||
$event = (int)$evSeg; // 我们用的是 calendar_events.id
|
|
||||||
}
|
}
|
||||||
return ['book' => $book, 'event' => $event];
|
return ['book' => $book, 'event' => (int)$seg, 'seg' => $seg];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端**发现链**用的固定路径(RFC 6764 / 6352)。
|
||||||
|
*
|
||||||
|
* 🔴 v31 修的真 bug:原来这两个入口**完全没实现**,客户端的发现链第一句就断。
|
||||||
|
* 实测(DAVx⁵ / iOS 每次添加账号都这么做):
|
||||||
|
* PROPFIND / → 问 current-user-principal → 服务器根本没有 PROPFIND 处理,
|
||||||
|
* 落到 index.php 的 ?r= 路由 → 返回登录页 HTML(客户端解析失败)
|
||||||
|
* PROPFIND /dav/ 问 current-user-principal → 我们回的是"日历本清单",
|
||||||
|
* 里面**一个 principal 都没有** → 客户端判定"这不是个 DAV 服务器"
|
||||||
|
* 症状正是用户报的「同步不出任何数据」—— 连账号都建不起来,谈何同步。
|
||||||
|
*
|
||||||
|
* ⚠️ principal / home-set 不能"就地"写成 `/dav/` 自己:
|
||||||
|
* 客户端会拿 home-set 当**集合根**再往下 PROPFIND Depth:1。
|
||||||
|
* 直接回 /dav/ 的话,Depth:1 会要求列出 /dav/ 下所有日历本的**资源**,
|
||||||
|
* 语义错位且体积巨大。这里给独立路径,行为与"标准 DAV 服务器"一致。
|
||||||
|
*/
|
||||||
|
public const PRINCIPAL_PATH = '/dav/principal/';
|
||||||
|
|
||||||
|
public const HOME_PATH = '/dav/calendars/';
|
||||||
|
|
||||||
|
/** 通讯录那边同义(见 App\CardDav,两边刻意保持一致) */
|
||||||
|
public const CARD_PRINCIPAL_PATH = '/carddav/principal/';
|
||||||
|
|
||||||
|
public const CARD_HOME_PATH = '/carddav/addressbooks/';
|
||||||
|
|
||||||
// ---------------- 应答 ----------------
|
// ---------------- 应答 ----------------
|
||||||
|
|
||||||
public static function xmlHeader(): void
|
public static function xmlHeader(): void
|
||||||
@@ -117,6 +155,113 @@ final class CalDav
|
|||||||
return Calendar::exportRows($cal, $familyId, $me);
|
return Calendar::exportRows($cal, $familyId, $me);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个日历本里,"**资源**"意义上的条目(每个 href 一条)。
|
||||||
|
*
|
||||||
|
* 🔴 v31 修的真 bug:原来直接把 rows() 当成资源列表用,而
|
||||||
|
* `Calendar::exportRows()` 对**派生本**(观影 / 旅程)是按"日历视图"取数的 ——
|
||||||
|
* 重复日程会展开成 N 次发生、跨天日程逐天出一条、纪念日/农历/节假日逐日派生。
|
||||||
|
* 实测线上 `/dav/1.ics/`(观影本):**2981 个 `<d:response>`,只有 196 个不同 href**
|
||||||
|
* (其中一条 `anniv-0_familybook.ics` 自己重复了 2399 次)。
|
||||||
|
* CalDAV 的资源是**一个 .ics 文件**,同名重复出现会让客户端:
|
||||||
|
* · 把同一条日程反复入库(删除/去重逻辑整天在跑);
|
||||||
|
* · 或直接判定"服务器状态异常"而中止本轮同步。
|
||||||
|
*
|
||||||
|
* ⇒ 这里按 **UID** 去重(CalDAV 的资源名就是 UID)。同一个 UID 的多条谱系
|
||||||
|
* (重复日程的每次发生)在**一个资源**里由 RRULE 自己表达,本就该合并。
|
||||||
|
*
|
||||||
|
* ⚠️ 保留**第一次**出现的那条:rows() 按时间升序,第一条就是系列里最早的那次,
|
||||||
|
* 它的 title / 时长信息最完整(跨天条目里也只有第一天带 time)。
|
||||||
|
*/
|
||||||
|
public static function resources(array $cal, int $familyId, int $me): array
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
$out = [];
|
||||||
|
foreach (self::rows($cal, $familyId, $me) as $r) {
|
||||||
|
$uid = (string)($r['uid'] ?? '');
|
||||||
|
if ($uid === '') {
|
||||||
|
$out[] = $r; // 没有 UID 的(理论上不该有)不参与去重,照样列出
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($seen[$uid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seen[$uid] = true;
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资源名(href 的最后一段,不含 .ics)。
|
||||||
|
*
|
||||||
|
* - 自定义日程:用 calendar_events.id,稳定且与 oneEvent() 的入参一一对应;
|
||||||
|
* - 派生条目:id 为 0,用 uid 的净化形式(同书注释:"派生本没有 calendar_events.id")。
|
||||||
|
*/
|
||||||
|
public static function segmentOf(array $r): string
|
||||||
|
{
|
||||||
|
$rid = (int)($r['id'] ?? 0);
|
||||||
|
if ($rid > 0) {
|
||||||
|
return (string)$rid;
|
||||||
|
}
|
||||||
|
$uid = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)($r['uid'] ?? ''));
|
||||||
|
return $uid === null ? '' : $uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一个**资源**的 ICS 正文与 ETag —— 与 CardDav::cardOf 同一个套路:
|
||||||
|
* 正文与 ETag **成对**算出来,不给"两处各算一次"留机会。
|
||||||
|
*
|
||||||
|
* 🔴 v31:ETag 必须由**内容**算出。原先 REPORT 里用 md5($evIcs),
|
||||||
|
* 而 $evIcs 里含每次都在变的 DTSTAMP(见 Ics::stampOf)⇒ ETag 潮汐。
|
||||||
|
* 现在 DTSTAMP 已确定,但**判据仍钉在"内容哈希"上** —— 这样以后
|
||||||
|
* ICS 生成器再加什么时间性字段,也不会又变成"每轮都变"。
|
||||||
|
*
|
||||||
|
* @return array{ics:string, etag:string}
|
||||||
|
*/
|
||||||
|
public static function resourceOf(array $r, string $calName): array
|
||||||
|
{
|
||||||
|
$ics = Ics::export([$r], $calName);
|
||||||
|
return ['ics' => $ics, 'etag' => '"' . md5($ics) . '"'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按**资源名**找一个条目(供单条资源的 GET / REPORT 用)。
|
||||||
|
*
|
||||||
|
* ⚠️ 认两种资源名(与 segmentOf() 一一对应):
|
||||||
|
* · 纯数字 = calendar_events.id(自定义本)
|
||||||
|
* · 其它 = uid 净化形式(派生本:movie-100_familybook、lunar-2026-10-01_familybook)
|
||||||
|
* v31 之前只认数字,于是派生本的单条资源请求会被当成"整本"处理
|
||||||
|
* (实测 `GET /dav/4.ics/lunar-2025-01-01_familybook.ics` 回了 400 条 VEVENT)。
|
||||||
|
*
|
||||||
|
* ⚠️ 数字那一支**直接查库**(`oneEvent`),不走 resources():
|
||||||
|
* 理论上"合法资源名"必然出现在 resources() 里(href 就是从它生成的),
|
||||||
|
* 但"必须能取到"和"必须扫全表才能取到"是两件事 —— 一本几千条时
|
||||||
|
* 每取一条都全量派生一遍(含 130 年农历换算)代价太大。
|
||||||
|
* 派生本没有 id,只能从列表里找;量小(几十到几百条),可接受。
|
||||||
|
*/
|
||||||
|
public static function oneResource(array $cal, int $familyId, int $me, string $seg): ?array
|
||||||
|
{
|
||||||
|
$seg = trim($seg);
|
||||||
|
if ($seg === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 数字资源名 = calendar_events.id(自定义本)
|
||||||
|
if (ctype_digit($seg)) {
|
||||||
|
$one = self::oneEvent((int)$cal['id'], (int)$seg, [$familyId, $me]);
|
||||||
|
if ($one) {
|
||||||
|
return $one;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 派生资源名:从派生列表里按净化后的 uid 找
|
||||||
|
foreach (self::resources($cal, $familyId, $me) as $r) {
|
||||||
|
if (self::segmentOf($r) === $seg) {
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
|
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
|
||||||
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
|
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
|
||||||
{
|
{
|
||||||
@@ -139,9 +284,14 @@ final class CalDav
|
|||||||
return $r;
|
return $r;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一整本导出成 ics 文本(GET 与 REPORT 都用它) */
|
/**
|
||||||
|
* 一整本导出成 ics 文本(GET 与 REPORT 都用它)。
|
||||||
|
*
|
||||||
|
* ⚠️ 走 resources()(按 UID 去重)而不是 rows():日历视图里同一条日程会被
|
||||||
|
* 重复日程/跨天/派生规则展开成很多条,整本导出时那些都是**同一个资源**。
|
||||||
|
*/
|
||||||
public static function ics(array $cal, int $familyId, int $me): string
|
public static function ics(array $cal, int $familyId, int $me): string
|
||||||
{
|
{
|
||||||
return Ics::export(self::rows($cal, $familyId, $me), (string)$cal['name']);
|
return Ics::export(self::resources($cal, $familyId, $me), (string)$cal['name']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,7 +568,18 @@ final class Calendar
|
|||||||
$to = '2100-01-01';
|
$to = '2100-01-01';
|
||||||
$out = [];
|
$out = [];
|
||||||
$seen = [];
|
$seen = [];
|
||||||
|
$want = (int)$cal['id'];
|
||||||
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
|
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
|
||||||
|
// 🔴 v31 修的真 bug:`month()` 里**任务**与**纪念日**那两段是
|
||||||
|
// **不带 kind 判断**的(它们本就不属于任何日历本,`cal` 给的是 0)。
|
||||||
|
// 于是"取观影本"也会把全家 130 年的生日 + 所有任务一起带出来 ——
|
||||||
|
// 实测线上 /dav/1.ics/ 的 2981 条里,绝大多数是 anniv(生日)。
|
||||||
|
// ⇒ 导出必须只认**真正属于这一本**的条目。
|
||||||
|
// ⚠️ 判据是 `cal` 相等,不是 `kind` 白名单:白名单每加一个
|
||||||
|
// 系统日历本就要改一次,漏改就是"这本里混进别人"。
|
||||||
|
if ((int)($e['cal'] ?? 0) !== $want) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
|
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
|
||||||
if (isset($seen[$key])) {
|
if (isset($seen[$key])) {
|
||||||
continue; // 跨天日程会逐天出一条,导出时一条就够
|
continue; // 跨天日程会逐天出一条,导出时一条就够
|
||||||
@@ -598,6 +609,51 @@ final class Calendar
|
|||||||
'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at'
|
'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at'
|
||||||
);
|
);
|
||||||
$st->execute([(int)$cal['id']]);
|
$st->execute([(int)$cal['id']]);
|
||||||
|
|
||||||
|
// 🔴 v31:系统日历本(农历 / 节假日 / 传统节日 / 节气)原来是**空的** ——
|
||||||
|
// exportRows 只认 custom/movie/trip,于是手机上把这几个本订阅进来
|
||||||
|
// 里面一条都没有,用户会以为"同步坏了"。
|
||||||
|
// 它们的内容是纯日期推导(Holiday::*),没有 calendar_events 行,只能派生。
|
||||||
|
//
|
||||||
|
// ⚠️ 范围必须**封顶**,不能像派生本那样 1970→2100:
|
||||||
|
// · 农历是**每天一条** —— 130 年 ≈ 47500 条,单个 multistatus 会有十几 MB;
|
||||||
|
// · 客户端订阅的也是"现在前后若干年"。
|
||||||
|
// 给 [今年-1, 今年+5]:往前一年覆盖"今年年初要看的去年腊月",往后五年
|
||||||
|
// 够手机翻日历用,总量(农历 ≈ 2200 条)与真实使用相符。
|
||||||
|
if ($kind !== 'custom') {
|
||||||
|
$y0 = (int)date('Y') - 1;
|
||||||
|
$y1 = (int)date('Y') + 5;
|
||||||
|
$want = (int)$cal['id'];
|
||||||
|
$out = [];
|
||||||
|
foreach (self::month($familyId, [$cal], $me, $y0 . '-01-01', $y1 . '-12-31') as $e) {
|
||||||
|
// 只认属于这一本的(`month()` 里任务/纪念日那两段不带 kind 判断,
|
||||||
|
// 给任意 cal 都会一起出来 —— 见上面派生本那段注释)
|
||||||
|
if ((int)($e['cal'] ?? 0) !== $want) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$day = (string)$e['date'];
|
||||||
|
// ⚠️ UID 必须**带日期**:这些条目 ref_id 恒为 0,
|
||||||
|
// 照搬 `kind-ref_id@familybook` 会让整本共用一个 UID,
|
||||||
|
// 按 UID 去重后就只剩一天(见 CalDav::resources)。
|
||||||
|
$out[] = [
|
||||||
|
'uid' => (string)$e['kind'] . '-' . $day . '@familybook',
|
||||||
|
'title' => (string)$e['title'],
|
||||||
|
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
|
||||||
|
'ends_at' => null,
|
||||||
|
'all_day' => 1,
|
||||||
|
'location' => null,
|
||||||
|
'note' => null,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'transp' => 0,
|
||||||
|
'sequence' => 0,
|
||||||
|
'created_at' => null,
|
||||||
|
'updated_at' => null,
|
||||||
|
'reminder_minutes' => null,
|
||||||
|
'rrule' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
return $st->fetchAll();
|
return $st->fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ final class CardDav
|
|||||||
/** 默认那一本通讯录的 id。家庭场景只有一本,用固定 id 让 URL 稳定可记 */
|
/** 默认那一本通讯录的 id。家庭场景只有一本,用固定 id 让 URL 稳定可记 */
|
||||||
public const BOOK_ID = 1;
|
public const BOOK_ID = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 客户端**发现链**用的固定路径(RFC 6352 §6 / RFC 6764)。
|
||||||
|
*
|
||||||
|
* 🔴 v31 修的真 bug:与 CalDAV 同一个病 —— 这两个入口原来**完全没实现**。
|
||||||
|
* 实测线上:`PROPFIND /carddav/` 问 current-user-principal 回的是"通讯录清单"
|
||||||
|
* (一个 principal 都没有)→ 客户端判定"这不是个 CardDAV 服务器" →
|
||||||
|
* 建账号那一步就失败,一张卡片都同步不出来。
|
||||||
|
*
|
||||||
|
* ⚠️ 与 CalDav 刻意**同名同义**(PRINCIPAL_PATH / HOME_PATH 语义一一对应),
|
||||||
|
* 两边漂移的话,手机上"日历能连、通讯录不能连"这类半通状态会很难查。
|
||||||
|
*/
|
||||||
|
public const PRINCIPAL_PATH = '/carddav/principal/';
|
||||||
|
|
||||||
|
public const HOME_PATH = '/carddav/addressbooks/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前用户可见的"通讯录"。
|
* 当前用户可见的"通讯录"。
|
||||||
*
|
*
|
||||||
|
|||||||
+19
-3
@@ -323,11 +323,27 @@ final class Ics
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** DTSTAMP / CREATED 一律写成 UTC(RFC 要求带 Z) */
|
/** DTSTAMP / CREATED 一律写成 UTC(RFC 要求带 Z) */
|
||||||
|
/**
|
||||||
|
* DTSTAMP / CREATED / LAST-MODIFIED 的时间戳。
|
||||||
|
*
|
||||||
|
* 🔴 v31 修的真 bug:原来"没给时间就取 time()"。而**派生条目**
|
||||||
|
* (观影 / 行程节点 / 纪念日 / 农历 / 节假日 / 节气)没有 updated_at、created_at,
|
||||||
|
* 于是每生成一次 ICS,文本里就写进一个新的"现在" ⇒ 正文变了 ⇒ ETag 跟着变。
|
||||||
|
* 实测线上同一本连续两次 REPORT,**每一条的 ETag 都不一样** ——
|
||||||
|
* 客户端的"增量同步"因此退化成"每轮全量重拉",而且永远不收敛
|
||||||
|
* (拉回来的 ETag 与它记下的对不上 → 认为服务器一直在变)。
|
||||||
|
*
|
||||||
|
* ⇒ 缺时间就写**固定值**:确定性优先。一个诚实的"不知道",
|
||||||
|
* 远比一个每次都变的"现在"对同步友好。
|
||||||
|
* ⚠️ 别改成"用 starts_at 当 DTSTAMP"——DTSTAMP 的语义是"这一条什么时候
|
||||||
|
* 被创建/最后修改",不是"这件事什么时候发生";改了会让客户端排序错乱。
|
||||||
|
*/
|
||||||
private static function stampOf($dt): string
|
private static function stampOf($dt): string
|
||||||
{
|
{
|
||||||
$t = strtotime((string)$dt);
|
$s = trim((string)$dt);
|
||||||
if ($t === false) {
|
$t = $s === '' ? 0 : strtotime($s);
|
||||||
$t = time();
|
if ($t === false || $t <= 0) {
|
||||||
|
return '19700101T000000Z';
|
||||||
}
|
}
|
||||||
return gmdate('Ymd\THis\Z', $t);
|
return gmdate('Ymd\THis\Z', $t);
|
||||||
}
|
}
|
||||||
|
|||||||
+179
-35
@@ -19,6 +19,24 @@
|
|||||||
*
|
*
|
||||||
* ⚠️ 与 dav.php 一样,这里不做"半支持":写方法一律 403。
|
* ⚠️ 与 dav.php 一样,这里不做"半支持":写方法一律 403。
|
||||||
* 手机上加/改/删联系人**不会**回写到家书 —— 与其让用户以为同步了,不如明说。
|
* 手机上加/改/删联系人**不会**回写到家书 —— 与其让用户以为同步了,不如明说。
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。
|
||||||
|
*
|
||||||
|
* 与 CalDAV 是**同一个根因**:发现链(discovery)整段缺失。
|
||||||
|
* 标准客户端建账号时按 RFC 6764 走:
|
||||||
|
* ① GET /.well-known/carddav → 301 到 /carddav/principal/
|
||||||
|
* ② PROPFIND /carddav/ → d:current-user-principal
|
||||||
|
* ③ PROPFIND <principal> → card:addressbook-home-set
|
||||||
|
* ④ PROPFIND <home-set> Depth:1 → 列出通讯录
|
||||||
|
* ⑤ PROPFIND <book> Depth:1 / REPORT → 取每张卡
|
||||||
|
*
|
||||||
|
* 实测线上:① 404、②③ 回空 ⇒ 客户端在"填完账号点保存"那一刻就失败。
|
||||||
|
*
|
||||||
|
* ⚠️ 本文件**同时**补了一个"路径被当成通讯录 id"的坑:
|
||||||
|
* CardDav::parsePath('/carddav/principal/') 会把 `principal` 段去 `.vcf` 后缀后
|
||||||
|
* 转成 int → **0**,于是 principal 路径会被当成"根"、走进"列通讯录"那条路,
|
||||||
|
* 客户端就永远问不到 addressbook-home-set。所以发现链节点必须先于 parsePath 判定。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -49,6 +67,26 @@ if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
|
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/carddav/'), PHP_URL_PATH);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ `/.well-known/carddav` 是**唯一免鉴权**的入口(RFC 6764 §6.1)。
|
||||||
|
* 在这里就 401 的话,很多客户端会直接报"服务器不支持 CardDAV",
|
||||||
|
* 而不是弹口令框 —— 所以这一跳必须在鉴权**之前**。
|
||||||
|
*
|
||||||
|
* ⚠️ 跳转目标选**上下文路径 `/carddav/`**(RFC 6764 原话是
|
||||||
|
* "redirect to the context path"):它的 PROPFIND 里同时带了
|
||||||
|
* current-user-principal、addressbook-home-set 与通讯录清单,
|
||||||
|
* 三种客户端一次都满足。
|
||||||
|
*/
|
||||||
|
if ($path === '/.well-known/carddav') {
|
||||||
|
http_response_code(301);
|
||||||
|
header('Location: /carddav/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$who = Auth::davAuth($user, $pass);
|
$who = Auth::davAuth($user, $pass);
|
||||||
if (!$who) {
|
if (!$who) {
|
||||||
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
|
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
|
||||||
@@ -57,8 +95,19 @@ if (!$who) {
|
|||||||
$familyId = (int)$who['family_id'];
|
$familyId = (int)$who['family_id'];
|
||||||
$me = (int)$who['person_id'];
|
$me = (int)$who['person_id'];
|
||||||
|
|
||||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
/** 统一的尾斜杠形式,供"发现链路径"比对(`/carddav` 与 `/carddav/` 视为同一个) */
|
||||||
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/carddav/'), PHP_URL_PATH);
|
$norm = '/' . trim($path, '/') . '/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发现链节点。
|
||||||
|
* ⚠️⚠️ `$isRoot` **只**指**站点根 `/`**,不含 `/carddav/` —— 与 dav.php 同一个坑
|
||||||
|
* (本批实测):把 `/carddav/` 也算成 root 的话,`PROPFIND /carddav/`
|
||||||
|
* 只回一行 principal、**一本通讯录都不列**,客户端仍然同步不出数据。
|
||||||
|
*/
|
||||||
|
$isRoot = ($path === '/' || $path === '');
|
||||||
|
$isPrincipal = ($norm === CardDav::PRINCIPAL_PATH);
|
||||||
|
$isHome = ($norm === CardDav::HOME_PATH);
|
||||||
|
|
||||||
$hit = CardDav::parsePath($path);
|
$hit = CardDav::parsePath($path);
|
||||||
$bookId = (int)$hit['book'];
|
$bookId = (int)$hit['book'];
|
||||||
$cardId = (int)$hit['card'];
|
$cardId = (int)$hit['card'];
|
||||||
@@ -82,7 +131,11 @@ if ($method === 'OPTIONS') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 4. 找不到那一本 → 404(不要回 200 空列表:客户端会以为"这本是空的")----
|
// ---- 4. 找不到那一本 → 404(不要回 200 空列表:客户端会以为"这本是空的")----
|
||||||
if ($bookId > 0 && !in_array($bookId, array_column(CardDav::books($familyId, $me), 'id'), true)) {
|
// ⚠️ 发现链节点(principal / addressbooks)**不是通讯录 id**,
|
||||||
|
// parsePath 会把它们解析成 0 —— 必须先把它们放行,否则下面这句把客户端挡在门外。
|
||||||
|
$isDiscovery = $isRoot || $isPrincipal || $isHome;
|
||||||
|
if (!$isDiscovery && $bookId > 0
|
||||||
|
&& !in_array($bookId, array_column(CardDav::books($familyId, $me), 'id'), true)) {
|
||||||
cd_fail(404, '没有这本通讯录。');
|
cd_fail(404, '没有这本通讯录。');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,13 +145,72 @@ $vcfOfBook = static function (int $bid) use ($familyId, $me): string {
|
|||||||
return CardDav::vcf($familyId, $me);
|
return CardDav::vcf($familyId, $me);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 5. PROPFIND:清单 / 属性 / 资源列表 ----
|
$readOnlyPriv = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
|
||||||
|
. '</d:current-user-privilege-set>';
|
||||||
|
|
||||||
|
$emit = static function (array $parts): void {
|
||||||
|
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>';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
|
||||||
if ($method === 'PROPFIND') {
|
if ($method === 'PROPFIND') {
|
||||||
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
||||||
|
|
||||||
if ($bookId === 0) {
|
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal
|
||||||
// 根:列出这个人可见的通讯录
|
// (RFC 6764 §6:客户端把它当"从这个域名开始找 CardDAV 服务"的起点)
|
||||||
|
if ($isRoot) {
|
||||||
|
$emit([
|
||||||
|
'<d:response><d:href>/</d:href><d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/></d:resourcetype>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:current-user-principal>'
|
||||||
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5b. principal:客户端拿 addressbook-home-set 才知道"去哪列通讯录"
|
||||||
|
// ⚠️ 同样要回指向自己的 current-user-principal(理由见 dav.php 同段注释)。
|
||||||
|
if ($isPrincipal) {
|
||||||
|
$emit([
|
||||||
|
'<d:response><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:principal/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:current-user-principal>'
|
||||||
|
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:principal-URL>'
|
||||||
|
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
|
||||||
|
. '</d:href></card:addressbook-home-set>'
|
||||||
|
. $readOnlyPriv
|
||||||
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5c. home-set(以及 `/carddav/` 本身):列出这个人可见的通讯录。
|
||||||
|
// ⚠️ `/carddav/` 也走这条路:用户与老客户端都直接把 /carddav/ 当 home 用。
|
||||||
|
if ($isHome || $bookId === 0) {
|
||||||
$parts = [];
|
$parts = [];
|
||||||
|
// 首次请求 `/carddav/`(不是 home 路径)时,把发现链的答案一并给出 ——
|
||||||
|
// 有些客户端在这里**同时**问 current-user-principal,少给一个它就又断了。
|
||||||
|
if (!$isHome) {
|
||||||
|
$parts[] = '<d:response><d:href>/carddav/</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:current-user-principal>'
|
||||||
|
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:principal-URL>'
|
||||||
|
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
|
||||||
|
. '</d:href></card:addressbook-home-set>'
|
||||||
|
. '</d:prop></d:propstat></d:response>';
|
||||||
|
}
|
||||||
foreach (CardDav::books($familyId, $me) as $b) {
|
foreach (CardDav::books($familyId, $me) as $b) {
|
||||||
$parts[] = '<d:response><d:href>' . CardDav::x(CardDav::hrefOf($b)) . '</d:href>'
|
$parts[] = '<d:response><d:href>' . CardDav::x(CardDav::hrefOf($b)) . '</d:href>'
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
@@ -106,13 +218,10 @@ if ($method === 'PROPFIND') {
|
|||||||
. '<d:displayname>' . CardDav::x((string)$b['name']) . '</d:displayname>'
|
. '<d:displayname>' . CardDav::x((string)$b['name']) . '</d:displayname>'
|
||||||
. '<card:addressbook-description>'
|
. '<card:addressbook-description>'
|
||||||
. CardDav::x((string)$b['description']) . '</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>'
|
. $readOnlyPriv
|
||||||
. '</d:prop></d:propstat></d:response>';
|
. '</d:prop></d:propstat></d:response>';
|
||||||
}
|
}
|
||||||
CardDav::multis();
|
$emit($parts);
|
||||||
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,10 +246,7 @@ if ($method === 'PROPFIND') {
|
|||||||
. '<d:getcontentlength>' . strlen($card['body']) . '</d:getcontentlength>'
|
. '<d:getcontentlength>' . strlen($card['body']) . '</d:getcontentlength>'
|
||||||
. '</d:prop></d:propstat></d:response>';
|
. '</d:prop></d:propstat></d:response>';
|
||||||
}
|
}
|
||||||
CardDav::multis();
|
$emit($parts);
|
||||||
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,23 +257,50 @@ if ($method === 'PROPFIND') {
|
|||||||
$book = $b;
|
$book = $b;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CardDav::multis();
|
$emit([
|
||||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
'<d:response><d:href>' . CardDav::x(CardDav::bookHref($bookId)) . '</d:href>'
|
||||||
. '<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:propstat><d:prop>'
|
||||||
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
|
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
|
||||||
. '<d:displayname>' . CardDav::x((string)($book['name'] ?? '家庭名册')) . '</d:displayname>'
|
. '<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>'
|
. $readOnlyPriv
|
||||||
. '</d:prop></d:propstat></d:response></d:multistatus>';
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 6. GET / HEAD:整本导出成一个 vcf(不少客户端就走这一条)----
|
// ---- 6. GET / HEAD:整本导出成一个 vcf,或单张卡 ----
|
||||||
if ($method === 'GET' || $method === 'HEAD') {
|
if ($method === 'GET' || $method === 'HEAD') {
|
||||||
if ($bookId === 0) {
|
if ($bookId === 0) {
|
||||||
cd_fail(404, '没有这本通讯录。');
|
cd_fail(404, '没有这本通讯录。');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6a. 单张卡(/carddav/1.vcf/12.vcf)。
|
||||||
|
// 🔴 v31:与 CalDAV 同一个坑 —— 原来**没有**这个分支,
|
||||||
|
// 于是 `GET /carddav/1.vcf/12.vcf` 会落进下面的"整本导出",
|
||||||
|
// 一次回 801 张卡(162 KB)。客户端取一张卡却收到整本,
|
||||||
|
// 要么把整本当成这张卡的正文,要么因 UID 不匹配判定"资源不存在"。
|
||||||
|
if ($cardId > 0) {
|
||||||
|
$one = CardDav::one($familyId, $me, $cardId);
|
||||||
|
if (!$one) {
|
||||||
|
cd_fail(404, '没有这张联系人卡片。');
|
||||||
|
}
|
||||||
|
$card = CardDav::cardOf($one);
|
||||||
|
$etag = $card['etag'];
|
||||||
|
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
||||||
|
http_response_code(304);
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code(200);
|
||||||
|
header('Content-Type: text/vcard; charset=utf-8');
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
if ($method === 'GET') {
|
||||||
|
echo $card['body'] . "\r\n";
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6b. 整本导出成一个 vcf(老客户端走这条)
|
||||||
$vcf = $vcfOfBook($bookId);
|
$vcf = $vcfOfBook($bookId);
|
||||||
$etag = CardDav::etag($vcf);
|
$etag = CardDav::etag($vcf);
|
||||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
||||||
@@ -201,27 +334,38 @@ if ($method === 'REPORT') {
|
|||||||
cd_fail(404, '没有这张联系人卡片。');
|
cd_fail(404, '没有这张联系人卡片。');
|
||||||
}
|
}
|
||||||
/* ⚠️ 正文与 ETag 都从 cardOf 取(**同一处**算),别在这里另算一次 ——
|
/* ⚠️ 正文与 ETag 都从 cardOf 取(**同一处**算),别在这里另算一次 ——
|
||||||
本轮实测:PROPFIND 用不带结尾 CRLF 的正文算 ETag、这里用带 CRLF 的算,
|
v24 第 22 批实测:PROPFIND 用不带结尾 CRLF 的正文算 ETag、这里用带 CRLF 的算,
|
||||||
同一张卡两个 ETag → 客户端认为它一直在变、反复重拉(同步不收敛)。
|
同一张卡两个 ETag → 客户端认为它一直在变、反复重拉(同步不收敛)。
|
||||||
结尾那条 CRLF 只加在**正文**(address-data 要有完整的 vCard 文本),
|
结尾那条 CRLF 只加在**正文**(address-data 要有完整的 vCard 文本),
|
||||||
不参与 ETag。 */
|
不参与 ETag。 */
|
||||||
$card = CardDav::cardOf($one);
|
$card = CardDav::cardOf($one);
|
||||||
CardDav::multis();
|
$emit([
|
||||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
'<d:response><d:href>'
|
||||||
. '<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>'
|
. CardDav::x(CardDav::bookHref($bookId) . $cardId . '.vcf') . '</d:href>'
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
|
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
|
||||||
. '<card:address-data>' . CardDav::x($card['body'] . "\r\n") . '</card:address-data>'
|
. '<card:address-data>' . CardDav::x($card['body'] . "\r\n") . '</card:address-data>'
|
||||||
. '</d:prop></d:propstat></d:response></d:multistatus>';
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ multiget 时客户端会点明要哪几张(href 里带 id)。
|
// ⚠️ multiget 时客户端会点明要哪几张(href 里带 id)。
|
||||||
// 我们**整本给**、由客户端自己按 href 挑 —— 家庭场景一本最多几百张,
|
// v31 起**真的按 href 过滤**(原来"整本给、客户端自己挑")——
|
||||||
// 整本回的体积完全可接受,而"解析客户端要哪些"那套逻辑
|
// href 现在完全确定(`/carddav/<book>/<persons.id>.vcf`),
|
||||||
// (href 可能是绝对 URL、可能带前缀)是出错率最高的地方,不值得写。
|
// 只要最后一段就能反查,不必猜"绝对 URL / 前缀 / 编码"。
|
||||||
|
$wantsMulti = stripos($bodyRaw, 'addressbook-multiget') !== false;
|
||||||
|
$wantIds = [];
|
||||||
|
if ($wantsMulti && preg_match_all('#<[^>]*href[^>]*>([^<]+)</[^>]*href>#i', $bodyRaw, $mm)) {
|
||||||
|
foreach ($mm[1] as $h) {
|
||||||
|
$h = trim(html_entity_decode($h, ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||||
|
$seg = preg_replace('/\.vcf$/', '', (string)basename(rtrim($h, '/')));
|
||||||
|
if ($seg !== '' && ctype_digit($seg)) {
|
||||||
|
$wantIds[(int)$seg] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$rows = CardDav::rows($familyId, $me);
|
$rows = CardDav::rows($familyId, $me);
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
@@ -229,6 +373,9 @@ if ($method === 'REPORT') {
|
|||||||
if ($rid <= 0) {
|
if ($rid <= 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($wantsMulti && $wantIds && !isset($wantIds[$rid])) {
|
||||||
|
continue; // multiget:只要点名的
|
||||||
|
}
|
||||||
$card = CardDav::cardOf($r);
|
$card = CardDav::cardOf($r);
|
||||||
$parts[] = '<d:response><d:href>'
|
$parts[] = '<d:response><d:href>'
|
||||||
. CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '</d:href>'
|
. CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '</d:href>'
|
||||||
@@ -238,10 +385,7 @@ if ($method === 'REPORT') {
|
|||||||
. '</d:prop></d:propstat></d:response>';
|
. '</d:prop></d:propstat></d:response>';
|
||||||
}
|
}
|
||||||
unset($bodyRaw);
|
unset($bodyRaw);
|
||||||
CardDav::multis();
|
$emit($parts);
|
||||||
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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,29 @@
|
|||||||
*
|
*
|
||||||
* 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。
|
* 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。
|
||||||
* 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。
|
* 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。
|
||||||
|
*
|
||||||
|
* 真根因**不是**"哪里写错了",而是**发现链(discovery)整段缺失**——
|
||||||
|
* 而这恰好是上一批验收唯一没覆盖的部分(上批只验了 `PROPFIND /dav/` 与
|
||||||
|
* `PROPFIND /carddav/`,那两步一直是通的)。
|
||||||
|
*
|
||||||
|
* 标准客户端(DAVx⁵ / iOS / SyncCalendar)建账号时**一定**按 RFC 6764 走这么一条链:
|
||||||
|
* ① GET /.well-known/caldav → 302 到 /dav/
|
||||||
|
* ② PROPFIND /dav/ → d:current-user-principal → 拿到 principal 路径
|
||||||
|
* ③ PROPFIND <principal> → c:calendar-home-set → 拿到"日历本们的容器"
|
||||||
|
* ④ PROPFIND <home-set> Depth:1 → 列出各日历本
|
||||||
|
* ⑤ PROPFIND <book> Depth:1 / REPORT → 取每条日程
|
||||||
|
*
|
||||||
|
* 实测(线上,本批新写的 _fb_dav_diag.py):① 404、②③ 都回空。
|
||||||
|
* 于是客户端拿到 207 却**找不到 principal** → 它认为"这个地址不是 DAV 服务器"
|
||||||
|
* → 在"填完账号点保存"那一刻就失败,一条数据都拉不到。
|
||||||
|
* ⇒ 这就解释了为什么"看起来协议都对、就是没数据"。
|
||||||
|
*
|
||||||
|
* 本批补齐 ①②③,并顺手修掉两个只有真客户端才暴露的坑(见 REPORT / GET 段注释):
|
||||||
|
* · 派生本(观影/旅程)把"日历视图的逐日条目"当成了"资源",导致同一 href 重复上千次;
|
||||||
|
* · ICS 里的 DTSTAMP 每次生成都写"现在" → ETag 每轮都变 → 增量同步永远不收敛。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -49,6 +72,29 @@ if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
|
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_URL_PATH);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ `/.well-known/caldav` 是**唯一免鉴权**的入口(RFC 6764 §6.1)。
|
||||||
|
* 客户端在"还不知道用户名口令对不对"的时候先探这里;
|
||||||
|
* 在这里就 401 的话,很多客户端会直接报"服务器不支持 CalDAV",
|
||||||
|
* 而不是弹口令框 —— 所以这一跳必须在鉴权**之前**。
|
||||||
|
*
|
||||||
|
* ⚠️ 跳转目标选**上下文路径 `/dav/`**(RFC 6764 §6.1 的原话就是
|
||||||
|
* "redirect to the context path"),而不是 principal:
|
||||||
|
* `/dav/` 的 PROPFIND 里**同时**带了 current-user-principal、
|
||||||
|
* calendar-home-set 和日历本清单 —— 三种客户端(要 principal 的、
|
||||||
|
* 要 home 的、直接当集合用的)一次都满足,少一次往返。
|
||||||
|
* (本批实测踩过:跳到 principal 只对"严格按 RFC 5397 走的"客户端够用。)
|
||||||
|
*/
|
||||||
|
if ($path === '/.well-known/caldav') {
|
||||||
|
http_response_code(301);
|
||||||
|
header('Location: /dav/');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$who = Auth::davAuth($user, $pass);
|
$who = Auth::davAuth($user, $pass);
|
||||||
if (!$who) {
|
if (!$who) {
|
||||||
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
|
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
|
||||||
@@ -57,11 +103,30 @@ if (!$who) {
|
|||||||
$familyId = (int)$who['family_id'];
|
$familyId = (int)$who['family_id'];
|
||||||
$me = (int)$who['person_id'];
|
$me = (int)$who['person_id'];
|
||||||
|
|
||||||
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
/** 统一的尾斜杠形式,供"发现链路径"比对(`/dav` 与 `/dav/` 视为同一个) */
|
||||||
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_URL_PATH);
|
$norm = '/' . trim($path, '/') . '/';
|
||||||
|
|
||||||
$hit = CalDav::parsePath($path);
|
$hit = CalDav::parsePath($path);
|
||||||
$bookId = (int)$hit['book'];
|
$bookId = (int)$hit['book'];
|
||||||
$evId = (int)$hit['event'];
|
$evSeg = (string)($hit['seg'] ?? ''); // 资源名(可能是 'movie-100_familybook')
|
||||||
|
$evId = (int)$hit['event']; // 数字形式(自定义日程 id;派生条目恒为 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发现链上的三个固定节点。
|
||||||
|
* ⚠️ 它们**不是日历本**,必须在下面的"找不到那本 → 404"之前识别出来,
|
||||||
|
* 否则会被当成 bookId=0 走"列日历本"那条路 —— 客户端就问不到 principal 了。
|
||||||
|
*
|
||||||
|
* ⚠️⚠️ `$isRoot` **只**指**站点根 `/`**,不包括 `/dav/`:
|
||||||
|
* 这里踩过一次(本批实测)—— 把 `/dav/` 也算进 isRoot,于是
|
||||||
|
* `PROPFIND /dav/` 只回一行 principal、**一本日历都不列**,
|
||||||
|
* 客户端拿不到任何日历本 ⇒ 仍然是"同步不出数据"。
|
||||||
|
* `/dav/` 是**日历本的容器**,必须走 5c 的列表分支。
|
||||||
|
* (`/dav/` 的响应里会**同时**带上 current-user-principal 与 calendar-home-set,
|
||||||
|
* 所以把 /dav/ 当入口的老客户端也一次就能往下走。)
|
||||||
|
*/
|
||||||
|
$isRoot = ($path === '/' || $path === '');
|
||||||
|
$isPrincipal = ($norm === CalDav::PRINCIPAL_PATH);
|
||||||
|
$isHome = ($norm === CalDav::HOME_PATH);
|
||||||
|
|
||||||
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
|
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
|
||||||
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
|
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
|
||||||
@@ -71,7 +136,7 @@ if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATC
|
|||||||
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
|
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
|
||||||
if ($method === 'OPTIONS') {
|
if ($method === 'OPTIONS') {
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
header('DAV: 1, 2, calendar-access');
|
header('DAV: 1, 2, 3, calendar-access');
|
||||||
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
||||||
header('MS-Author-Via: DAV');
|
header('MS-Author-Via: DAV');
|
||||||
exit;
|
exit;
|
||||||
@@ -95,78 +160,173 @@ $dispName = static function (array $c) use ($me): string {
|
|||||||
return $nm;
|
return $nm;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- 5. PROPFIND:清单 / 属性 / 资源列表 ----
|
/** 一个 `d:propstat` 里塞当前用户的读权限(只读实现,所以恒为 read) */
|
||||||
|
$readOnlyPriv = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
|
||||||
|
. '</d:current-user-privilege-set>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出一段 multistatus(207 + XML 头)。
|
||||||
|
* ⚠️ 抽成函数是因为发现链那几个分支要吐**结构完全不同**的 XML,
|
||||||
|
* 内联三份 implode 容易漏掉某一份的命名空间声明。
|
||||||
|
*/
|
||||||
|
$emit = static function (string $xmlns, array $parts): void {
|
||||||
|
CalDav::multis();
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus ' . $xmlns . '>' . implode('', $parts) . '</d:multistatus>';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
|
||||||
if ($method === 'PROPFIND') {
|
if ($method === 'PROPFIND') {
|
||||||
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
||||||
|
|
||||||
if ($bookId === 0) {
|
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
|
||||||
// 根:列出这个人可见的全部日历本
|
// 客户端拿它当"从这个域名开始找 DAV 服务"的起点。
|
||||||
|
if ($isRoot) {
|
||||||
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
|
||||||
|
'<d:response><d:href>/</d:href><d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/></d:resourcetype>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:current-user-principal>'
|
||||||
|
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
|
||||||
|
. '</d:href></c:calendar-home-set>'
|
||||||
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5b. principal:客户端拿 calendar-home-set 才能知道"去哪列日历本"。
|
||||||
|
// ⚠️ 还要回 `current-user-principal`(指向自己)—— 客户端**在 principal 上
|
||||||
|
// 也会再问一遍这个属性**(RFC 5397 里 principal 资源本就该有它)。
|
||||||
|
// 本批实测:不回的话,严格按 RFC 走的客户端会在这里判"拿不到 principal"
|
||||||
|
// 而中止建账号(探针的"照它报的地址再走一步"那两条正是抓这个的)。
|
||||||
|
if ($isPrincipal) {
|
||||||
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
|
||||||
|
'<d:response><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:principal/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:current-user-principal>'
|
||||||
|
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:principal-URL>'
|
||||||
|
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
|
||||||
|
. '</d:href></c:calendar-home-set>'
|
||||||
|
. $readOnlyPriv
|
||||||
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5c. home-set:列出这个人可见的全部日历本。
|
||||||
|
// ⚠️ `PROPFIND /dav/` 也走这条路(用户与老客户端都直接把 /dav/ 当 home 用),
|
||||||
|
// 差别只在 href 报哪个 —— 客户端会拿它当后续请求的 base,报错就 404。
|
||||||
|
if ($isHome || $bookId === 0) {
|
||||||
|
$baseHref = $isHome ? CalDav::HOME_PATH : '/dav/';
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach (CalDav::books($familyId, $me) as $c) {
|
foreach (CalDav::books($familyId, $me) as $c) {
|
||||||
$href = CalDav::hrefOf($c);
|
$href = CalDav::hrefOf($c);
|
||||||
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
|
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:resourcetype><c:calendar/></d:resourcetype>'
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
|
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
|
||||||
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$c['name']) . '</c:calendar-description>'
|
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$c['name']) . '</c:calendar-description>'
|
||||||
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$c['color']) . '</x:calendar-color>'
|
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$c['color']) . '</x:calendar-color>'
|
||||||
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
||||||
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
|
. $readOnlyPriv
|
||||||
. '</d:prop></d:propstat></d:response>';
|
. '</d:prop></d:propstat></d:response>';
|
||||||
}
|
}
|
||||||
CalDav::multis();
|
// 首次请求 `/dav/`(bookId=0 且不是 home 路径)时,把发现链的三个答案一并给出:
|
||||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
// 有些客户端在 /dav/ 上**同时**问 current-user-principal —— 少给一个它就又断了。
|
||||||
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
if (!$isHome) {
|
||||||
. implode('', $parts) . '</d:multistatus>';
|
array_unshift($parts, '<d:response><d:href>' . CalDav::x($baseHref) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CalDav::x('家书日历') . '</d:displayname>'
|
||||||
|
. '<d:current-user-principal><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:current-user-principal>'
|
||||||
|
. '<d:principal-URL><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH)
|
||||||
|
. '</d:href></d:principal-URL>'
|
||||||
|
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
|
||||||
|
. '</d:href></c:calendar-home-set>'
|
||||||
|
. '</d:prop></d:propstat></d:response>');
|
||||||
|
}
|
||||||
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" xmlns:x="http://apple.com/ns/ical/"', $parts);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($depth === '1') {
|
if ($depth === '1') {
|
||||||
// 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉"
|
// 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉"
|
||||||
$rows = CalDav::rows($cal, $familyId, $me);
|
$rows = CalDav::resources($cal, $familyId, $me);
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$rid = (int)($r['id'] ?? 0);
|
// ⚠️ 资源名走 CalDav::segmentOf(与 REPORT / GET 同一处算)——
|
||||||
$uid = (string)($r['uid'] ?? '');
|
// 三处各自拼字符串的话,客户端 PROPFIND 记下的 href
|
||||||
// 派生本(观影 / 旅程)没有 calendar_events.id,用 uid 段做资源名
|
// 与它 REPORT 时用的 href 对不上,就会反复重拉。
|
||||||
$seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid);
|
$seg = CalDav::segmentOf($r);
|
||||||
if ($seg === '') {
|
if ($seg === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$etag = '"' . md5($uid . '|' . (string)($r['starts_at'] ?? '') . '|' . (string)($r['updated_at'] ?? '')) . '"';
|
$etag = CalDav::resourceOf($r, (string)$cal['name'])['etag'];
|
||||||
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
$parts[] = '<d:response><d:href>'
|
||||||
|
. CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
|
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
|
||||||
. '<d:resourcetype/>'
|
. '<d:resourcetype/>'
|
||||||
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
|
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
|
||||||
. '</d:prop></d:propstat></d:response>';
|
. '</d:prop></d:propstat></d:response>';
|
||||||
}
|
}
|
||||||
CalDav::multis();
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
|
||||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
|
||||||
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
|
||||||
. implode('', $parts) . '</d:multistatus>';
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Depth 0:这一天历本自身的属性
|
// Depth 0:这一天历本自身的属性
|
||||||
CalDav::multis();
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
|
||||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
'<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
|
||||||
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
|
||||||
. '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
|
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
|
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
|
||||||
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
||||||
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
|
. $readOnlyPriv
|
||||||
. '</d:prop></d:propstat></d:response></d:multistatus>';
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 6. GET / HEAD:整本导出成 ics(不少客户端就走这一条)----
|
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
|
||||||
if ($method === 'GET' || $method === 'HEAD') {
|
if ($method === 'GET' || $method === 'HEAD') {
|
||||||
if (!$cal) {
|
if (!$cal) {
|
||||||
dav_fail(404, '没有这本日历。');
|
dav_fail(404, '没有这本日历。');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6a. 单条资源(/dav/3.ics/12.ics)—— 客户端增量同步走的就是这条。
|
||||||
|
// 🔴 v31 修的真 bug:原来**没有**这个分支,于是 `/dav/1.ics/movie-100_familybook.ics`
|
||||||
|
// 落进了下面的"整本导出",一次回 2981 条 VEVENT(634 KB)。
|
||||||
|
// 客户端拿一条资源却收到整本 → 要么把整本当成"这一条"的正文(爆炸式重复入库),
|
||||||
|
// 要么因为 UID 不匹配而判定"资源不存在"。**两种都是"同步不出数据"**。
|
||||||
|
if ($evSeg !== '') {
|
||||||
|
$one = CalDav::oneResource($cal, $familyId, $me, $evSeg);
|
||||||
|
if (!$one) {
|
||||||
|
dav_fail(404, '没有这条日程。');
|
||||||
|
}
|
||||||
|
$res = CalDav::resourceOf($one, (string)$cal['name']);
|
||||||
|
$etag = $res['etag'];
|
||||||
|
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
||||||
|
http_response_code(304);
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code(200);
|
||||||
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
if ($method === 'GET') {
|
||||||
|
echo $res['ics'];
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6b. 整本导出(老客户端走这条把整本当一段 ics 读)。
|
||||||
|
// ⚠️ 用 resources()(按 UID 去重)而不是 rows():整本里同一条日程出现 2399 次,
|
||||||
|
// 客户端会把它当成 2399 个不同事件灌进本地库。
|
||||||
$ics = CalDav::ics($cal, $familyId, $me);
|
$ics = CalDav::ics($cal, $familyId, $me);
|
||||||
$etag = '"' . md5($ics) . '"';
|
$etag = '"' . md5($ics) . '"';
|
||||||
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
||||||
@@ -186,21 +346,37 @@ if ($method === 'GET' || $method === 'HEAD') {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 7. REPORT:calendar-query(按时间范围) / calendar-multiget(按 href 取)----
|
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
|
||||||
if ($method === 'REPORT') {
|
if ($method === 'REPORT') {
|
||||||
if (!$cal) {
|
if (!$cal) {
|
||||||
dav_fail(404, '没有这本日历。');
|
dav_fail(404, '没有这本日历。');
|
||||||
}
|
}
|
||||||
$bodyRaw = (string)file_get_contents('php://input');
|
$bodyRaw = (string)file_get_contents('php://input');
|
||||||
|
|
||||||
// calendar-multiget:客户端点明要哪几条(我们按整本回,客户端自己挑)
|
// calendar-multiget:客户端点明要哪几条。
|
||||||
|
// 🔴 v31:这里**改成真的按 href 过滤**(原来"整本回、客户端自己挑")。
|
||||||
|
// 原因:现在 href 是确定性的(`/dav/<book>/<seg>.ics`,seg 由 segmentOf 统一给),
|
||||||
|
// 按 href 取回来的资源名 → 直接反查,不再需要猜"绝对 URL / 前缀 / 编码"。
|
||||||
|
// 整本回的问题在派生本上尤其严重(2981 条里 2785 条是重复的同一 href)。
|
||||||
$wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false;
|
$wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false;
|
||||||
|
$wantHrefs = [];
|
||||||
|
if ($wantsMulti) {
|
||||||
|
// 只认 href 的最后一段(前面的域名/前缀怎么变都不影响)
|
||||||
|
if (preg_match_all('#<[^>]*href[^>]*>([^<]+)</[^>]*href>#i', $bodyRaw, $mm)) {
|
||||||
|
foreach ($mm[1] as $h) {
|
||||||
|
$h = trim(html_entity_decode($h, ENT_QUOTES | ENT_XML1, 'UTF-8'));
|
||||||
|
$seg = preg_replace('/\.ics$/', '', (string)basename(rtrim($h, '/')));
|
||||||
|
if ($seg !== '') {
|
||||||
|
$wantHrefs[$seg] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// calendar-query 的时间范围(有就按范围裁;没有就整本)
|
// calendar-query 的时间范围(有就按范围裁;没有就整本)
|
||||||
$from = '';
|
$from = '';
|
||||||
$to = '';
|
$to = '';
|
||||||
if (preg_match('#<c:time-range[^>]*start="([^"]+)"#i', $bodyRaw, $m)) {
|
if (preg_match('#<c:time-range[^>]*start="([^"]+)"#i', $bodyRaw, $m)) {
|
||||||
$from = substr($m[1], 0, 8); // 20260101T000000Z → 20260101
|
|
||||||
$from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
|
$from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
|
||||||
}
|
}
|
||||||
if (preg_match('#<c:time-range[^>]*end="([^"]+)"#i', $bodyRaw, $m)) {
|
if (preg_match('#<c:time-range[^>]*end="([^"]+)"#i', $bodyRaw, $m)) {
|
||||||
@@ -208,52 +384,51 @@ if ($method === 'REPORT') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 单条资源请求(/dav/3.ics/12.ics)
|
// 单条资源请求(/dav/3.ics/12.ics)
|
||||||
if ($evId > 0) {
|
if ($evSeg !== '') {
|
||||||
$one = CalDav::oneEvent($bookId, $evId, [$familyId, $me]);
|
$one = CalDav::oneResource($cal, $familyId, $me, $evSeg);
|
||||||
if (!$one) {
|
if (!$one) {
|
||||||
dav_fail(404, '没有这条日程。');
|
dav_fail(404, '没有这条日程。');
|
||||||
}
|
}
|
||||||
$evIcs = \App\Ics::export([$one], (string)$cal['name']);
|
$res = CalDav::resourceOf($one, (string)$cal['name']);
|
||||||
CalDav::multis();
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
|
||||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
'<d:response><d:href>'
|
||||||
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
. CalDav::x(CalDav::bookHref($bookId) . $evSeg . '.ics') . '</d:href>'
|
||||||
. '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $evId . '.ics') . '</d:href>'
|
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:getetag>' . CalDav::x('"' . md5($evIcs) . '"') . '</d:getetag>'
|
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
|
||||||
. '<c:calendar-data>' . CalDav::x($evIcs) . '</c:calendar-data>'
|
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
|
||||||
. '</d:prop></d:propstat></d:response></d:multistatus>';
|
. '</d:prop></d:propstat></d:response>',
|
||||||
|
]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = CalDav::rows($cal, $familyId, $me);
|
$rows = CalDav::resources($cal, $familyId, $me);
|
||||||
$parts = [];
|
$parts = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
|
$seg = CalDav::segmentOf($r);
|
||||||
|
if ($seg === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($wantsMulti && !isset($wantHrefs[$seg])) {
|
||||||
|
continue; // multiget:只要点名的
|
||||||
|
}
|
||||||
$day = substr((string)($r['starts_at'] ?? ''), 0, 10);
|
$day = substr((string)($r['starts_at'] ?? ''), 0, 10);
|
||||||
|
if (!$wantsMulti) {
|
||||||
if ($from !== '' && $day !== '' && $day < $from) {
|
if ($from !== '' && $day !== '' && $day < $from) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($to !== '' && $day !== '' && $day > $to) {
|
if ($to !== '' && $day !== '' && $day > $to) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$rid = (int)($r['id'] ?? 0);
|
|
||||||
$uid = (string)($r['uid'] ?? '');
|
|
||||||
$seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid);
|
|
||||||
if ($seg === '') {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
$evIcs = \App\Ics::export([$r], (string)$cal['name']);
|
$res = CalDav::resourceOf($r, (string)$cal['name']);
|
||||||
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
$parts[] = '<d:response><d:href>'
|
||||||
|
. CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
||||||
. '<d:propstat><d:prop>'
|
. '<d:propstat><d:prop>'
|
||||||
. '<d:getetag>' . CalDav::x('"' . md5($evIcs) . '"') . '</d:getetag>'
|
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
|
||||||
. '<c:calendar-data>' . CalDav::x($evIcs) . '</c:calendar-data>'
|
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
|
||||||
. '</d:prop></d:propstat></d:response>';
|
. '</d:prop></d:propstat></d:response>';
|
||||||
// calendar-multiget 时客户端自己按 href 挑;我们整本给,列表不长(家庭场景)
|
|
||||||
unset($wantsMulti);
|
|
||||||
}
|
}
|
||||||
CalDav::multis();
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
|
||||||
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
|
||||||
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
|
||||||
. implode('', $parts) . '</d:multistatus>';
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+16
@@ -15,6 +15,22 @@
|
|||||||
|
|
||||||
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
|
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
|
||||||
|
|
||||||
|
// 🔴 v31:`/.well-known/caldav` 与 `/.well-known/carddav`(RFC 6764)。
|
||||||
|
// 标准客户端(DAVx⁵ / iOS / Evolution)添加账号时**第一句**就试这里 ——
|
||||||
|
// 原来没有这条路由,于是落到 index.php、被 302 去登录页,
|
||||||
|
// 客户端判定"域名下没有 DAV 服务",连口令框都不弹。
|
||||||
|
// 症状正是用户报的「同步不出任何数据」(账号根本建不起来)。
|
||||||
|
// ⚠️ 必须排在下面 dav/carddav 通配两条**之前**?不需要(前缀互不包含),
|
||||||
|
// 但**必须**排在后面"真实文件"判断之前 —— 这个目录线上并不存在。
|
||||||
|
if ($path === '/.well-known/caldav') {
|
||||||
|
require __DIR__ . '/dav.php';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if ($path === '/.well-known/carddav') {
|
||||||
|
require __DIR__ . '/carddav.php';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// CalDAV:/dav/ 及其子路径(/dav/、/dav/3.ics/、/dav/3.ics/12.ics)
|
// CalDAV:/dav/ 及其子路径(/dav/、/dav/3.ics/、/dav/3.ics/12.ics)
|
||||||
if (preg_match('#^/dav(/.*)?$#', $path)) {
|
if (preg_match('#^/dav(/.*)?$#', $path)) {
|
||||||
require __DIR__ . '/dav.php';
|
require __DIR__ . '/dav.php';
|
||||||
|
|||||||
@@ -84,6 +84,22 @@ $rosterCount = $rosterCount ?? 0;
|
|||||||
同步的是「我的名册」里那 <b><?= (int)$davRosterN ?></b> 个人 —— 和你名册页上看到的一致。
|
同步的是「我的名册」里那 <b><?= (int)$davRosterN ?></b> 个人 —— 和你名册页上看到的一致。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<?php /* 🔴 v31:客户端(DAVx⁵ / iOS)添加账号时有两条路:
|
||||||
|
① 只填**服务器地址**(不带 /dav/、/carddav/)让它自己发现 —— 走
|
||||||
|
`/.well-known/caldav`;② 直接填完整路径(上面两个框)。
|
||||||
|
以前只支持②,而很多客户端的引导界面**只让填域名**,
|
||||||
|
于是"填完点保存就失败",用户看到的就是「同步不出任何数据」。
|
||||||
|
现在两条路都通了(见 dav.php / carddav.php 的发现链实现),
|
||||||
|
这里把①也写出来,免得用户还得自己猜。 */ ?>
|
||||||
|
<div class="field full">
|
||||||
|
<label>或者只填服务器地址(让客户端自动发现)</label>
|
||||||
|
<input class="input mono" readonly value="<?= e((string)$davBase) ?>">
|
||||||
|
<span class="tip">
|
||||||
|
填这个也行 —— 客户端会自动向
|
||||||
|
<span class="mono"><?= e((string)$davBase . '/.well-known/caldav') ?></span>
|
||||||
|
问在哪里(RFC 6764)。iOS 的「日历账户 → 添加 CalDAV 账户」就推荐填这一种。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>用户名</label>
|
<label>用户名</label>
|
||||||
<input class="input mono" readonly value="<?= e((string)$person['name']) ?>">
|
<input class="input mono" readonly value="<?= e((string)$person['name']) ?>">
|
||||||
|
|||||||
Reference in New Issue
Block a user