Files
FamilyBook/app/CalDav.php
T
laoyang 8f80f3e835 v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:

- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
     弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
     REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
     按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
     修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
     压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
     以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
     (实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
     金额一律以「分」存,不用浮点。

验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
2026-10-03 08:18:05 +08:00

142 lines
5.4 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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']);
}
}