142 lines
5.4 KiB
PHP
142 lines
5.4 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* CalDAV 服务端(v24 用户第 7 条后半)
|
|||
|
|
*
|
|||
|
|
* 用户原话:「再增加caldav功能,就是可以把属于我的日历本,通过caldav协议分享给别人。」
|
|||
|
|
*
|
|||
|
|
* 实现的是 RFC 4791 的一个**只读子集** —— 够 iOS / 安卓 / Thunderbird / DAVx⁵
|
|||
|
|
* 把家书的日历本订阅进来;本阶段不做 PUT/DELETE(在手机上改日程)。
|
|||
|
|
* 少做这一半的代价是"要处理冲突与 ETag 回写",而收益(随时改)并不急。
|
|||
|
|
*
|
|||
|
|
* 客户端会依次发这些请求(这是**必须**按顺序实现的最小集):
|
|||
|
|
*
|
|||
|
|
* OPTIONS /dav/ → 声明支持 DAV: 1, calendar-access
|
|||
|
|
* PROPFIND /dav/ (Depth: 0) → 当前用户可见的日历本清单
|
|||
|
|
* PROPFIND /dav/<book>.ics/ (0) → 某一本的属性(displayname / color / 组件集)
|
|||
|
|
* PROPFIND /dav/<book>.ics/ (1) → 这本里所有日程的资源清单(getetag)
|
|||
|
|
* REPORT /dav/<book>.ics/ → calendar-query / calendar-multiget,取正文
|
|||
|
|
* GET /dav/<book>.ics/ → 整本导出成一个 ics(很多客户端走这条)
|
|||
|
|
*
|
|||
|
|
* 鉴权用 **HTTP Basic**(DAVx⁵ / iOS 都原生支持),口令不是登录口令:
|
|||
|
|
* 见 Auth::caldavToken() —— 登录口令绝不该在客户端里被反复存着。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 只读:所有写方法(PUT/DELETE/PROPPATCH)一律 403。
|
|||
|
|
* 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
namespace App;
|
|||
|
|
|
|||
|
|
final class CalDav
|
|||
|
|
{
|
|||
|
|
/** 资源前缀(URL 里的路径) */
|
|||
|
|
public const ROOT = '/dav/';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 当前用户可见的日历本。与「日程」模块里看到的是同一批
|
|||
|
|
* (我建的 + 共享给我的),口径必须一致,否则手机上少一本、网页上多一本。
|
|||
|
|
*/
|
|||
|
|
public static function books(int $familyId, int $personId): array
|
|||
|
|
{
|
|||
|
|
return Calendar::mine($familyId, $personId);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 日历本 → URL 段。用 id 而不是名字:名字里有中文/空格/斜杠时,
|
|||
|
|
* 客户端与服务端对"怎么编码"的理解常常不一致(实测 DAVx⁵ 会二次编码)。
|
|||
|
|
*/
|
|||
|
|
public static function hrefOf(array $cal): string
|
|||
|
|
{
|
|||
|
|
return self::ROOT . (int)$cal['id'] . '.ics/';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public static function bookHref(int $calId): string
|
|||
|
|
{
|
|||
|
|
return self::ROOT . $calId . '.ics/';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程" */
|
|||
|
|
public static function parsePath(string $path): array
|
|||
|
|
{
|
|||
|
|
$p = (string)parse_url($path, PHP_URL_PATH);
|
|||
|
|
if (strpos($p, self::ROOT) !== 0) {
|
|||
|
|
return ['book' => 0, 'event' => 0];
|
|||
|
|
}
|
|||
|
|
$rest = substr($p, strlen(self::ROOT));
|
|||
|
|
$rest = trim($rest, '/');
|
|||
|
|
if ($rest === '') {
|
|||
|
|
return ['book' => 0, 'event' => 0];
|
|||
|
|
}
|
|||
|
|
// 形如 3.ics 或 3.ics/<uid>.ics
|
|||
|
|
$parts = explode('/', $rest);
|
|||
|
|
$bookSeg = $parts[0];
|
|||
|
|
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
|
|||
|
|
$event = 0;
|
|||
|
|
if (isset($parts[1]) && $parts[1] !== '') {
|
|||
|
|
// 单条日程:<uid>.ics —— uid 里可能含点,所以只去掉结尾的 .ics
|
|||
|
|
$evSeg = preg_replace('/\.ics$/', '', $parts[1]);
|
|||
|
|
$event = (int)$evSeg; // 我们用的是 calendar_events.id
|
|||
|
|
}
|
|||
|
|
return ['book' => $book, 'event' => $event];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------------- 应答 ----------------
|
|||
|
|
|
|||
|
|
public static function xmlHeader(): void
|
|||
|
|
{
|
|||
|
|
header('Content-Type: application/xml; charset=utf-8');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 多状态应答(207)—— PROPFIND / REPORT 都用它 */
|
|||
|
|
public static function multis(int $code = 207): void
|
|||
|
|
{
|
|||
|
|
http_response_code($code);
|
|||
|
|
self::xmlHeader();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 把一个 xml 文本片段做最省事的转义(我们只回显自己库里的字符串) */
|
|||
|
|
public static function x(string $s): string
|
|||
|
|
{
|
|||
|
|
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 某个日历本里"能列出来"的日程。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 与网页日历同源:导出/订阅的内容必须与页面上看到的一致。
|
|||
|
|
* 派生本(观影 / 旅程)走 Calendar::exportRows,自定义本走 calendar_events。
|
|||
|
|
*/
|
|||
|
|
public static function rows(array $cal, int $familyId, int $me): array
|
|||
|
|
{
|
|||
|
|
return Calendar::exportRows($cal, $familyId, $me);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
|
|||
|
|
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
|
|||
|
|
{
|
|||
|
|
[$familyId, $me] = $familyIdAndMe;
|
|||
|
|
$st = Database::get()->prepare(
|
|||
|
|
'SELECT e.* FROM calendar_events e
|
|||
|
|
JOIN calendars c ON c.id = e.calendar_id
|
|||
|
|
WHERE e.id = ? AND e.calendar_id = ? AND c.family_id = ?'
|
|||
|
|
);
|
|||
|
|
$st->execute([$eventId, $calId, $familyId]);
|
|||
|
|
$r = $st->fetch();
|
|||
|
|
if (!$r) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
// 共享只读的人也要能取到(能看见这本就能看见里面的条目)
|
|||
|
|
$cal = Calendar::mineOne($familyId, $calId, $me);
|
|||
|
|
if (!$cal) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
return $r;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 一整本导出成 ics 文本(GET 与 REPORT 都用它) */
|
|||
|
|
public static function ics(array $cal, int $familyId, int $me): string
|
|||
|
|
{
|
|||
|
|
return Ics::export(self::rows($cal, $familyId, $me), (string)$cal['name']);
|
|||
|
|
}
|
|||
|
|
}
|