2026-10-03 08:18:05 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 家书 FamilyBook · CalDAV 入口(v24 用户第 7 条后半)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 「把我的日历本,通过 CalDAV 协议分享给别人」—— 别人在他们的
|
|
|
|
|
|
* iOS 日历 / 安卓 DAVx⁵ / Thunderbird / 群晖日历里填上这个地址就能订阅到。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 地址:https://<站点>/dav/
|
|
|
|
|
|
* 账号:手机号或姓名(同登录)
|
2026-10-06 17:33:48 +08:00
|
|
|
|
* 口令:**DAV 专用口令**(在「个人设置 → DAV 同步」页生成,不是登录口令)
|
|
|
|
|
|
* ⚠️ 这把口令**同时**给 CalDAV 与 CardDAV 用(用户 2026-10-06 第 22 批),
|
|
|
|
|
|
* 所以手机上填一次就够,不必在日历与通讯录里各配一遍。
|
2026-10-03 08:18:05 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ 为什么单独一个文件而不是塞进 index.php:
|
|
|
|
|
|
* CalDAV 用的是 HTTP 方法名(PROPFIND / REPORT / OPTIONS / PUT),
|
|
|
|
|
|
* index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西,
|
|
|
|
|
|
* 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。
|
|
|
|
|
|
* 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。
|
2026-10-08 20:05:01 +08:00
|
|
|
|
*
|
|
|
|
|
|
* ---------------------------------------------------------------------------
|
|
|
|
|
|
* 🔴 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 每轮都变 → 增量同步永远不收敛。
|
2026-10-03 08:18:05 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
require __DIR__ . '/app/bootstrap.php';
|
|
|
|
|
|
|
|
|
|
|
|
use App\Auth;
|
|
|
|
|
|
use App\CalDav;
|
|
|
|
|
|
use App\Calendar;
|
|
|
|
|
|
|
|
|
|
|
|
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
|
|
|
|
|
|
function dav_fail(int $code, string $msg): void
|
|
|
|
|
|
{
|
|
|
|
|
|
http_response_code($code);
|
|
|
|
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
|
|
|
|
echo $msg;
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 1. 鉴权:HTTP Basic(DAVx⁵ / iOS 都原生支持)----
|
|
|
|
|
|
$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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
|
|
|
|
|
|
$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') {
|
2026-10-11 18:35:03 +08:00
|
|
|
|
/* 🔴🔴 v42(用户第 10 条)核心修复之一:
|
|
|
|
|
|
这条路径原来**只回 301**(跳 `/dav/`)。匿名 GET 时这是对的;
|
|
|
|
|
|
但 Thunderbird 建账号时**常常是带鉴权直接 PROPFIND 这个地址**
|
|
|
|
|
|
(它把 `.well-known/caldav` 当成"DAV 服务的入口"来探,而不是只当跳转)。
|
|
|
|
|
|
对带鉴权的 PROPFIND 回 301 的后果:**TB 不跟这个跳转**
|
|
|
|
|
|
(它对"服务发现跳转"的跟随策略与浏览器不同),于是判定
|
|
|
|
|
|
"这里没有 CalDAV 服务" ⇒ 报「找不到日历」。
|
|
|
|
|
|
⇒ 修法:**按方法分流**
|
|
|
|
|
|
· 有鉴权凭据 + PROPFIND → 直接给出与 `/dav/` 一致的发现响应(207,
|
|
|
|
|
|
含 current-user-principal / calendar-home-set / 日历本清单),
|
|
|
|
|
|
客户端**一步到位**,不依赖它对 301 的跟随;
|
|
|
|
|
|
· 其余(匿名、GET、OPTIONS…)→ 仍是 301 到绝对 URL `/dav/`(RFC 6764 语义)。
|
|
|
|
|
|
⚠️ 免鉴权这条必须保住:匿名 OPTIONS/GET 不能 401(否则客户端以为不支持 DAV)。 */
|
|
|
|
|
|
$wkWantPropfind = (strtoupper($method) === 'PROPFIND');
|
|
|
|
|
|
$wkHasAuth = !empty($user) && $pass !== null && $pass !== '';
|
|
|
|
|
|
if ($wkWantPropfind && $wkHasAuth) {
|
|
|
|
|
|
$wkWho = Auth::davAuth($user, $pass);
|
|
|
|
|
|
if (!$wkWho) {
|
|
|
|
|
|
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
|
|
|
|
|
|
dav_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。');
|
|
|
|
|
|
}
|
|
|
|
|
|
$wkFamily = (int)$wkWho['family_id'];
|
|
|
|
|
|
$wkMe = (int)$wkWho['person_id'];
|
|
|
|
|
|
$wkParts = [
|
|
|
|
|
|
'<d:response><d:href>/dav/</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>'
|
|
|
|
|
|
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
|
|
|
|
|
|
. '</d:href></card:addressbook-home-set>'
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>',
|
|
|
|
|
|
];
|
|
|
|
|
|
foreach (CalDav::books($wkFamily, $wkMe) as $wkC) {
|
|
|
|
|
|
$wkParts[] = '<d:response><d:href>' . CalDav::x(CalDav::hrefOf($wkC)) . '</d:href>'
|
|
|
|
|
|
. '<d:propstat><d:prop>'
|
|
|
|
|
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
|
|
|
|
|
. '<d:displayname>' . CalDav::x('家书 · ' . (string)$wkC['name']) . '</d:displayname>'
|
|
|
|
|
|
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>';
|
|
|
|
|
|
}
|
|
|
|
|
|
CalDav::multis();
|
|
|
|
|
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
|
|
|
|
|
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
|
|
|
|
|
. 'xmlns:card="urn:ietf:params:xml:ns:carddav">'
|
|
|
|
|
|
. implode('', $wkParts) . '</d:multistatus>';
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
http_response_code(301);
|
2026-10-11 18:35:03 +08:00
|
|
|
|
/* 🔴🔴 v42(用户 2026-10-11 第 10 条:「caldav 还是不行,thunderbird 提示找不到日历」)
|
|
|
|
|
|
这里原来是**相对路径** `Location: /dav/`。
|
|
|
|
|
|
⚠️ RFC 7231 允许相对 Location(客户端按 RFC 3986 解析),
|
|
|
|
|
|
但 Thunderbird 的 CalDAV 服务发现在这一跳上**对相对 Location 不可靠** ——
|
|
|
|
|
|
`curl -L` 会跟、浏览器会跟,但 TB 自己不跟,
|
|
|
|
|
|
它把这个 301 当成"这里没有 CalDAV 服务" ⇒ 报「找不到日历」。
|
|
|
|
|
|
而我们的探针用 curl/urllib(都会自动跟)⇒ 全绿,
|
|
|
|
|
|
于是"探针全过、真客户端不行"这个组合一直复现不出来。
|
|
|
|
|
|
⇒ 改成**绝对 URL**(RFC 6764 §6.1 的示例也是绝对 URL)。
|
|
|
|
|
|
⚠️ 按**真实请求的 host**拼(不能写死域名):
|
|
|
|
|
|
本站既可能被 family.example.com 访问,也可能被 IP / 其它别名访问,
|
|
|
|
|
|
写死会把客户端带到别的域(跨域后鉴权头会丢)。 */
|
|
|
|
|
|
$wkHost = (string)($_SERVER['HTTP_HOST'] ?? '');
|
|
|
|
|
|
$wkProto = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
|
|
|
|
|
|
// 走代理(宝塔 nginx→apache)时协议可能丢,再从 X-Forwarded-Proto 补一次
|
|
|
|
|
|
if ($wkProto === 'http' && !empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
|
|
|
|
|
|
$wkProto = (string)$_SERVER['HTTP_X_FORWARDED_PROTO'];
|
|
|
|
|
|
}
|
|
|
|
|
|
$wkTarget = ($wkHost !== '') ? ($wkProto . '://' . $wkHost . '/dav/') : '/dav/';
|
|
|
|
|
|
header('Location: ' . $wkTarget);
|
2026-10-08 20:05:01 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-06 17:33:48 +08:00
|
|
|
|
$who = Auth::davAuth($user, $pass);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if (!$who) {
|
2026-10-06 17:33:48 +08:00
|
|
|
|
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
|
|
|
|
|
|
dav_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
$familyId = (int)$who['family_id'];
|
|
|
|
|
|
$me = (int)$who['person_id'];
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/** 统一的尾斜杠形式,供"发现链路径"比对(`/dav` 与 `/dav/` 视为同一个) */
|
|
|
|
|
|
$norm = '/' . trim($path, '/') . '/';
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$hit = CalDav::parsePath($path);
|
|
|
|
|
|
$bookId = (int)$hit['book'];
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$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);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
|
|
|
|
|
|
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
|
|
|
|
|
|
dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
|
|
|
|
|
|
if ($method === 'OPTIONS') {
|
|
|
|
|
|
http_response_code(200);
|
2026-10-08 20:05:01 +08:00
|
|
|
|
header('DAV: 1, 2, 3, calendar-access');
|
2026-10-03 08:18:05 +08:00
|
|
|
|
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
|
|
|
|
|
header('MS-Author-Via: DAV');
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 4. 找不到那本 → 404(不要回 200 空列表:客户端会以为"这本是空的")----
|
|
|
|
|
|
$cal = null;
|
|
|
|
|
|
if ($bookId > 0) {
|
|
|
|
|
|
$cal = Calendar::mineOne($familyId, $me, $bookId);
|
|
|
|
|
|
if (!$cal) {
|
|
|
|
|
|
dav_fail(404, '没有这本日历。');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 日历本的显示名:带上是"谁的本"(共享来的本子同名时能分清) */
|
|
|
|
|
|
$dispName = static function (array $c) use ($me): string {
|
|
|
|
|
|
$nm = (string)$c['name'];
|
|
|
|
|
|
if ((int)($c['owner_id'] ?? 0) !== $me && !empty($c['owner_name'])) {
|
|
|
|
|
|
$nm .= '(' . (string)$c['owner_name'] . ')';
|
|
|
|
|
|
}
|
|
|
|
|
|
return $nm;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
/** 一个 `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:发现链 + 清单 / 属性 / 资源列表 ----
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($method === 'PROPFIND') {
|
|
|
|
|
|
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal。
|
|
|
|
|
|
// 客户端拿它当"从这个域名开始找 DAV 服务"的起点。
|
2026-10-09 08:45:22 +08:00
|
|
|
|
// 🔴 v33:同时给出 **两套** home-set(calendar + addressbook)——
|
|
|
|
|
|
// 站点根是 CalDAV/CardDAV **共用的**发现起点,客户端只会看自己要的那个属性。
|
|
|
|
|
|
// ⚠️ 光靠这段还不够:线上 Apache 会把 `PROPFIND /` 交给 index.php
|
|
|
|
|
|
// (→ 302 登录页),所以 `.htaccess` 里必须补一条"DAV 方法才转发"的规则。
|
2026-10-08 20:05:01 +08:00
|
|
|
|
if ($isRoot) {
|
2026-10-09 08:45:22 +08:00
|
|
|
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
|
|
|
|
|
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
|
2026-10-08 20:05:01 +08:00
|
|
|
|
'<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>'
|
2026-10-09 08:45:22 +08:00
|
|
|
|
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
|
|
|
|
|
|
. '</d:href></card:addressbook-home-set>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. '</d:prop></d:propstat></d:response>',
|
|
|
|
|
|
]);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5b. principal:客户端拿 calendar-home-set 才能知道"去哪列日历本"。
|
|
|
|
|
|
// ⚠️ 还要回 `current-user-principal`(指向自己)—— 客户端**在 principal 上
|
|
|
|
|
|
// 也会再问一遍这个属性**(RFC 5397 里 principal 资源本就该有它)。
|
|
|
|
|
|
// 本批实测:不回的话,严格按 RFC 走的客户端会在这里判"拿不到 principal"
|
|
|
|
|
|
// 而中止建账号(探针的"照它报的地址再走一步"那两条正是抓这个的)。
|
|
|
|
|
|
if ($isPrincipal) {
|
2026-10-09 08:45:22 +08:00
|
|
|
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
|
|
|
|
|
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
|
2026-10-08 20:05:01 +08:00
|
|
|
|
'<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>'
|
2026-10-09 08:45:22 +08:00
|
|
|
|
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
|
|
|
|
|
|
. '</d:href></card:addressbook-home-set>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. $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 = [];
|
2026-10-11 10:12:11 +08:00
|
|
|
|
|
|
|
|
|
|
// 🔴🔴 2026-10-11(本批线上实测发现的协议隐患,修之):
|
|
|
|
|
|
// RFC 4918 规定 **Depth:0 只描述「请求的那个资源本身」**,
|
|
|
|
|
|
// 而这里原来无论 depth 都把**所有日历本**一起回。
|
|
|
|
|
|
// 线上实测的坏结果:`PROPFIND /dav/ Depth:0` 的**第一条 response
|
|
|
|
|
|
// 是 `/dav/1.ics/`**(日历本自己,**不含 current-user-principal**)
|
|
|
|
|
|
// —— 严格客户端(Thunderbird / iOS)只读"与请求 URL 匹配的那条"或
|
|
|
|
|
|
// 第一条,就可能判"拿不到 principal"而中止。
|
|
|
|
|
|
// ⇒ 按 depth 分流:
|
|
|
|
|
|
// · Depth:0 → **只回 `/dav/` 自身**(principal、home-set 都在这一条里,
|
|
|
|
|
|
// 所以"把 /dav/ 当入口的老客户端"仍然一次就能往下走);
|
|
|
|
|
|
// · Depth:1 → 自身 + 各日历本。
|
|
|
|
|
|
if ($depth !== '1') {
|
|
|
|
|
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav" '
|
|
|
|
|
|
. 'xmlns:card="urn:ietf:params:xml:ns:carddav"', [
|
|
|
|
|
|
'<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>'
|
|
|
|
|
|
. '<card:addressbook-home-set><d:href>' . CalDav::x(CalDav::CARD_HOME_PATH)
|
|
|
|
|
|
. '</d:href></card:addressbook-home-set>'
|
|
|
|
|
|
. $readOnlyPriv
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>',
|
|
|
|
|
|
]);
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 08:18:05 +08:00
|
|
|
|
foreach (CalDav::books($familyId, $me) as $c) {
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$href = CalDav::hrefOf($c);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
|
|
|
|
|
|
. '<d:propstat><d:prop>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
|
|
|
|
|
|
. '<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>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. $readOnlyPriv
|
2026-10-03 08:18:05 +08:00
|
|
|
|
. '</d:prop></d:propstat></d:response>';
|
|
|
|
|
|
}
|
2026-10-09 08:45:22 +08:00
|
|
|
|
// 🔴 v33:**始终**先输出 home 集合自身。
|
|
|
|
|
|
// RFC 4918 规定 PROPFIND 响应**必须包含被请求的资源本身**,Depth:1 也是。
|
|
|
|
|
|
// 原来只在 `!$isHome`(请求 `/dav/`)时才输出,于是
|
|
|
|
|
|
// `PROPFIND /dav/calendars/`(Thunderbird 走的就是这条)只回子本、没有自己
|
|
|
|
|
|
// ⇒ 严格客户端判定"这个 home 不存在" ⇒ 日历空。
|
|
|
|
|
|
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::HOME_PATH) . '</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>'
|
|
|
|
|
|
. '<c:calendar-home-set><d:href>' . CalDav::x(CalDav::HOME_PATH)
|
|
|
|
|
|
. '</d:href></c:calendar-home-set>'
|
|
|
|
|
|
. $readOnlyPriv
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>';
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// 首次请求 `/dav/`(bookId=0 且不是 home 路径)时,把发现链的三个答案一并给出:
|
|
|
|
|
|
// 有些客户端在 /dav/ 上**同时**问 current-user-principal —— 少给一个它就又断了。
|
|
|
|
|
|
if (!$isHome) {
|
|
|
|
|
|
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);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($depth === '1') {
|
|
|
|
|
|
// 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉"
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$rows = CalDav::resources($cal, $familyId, $me);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$parts = [];
|
|
|
|
|
|
foreach ($rows as $r) {
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// ⚠️ 资源名走 CalDav::segmentOf(与 REPORT / GET 同一处算)——
|
|
|
|
|
|
// 三处各自拼字符串的话,客户端 PROPFIND 记下的 href
|
|
|
|
|
|
// 与它 REPORT 时用的 href 对不上,就会反复重拉。
|
|
|
|
|
|
$seg = CalDav::segmentOf($r);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($seg === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$etag = CalDav::resourceOf($r, (string)$cal['name'])['etag'];
|
|
|
|
|
|
$parts[] = '<d:response><d:href>'
|
|
|
|
|
|
. CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
. '<d:propstat><d:prop>'
|
|
|
|
|
|
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
|
|
|
|
|
|
. '<d:resourcetype/>'
|
|
|
|
|
|
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>';
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Depth 0:这一天历本自身的属性
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
|
|
|
|
|
|
'<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
. '<d:propstat><d:prop>'
|
|
|
|
|
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
|
|
|
|
|
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
|
|
|
|
|
|
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. $readOnlyPriv
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>',
|
|
|
|
|
|
]);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// ---- 6. GET / HEAD:整本导出成 ics,或单条资源 ----
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($method === 'GET' || $method === 'HEAD') {
|
|
|
|
|
|
if (!$cal) {
|
|
|
|
|
|
dav_fail(404, '没有这本日历。');
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$etag = '"' . md5($ics) . '"';
|
|
|
|
|
|
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);
|
|
|
|
|
|
header('Cache-Control: private, max-age=0, must-revalidate');
|
|
|
|
|
|
$fn = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$cal['name']);
|
|
|
|
|
|
header('Content-Disposition: inline; filename="' . $fn . '.ics"');
|
|
|
|
|
|
if ($method === 'GET') {
|
|
|
|
|
|
echo $ics;
|
|
|
|
|
|
}
|
|
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// ---- 7. REPORT:calendar-multiget(按 href 取) / calendar-query(整本或按时间范围)----
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($method === 'REPORT') {
|
|
|
|
|
|
if (!$cal) {
|
|
|
|
|
|
dav_fail(404, '没有这本日历。');
|
|
|
|
|
|
}
|
|
|
|
|
|
$bodyRaw = (string)file_get_contents('php://input');
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
// calendar-multiget:客户端点明要哪几条。
|
|
|
|
|
|
// 🔴 v31:这里**改成真的按 href 过滤**(原来"整本回、客户端自己挑")。
|
|
|
|
|
|
// 原因:现在 href 是确定性的(`/dav/<book>/<seg>.ics`,seg 由 segmentOf 统一给),
|
|
|
|
|
|
// 按 href 取回来的资源名 → 直接反查,不再需要猜"绝对 URL / 前缀 / 编码"。
|
|
|
|
|
|
// 整本回的问题在派生本上尤其严重(2981 条里 2785 条是重复的同一 href)。
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false;
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-03 08:18:05 +08:00
|
|
|
|
|
|
|
|
|
|
// calendar-query 的时间范围(有就按范围裁;没有就整本)
|
|
|
|
|
|
$from = '';
|
|
|
|
|
|
$to = '';
|
|
|
|
|
|
if (preg_match('#<c:time-range[^>]*start="([^"]+)"#i', $bodyRaw, $m)) {
|
|
|
|
|
|
$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)) {
|
|
|
|
|
|
$to = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 单条资源请求(/dav/3.ics/12.ics)
|
2026-10-08 20:05:01 +08:00
|
|
|
|
if ($evSeg !== '') {
|
|
|
|
|
|
$one = CalDav::oneResource($cal, $familyId, $me, $evSeg);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if (!$one) {
|
|
|
|
|
|
dav_fail(404, '没有这条日程。');
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$res = CalDav::resourceOf($one, (string)$cal['name']);
|
|
|
|
|
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', [
|
|
|
|
|
|
'<d:response><d:href>'
|
|
|
|
|
|
. CalDav::x(CalDav::bookHref($bookId) . $evSeg . '.ics') . '</d:href>'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
. '<d:propstat><d:prop>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
|
|
|
|
|
|
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
|
|
|
|
|
|
. '</d:prop></d:propstat></d:response>',
|
|
|
|
|
|
]);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$rows = CalDav::resources($cal, $familyId, $me);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
$parts = [];
|
|
|
|
|
|
foreach ($rows as $r) {
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$seg = CalDav::segmentOf($r);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
if ($seg === '') {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
if ($wantsMulti && !isset($wantHrefs[$seg])) {
|
|
|
|
|
|
continue; // multiget:只要点名的
|
|
|
|
|
|
}
|
|
|
|
|
|
$day = substr((string)($r['starts_at'] ?? ''), 0, 10);
|
|
|
|
|
|
if (!$wantsMulti) {
|
|
|
|
|
|
if ($from !== '' && $day !== '' && $day < $from) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($to !== '' && $day !== '' && $day > $to) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
$res = CalDav::resourceOf($r, (string)$cal['name']);
|
|
|
|
|
|
$parts[] = '<d:response><d:href>'
|
|
|
|
|
|
. CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
. '<d:propstat><d:prop>'
|
2026-10-08 20:05:01 +08:00
|
|
|
|
. '<d:getetag>' . CalDav::x($res['etag']) . '</d:getetag>'
|
|
|
|
|
|
. '<c:calendar-data>' . CalDav::x($res['ics']) . '</c:calendar-data>'
|
2026-10-03 08:18:05 +08:00
|
|
|
|
. '</d:prop></d:propstat></d:response>';
|
|
|
|
|
|
}
|
2026-10-08 20:05:01 +08:00
|
|
|
|
$emit('xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"', $parts);
|
2026-10-03 08:18:05 +08:00
|
|
|
|
exit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 8. 其它方法一律 405(并告知支持哪些)----
|
|
|
|
|
|
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
|
|
|
|
|
dav_fail(405, '不支持这个请求方法。');
|