v45 第 45 批:日程「重复到」换最新控件 + CalDAV 双向同步

用户 2026-10-12 两条:

1. 新建日程里「重复到」的那个日历选择框,改成最新控件。
   - templates/calendar/_event_fields.php:裸 <input type=date> 换成
     .dt-input.dt-antd-single[data-antd-date=1](AntD 纯日期 DatePicker),
     复用既有 mountAntdDate,不新写挂载逻辑。
   - 值载体仍是 name=rep_until 的原生框(包在 .dt-native 里),
     服务端契约与 Repeat::build($_POST) 一字不改。

2. CalDAV 从「只读」改成「双向同步」。
   用户给了一台可用的参照服务器(群晖 DAViCal)要求「完全按参照返回」;
   抓参照 + 复刻 Thunderbird / SyncCalendar 的真实请求序列后确认:
   参照是可写的,而只读会让两个客户端都判「不可用」。
   - dav.php:放行 PUT(新建 201+ETag / 更新 204+ETag / If-Match 不符 412)、
     DELETE(204)、MKCALENDAR(201+Location),仍逐本判权;
     OPTIONS 的 Allow 与 DAV 头照参照补齐(DAV 头两行,注意 PHP header 追加);
     current-user-privilege-set 按实际可写性给,并排除系统派生本(农历等)。
   - app/CalDav.php:资源名统一用 UID(原自定义本用 id、派生本用 uid 两种混着,
     一开写就表现为「客户端 PUT 到 UID 名、服务端 href 是 id 名 ⇒ 写进去读不回」);
     补 owner / getctag / getcontenttype / calendar-description;新增 ctagSeedOf。
   - app/Ics.php:修一个一直存在的真 bug —— 导出 RRULE 漏了 `RRULE:` 前缀
     (库里存的是去前缀的值),导致所有导出/同步出去的重复日程都变成单次。
   - templates/home.php:首页红线补 data-now-min(给探针做零容差判据用)。

验证:本地 e2e 1510/0、TB 探针 35/0、DAV 诊断 28/0、
v43 真机 33/0 + 反向 9/0、v44 真机 41/0 + API 63/0 + 反向 19/0、
v45 重复到 10/0 + 反向 2/0、v45 DAV 双向 24/0 + 反向 5/0;
全流水线 PUSH_RC=0(含部署静态断言第 45 批形态)。
This commit is contained in:
2026-10-12 21:40:53 +08:00
parent 9f41e1622e
commit 4ccbcc3360
5 changed files with 384 additions and 28 deletions
+288 -15
View File
@@ -16,8 +16,14 @@
* index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西,
* 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。
*
* 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。
* 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。
* v24~v44 是**只读**实现(RFC 4791 的子集):写方法一律 403。
* 🔴 v45(用户 2026-10-12 第 2 条):**改成双向同步** —— 用户给了一个可用的
* 参照服务器(群晖 DAViCal)要求「完全按照这个来返回就行」。抓参照 + 复刻
* Thunderbird / SyncCalendar 的真实请求序列后确认:参照是**可写**的,
* 而只读会带来两个真问题(见下面 §7b 的长注释)。现在支持:
* PUT(新建/更新日程,按 UID 落库,带 ETag 与 If-Match 乐观锁)
* DELETE(删日程)· MKCALENDAR(建普通本)
* ⚠️ 仍**不支持** PROPPATCH / POST / PATCH,也**不声称** ACL —— 那些明确拒。
*
* ---------------------------------------------------------------------------
* 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。
@@ -50,6 +56,8 @@ require __DIR__ . '/app/bootstrap.php';
use App\Auth;
use App\CalDav;
use App\Calendar;
use App\Database;
use App\Ics;
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
function dav_fail(int $code, string $msg): void
@@ -199,16 +207,44 @@ $isRoot = ($path === '/' || $path === '');
$isPrincipal = ($norm === CalDav::PRINCIPAL_PATH);
$isHome = ($norm === CalDav::HOME_PATH);
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。');
// ---- 2. 写方法:**逐本判权**(v45 起支持双向同步)----
//
// 🔴🔴 v45(用户 2026-10-12 第 2 条):用户给了一个可用的参照服务器
// (群晖 DAViCal)要求「完全按照这个来返回就行」。抓参照 + 复刻 SyncCalendar
// 客户端的真实请求序列后,确认真根因是:
// **参照是双向可写的,而家书对写方法一律回 403、权限也只声明 read。**
// 后果分两层:
// ① SyncCalendar 用 `PUT .../synccalendar-probe.ics` **探测可写性**(见它
// DavClient.probeWritable)——收到 403 就把整本标成「只读」;
// ② Thunderbird 更直接:它是**双向同步**客户端,PUT/DELETE 被拒就报错,
// 且日历显示为只读,「在 TB 里改日程」完全不可用。
// ⇒ 本轮按参照实现 PUT / DELETE(MKCALENDAR 也一并给)。
//
// ⚠️ 但**不能**无条件放开:家书里日历本有「只读共享」这回事(别人共享给你的本
// 可能只有 read 权限)。所以先按本子判权,**能写才进写分支**,不能写仍 403。
// ⚠️ PROPPATCH / POST / PATCH 仍不支持(参照的 Allow 里也没有 PROPPATCH;
// 它支持 ACL/MKCALENDAR,我们不需要 ACL)—— 明确拒比"写一半"好。
if ($method === 'PROPPATCH' || $method === 'POST' || $method === 'PATCH') {
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
dav_fail(405, '家书的 DAV 不支持这个写方法(支持 PUT / DELETE)。');
}
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
// 🔴 v45:Allow / DAV 头**照参照 DAViCal 补齐** ——
// · Allow 里要有 PUT / DELETE / MKCALENDAR(否则客户端判定"不可写");
// · DAV 头补 `calendar-schedule` 与第二行 `extended-mkcol, ...`(参照有两行)。
// 参照:`DAV: 1, 2, 3, access-control, calendar-access, calendar-schedule`
// `DAV: extended-mkcol, bind, addressbook, calendar-auto-schedule`
// ⚠️ 我们**不**声称 access-control(没实现 ACL),也不声称 addressbook
// 这一层是 CardDAV 的(本文件是 CalDAV 入口;CardDAV 那边照旧)。
if ($method === 'OPTIONS') {
http_response_code(200);
header('DAV: 1, 2, 3, calendar-access');
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
// ⚠️ 第二个参数 `false` = **追加而不是替换**。参照 DAViCal 发的是**两行** DAV 头;
// PHP 的 `header()` 同名默认**覆盖** ⇒ 不给 false 的话第二行会把第一行顶掉,
// `calendar-access` 就没了(本探针 A3 抓到的正是这个)。
header('DAV: 1, 2, 3, calendar-access, calendar-schedule', false);
header('DAV: extended-mkcol', false);
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
header('MS-Author-Via: DAV');
exit;
}
@@ -231,9 +267,42 @@ $dispName = static function (array $c) use ($me): string {
return $nm;
};
/** 一个 `d:propstat` 里塞当前用户的读权限(只读实现,所以恒为 read) */
$readOnlyPriv = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
/** 一个 `d:propstat` 里的权限集。
*
* 🔴 v45(用户第 2 条「完全按参照返回」):**按实际可写性**给 ——
* 参照 DAViCal 对可写本给的是 `<privilege><all/>` + 一整串 write 相关权限;
* 原来我们**恒给 read**,于是 SyncCalendar 判「只读」(见它
* `entry.writable = /write/i.test(privSet)`),Thunderbird 也不让改。
* ⇒ 能写就声明 `<write/>`(`write-content` / `bind` / `unbind` 一并给,
* 它们正是"能建/能删资源"的语义,参照也是这么给的);
* 只读则只给 `<read/>`。 */
$privOf = static function (bool $writable): string {
if ($writable) {
return '<d:current-user-privilege-set>'
. '<d:privilege><d:read/></d:privilege>'
. '<d:privilege><d:write/></d:privilege>'
. '<d:privilege><d:write-properties/></d:privilege>'
. '<d:privilege><d:write-content/></d:privilege>'
. '<d:privilege><d:bind/></d:privilege>'
. '<d:privilege><d:unbind/></d:privilege>'
. '</d:current-user-privilege-set>';
}
return '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
};
/** 某个本子我能不能写(照 Calendar::canWrite 的真规则)。
*
* ⚠️ 还要**排除系统派生本**(农历/节假日/传统节日/二十四节气)——
* 那些本我虽然是 owner(`canWrite` 恒真),但它们是系统生成的、不能往里写。
* 漏这一条会让权限集对派生本声明 `write` ⇒ 客户端显示"可编辑"、
* 用户改完才在 PUT 那一步吃 403(体验很差,也是本探针 B2 抓到的)。 */
$canWriteCal = static function ($cal) use ($me): bool {
return $cal !== null
&& Calendar::canWrite($cal, $me)
&& !Calendar::isReadonlyKind((string)($cal['kind'] ?? ''));
};
// 兼容旧调用点:home / principal 这类"容器"不是具体某一本,按"至少有一本可写"给
$readOnlyPriv = $privOf(false);
/**
* 输出一段 multistatus(207 + XML 头)。
@@ -339,14 +408,30 @@ if ($method === 'PROPFIND') {
foreach (CalDav::books($familyId, $me) as $c) {
$href = CalDav::hrefOf($c);
$w = $canWriteCal($c); // 🔴 v45:按本子给真权限(含派生本只读)
/* 🔴 v45:**照参照补齐属性**(用户「完全按参照返回」)——
参照 DAViCal 在日历本上给了:resourcetype / displayname /
getcontenttype / sync-token / getctag / supported-calendar-component-set /
current-user-privilege-set / owner,并把没实现的属性单独放一个
404 propstat(getctag / calendar-color / description / timezone)。
⚠️ 我们**照给** owner 与 getctag(客户端用 getctag 判"这本变没变"),
但不给 sync-token(我们没实现 sync-collection —— 谎报会让
客户端走 sync-collection 然后拿到 501)。
⚠️ calendar-color 我们**有**(模块色),所以放在 200 那一组里,
不像参照那样 404 —— 有就给,是更好的实现。 */
$ctag = '"' . md5($href . ':' . CalDav::ctagSeedOf($c)) . '"';
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<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>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. $readOnlyPriv
. '<cs:getctag>' . CalDav::x($ctag) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ctag) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($w)
. '</d:prop></d:propstat></d:response>';
}
// 🔴 v33:**始终**先输出 home 集合自身。
@@ -379,7 +464,9 @@ if ($method === 'PROPFIND') {
. '</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);
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', $parts);
exit;
}
@@ -409,13 +496,25 @@ if ($method === 'PROPFIND') {
}
// Depth 0:这一天历本自身的属性
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
// 🔴 v45:权限按**这本实际可写性**给(原来恒 read);并按参照补
// getcontenttype / getctag / owner / calendar-description / 颜色。
$cw = $canWriteCal($cal);
$ct = '"' . md5(CalDav::bookHref($bookId) . ':' . CalDav::ctagSeedOf($cal)) . '"';
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
. 'xmlns:x="http://apple.com/ns/ical/" '
. 'xmlns:cs="http://calendarserver.org/ns/"', [
'<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
. '<d:getcontenttype>httpd/unix-directory</d:getcontenttype>'
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$cal['name']) . '</c:calendar-description>'
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$cal['color']) . '</x:calendar-color>'
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
. $readOnlyPriv
. '<cs:getctag>' . CalDav::x($ct) . '</cs:getctag>'
. '<d:getctag>' . CalDav::x($ct) . '</d:getctag>'
. '<d:owner><d:href>' . CalDav::x(CalDav::PRINCIPAL_PATH) . '</d:href></d:owner>'
. $privOf($cw)
. '</d:prop></d:propstat></d:response>',
]);
exit;
@@ -561,6 +660,180 @@ if ($method === 'REPORT') {
exit;
}
// ---- 7b. PUT:新建 / 更新一条日程(RFC 4791 §5.3.2)----
//
// 🔴 v45(用户第 2 条「完全按参照返回」):这是「双向同步」的核心。
// Thunderbird / SyncCalendar 都靠 PUT 把本地的改动推上来。
//
// 契约(照参照与 RFC):
// · 请求体 = 一份完整 VCALENDAR(含一个 VEVENT,带 UID);
// · 资源名由客户端决定(`<UID>.ics` 或任意名)—— 我们**按 UID 去重落库**,
// 不依赖文件名,这样客户端改名/换名也不会重复建;
// · 新建成功 → **201 Created** + `ETag`(客户端记下来做 If-Match);
// · 更新成功 → **204 No Content** + 新 `ETag`(RFC:更新不带正文);
// · 带 `If-Match` 且与当前 ETag 不符 → **412 Precondition Failed**
// (这是防"两个设备同时改"的乐观锁,客户端会重拉后再推);
// · 目标本不可写 → **403**(只读共享的本子不能写,与网页端同一条规则);
// · 正文不是合法日历 → **415**(参照与我们都不接受非 text/calendar)。
//
// ⚠️ 落库一律走 `Calendar::importEvent()` —— 它与网页「导入 .ics」是**同一个**
// 函数,所以"手机上建的"与"网页里导入的"结果完全一致(UID 去重、重复规则、
// 提醒、全天与否都由它统一处理)。**绝不在 DAV 里另写一套 INSERT**。
if ($method === 'PUT') {
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
if (!Calendar::canWrite($cal, $me)) {
dav_fail(403, '这本日历是只读共享,你不能往里写。');
}
// 派生本(观影/旅程/农历/节假日…)是**系统生成的只读本**,不能被客户端写。
if (Calendar::isReadonlyKind((string)$cal['kind'])) {
dav_fail(403, '这本是系统生成的只读日历,不能写入。');
}
$bodyRaw = (string)file_get_contents('php://input');
// 只收 text/calendar(客户端本来就会带;不带也宽容放行,但空正文一定拒)
$ctype = (string)($_SERVER['CONTENT_TYPE'] ?? '');
if ($ctype !== '' && stripos($ctype, 'calendar') === false && stripos($ctype, 'text/plain') === false) {
dav_fail(415, '只接受 text/calendar 正文。');
}
if (trim($bodyRaw) === '') {
dav_fail(400, '正文是空的。');
}
$list = Ics::parse($bodyRaw);
if (!$list) {
dav_fail(415, '正文里没有找到 VEVENT。');
}
$v = $list[0]; // 一个资源 = 一个 VEVENT(重复系列整份在一个 VEVENT 里)
if (empty($v['starts_at'])) {
dav_fail(422, 'VEVENT 缺 DTSTART。');
}
if (trim((string)($v['title'] ?? '')) === '') {
$v['title'] = '(无标题日程)';
}
$uidIn = (string)($v['uid'] ?? '');
// 是不是"改已有的":**优先按资源名(uid)找**(客户端 PUT 到 <uid>.ics),
// 再按正文里的 UID 找,最后兼容老式数字-id 资源名。
// ⚠️ 三种都要有,缺一种就会出现"客户端改完变成两条"或"改了但旧的那条还在"。
$existingId = 0;
if ($evSeg !== '') {
$bySeg = CalDav::oneResource($cal, $familyId, $me, $evSeg);
if ($bySeg && (int)($bySeg['id'] ?? 0) > 0) {
$existingId = (int)$bySeg['id'];
}
}
if ($existingId === 0 && $uidIn !== '') {
$st = Database::get()->prepare(
'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? LIMIT 1'
);
$st->execute([(int)$cal['id'], $uidIn]);
$existingId = (int)$st->fetchColumn();
}
// 乐观锁:If-Match 给了就必须与当前 ETag 相符(不符 = 别人改过了 → 412)
if ($existingId > 0) {
$cur = CalDav::oneEvent((int)$cal['id'], $existingId, [$familyId, $me]);
$curEt = $cur ? CalDav::resourceOf($cur, (string)$cal['name'])['etag'] : '';
$ifMatch = trim((string)($_SERVER['HTTP_IF_MATCH'] ?? ''));
if ($ifMatch !== '' && $ifMatch !== '*' && $curEt !== '' && $ifMatch !== $curEt) {
header('ETag: ' . $curEt);
dav_fail(412, '这条日程已经被改过了,请重新拉取后再试。');
}
// 归属校验:mime 前面已用 `Calendar::canWrite($cal, $me)` 判过"这本能不能写",
// 而 `$existingId` 这条日程必然是**这本里**的(上面按 calendar_id 查的),
// 所以无需再判一次 —— 只读共享的本在函数开头就被 403 拦掉了。
Calendar::updateEvent($existingId, $v);
// 保 UID 不被清空(老客户端可能不带 UID 就改:那就沿用原来的)
if ($uidIn === '') {
Database::get()->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?')
->execute([CalDav::segmentOf($cur ?: ['id' => $existingId]), $existingId]);
}
$after = CalDav::oneEvent((int)$cal['id'], $existingId, [$familyId, $me]);
$et = CalDav::resourceOf($after ?: $cur, (string)$cal['name'])['etag'];
http_response_code(204);
header('ETag: ' . $et);
exit;
}
// 新建
$newId = Calendar::importEvent((int)$cal['id'], $v, $me) === 'added'
? (int)Database::get()->lastInsertId() : 0;
$row = $newId > 0 ? CalDav::oneEvent((int)$cal['id'], $newId, [$familyId, $me]) : null;
if (!$row && $uidIn !== '') {
$st = Database::get()->prepare(
'SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ? LIMIT 1');
$st->execute([(int)$cal['id'], $uidIn]);
$rid = (int)$st->fetchColumn();
$row = $rid > 0 ? CalDav::oneEvent((int)$cal['id'], $rid, [$familyId, $me]) : null;
}
$et = $row ? CalDav::resourceOf($row, (string)$cal['name'])['etag'] : '';
http_response_code(201);
if ($et !== '') {
header('ETag: ' . $et);
}
if ($row) {
header('Location: ' . CalDav::bookHref($bookId) . CalDav::segmentOf($row) . '.ics');
}
exit;
}
// ---- 7c. DELETE:删一条日程 ----
//
// 照参照与 RFC:成功 **204 No Content**;不存在 **404**;本不可写 **403**。
// ⚠️ 删除的是**整条资源**(含重复系列的全部实例)—— CalDAV 里重复系列就是
// 一个资源,客户端要"只删某一次"会先 PUT 改 RRULE + EXDATE(那是另一条路径,
// 我们不能再替他做决定)。这样语义最简单,也与参照一致。
if ($method === 'DELETE') {
if (!$cal) {
dav_fail(404, '没有这本日历。');
}
if (!Calendar::canWrite($cal, $me) || Calendar::isReadonlyKind((string)$cal['kind'])) {
dav_fail(403, '这本日历不能写入/删除。');
}
if ($evSeg === '') {
dav_fail(404, '要删哪条?没给出资源名。');
}
// 🔴 v45:资源名现在**是 UID**(见 CalDav::segmentOf)—— 用 oneResource 解析,
// 它同时兼容老的数字-id 资源名。
// ⚠️ 但**只能删"真的有实体"的那种**(派生条目是算出来的、删不掉),
// 所以解析出来的行必须带正的 calendar_events.id。
$one = CalDav::oneResource($cal, $familyId, $me, $evSeg);
if (!$one || (int)($one['id'] ?? 0) <= 0) {
dav_fail(404, '没有这条日程(或它是系统派生的只读条目,删不了)。');
}
Calendar::deleteEvent((int)$one['id']);
http_response_code(204);
exit;
}
// ---- 7d. MKCALENDAR:新建一个日历本(参照支持;Thunderbird 会用它)----
//
// ⚠️ 我们**只允许建普通本**(kind=custom)。理由:观影/旅程/农历那些是
// 系统派生的只读本,客户端建不出来也不该建。
if ($method === 'MKCALENDAR') {
$bodyRaw = (string)file_get_contents('php://input');
$name = '';
if (preg_match('#<[^>]*displayname[^>]*>([^<]*)<#i', $bodyRaw, $m)) {
$name = trim(html_entity_decode($m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
}
if ($name === '') {
$name = '新日历本';
}
$color = '#4A7C82';
if (preg_match('#<[^>]*calendar-color[^>]*>([^<]*)<#i', $bodyRaw, $m)) {
$c = trim($m[1]);
if (preg_match('/^#[0-9A-Fa-f]{6}$/', substr($c, 0, 7))) {
$color = substr($c, 0, 7);
}
}
$newCal = Calendar::create($familyId, $me, mb_substr($name, 0, 60, 'UTF-8'), $color, 'custom');
http_response_code(201);
header('Location: ' . CalDav::bookHref($newCal));
exit;
}
// ---- 8. 其它方法一律 405(并告知支持哪些)----
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT, PUT, DELETE, MKCALENDAR');
dav_fail(405, '不支持这个请求方法。');