870a63df5a42962131be62b10b75e2d76c9ed831
12
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
610d524e68 |
CalDAV 待办改为按分类拆成多本(对齐参照 DAViCal)
用户提供参照服务器 nas.yangyq.net/caldav/synccalendartest(DAViCal 1.1.9)并指出
「应该可以直接显示那个家庭的待办分类」。
## 参照实测结果
那台服务器的待办本结构:
/caldav/<user>/tfxnz/ 家庭计划 VTODO
/caldav/<user>/zlqlzm/ 工作任务 VTODO
/caldav/<user>/tdotbsnl/ 个人成长 VTODO
/caldav/<user>/home_todo/ Inbox VTODO
⇒ 每个分类是**独立的 calendar 集合**,各自 supported-calendar-component-set
只含 VTODO,客户端里显示成并列的多个待办列表。
VTODO 正文还带 CREATED / LAST-MOD / DTSTAMP / SEQUENCE / PRIORITY /
PERCENT-COMPLETE / STATUS / DESCRIPTION / LOCATION / TRANSP。
## 上一版的问题
第一版做成「一个「家庭待办」本装全部任务」,所有分类混在一起,
客户端里只有一个列表 —— 用户要的是分类可见。
## 这一版
### 本的组织
/dav/tasks-<categoryId>.ics/ 每个 task_categories 一行 = 一本
/dav/tasks-inbox.ics/ category_id 为空的任务(Inbox)
⚠️ 用 `tasks-<id>` 而不是裸 id:真实日历本是 `/dav/<id>.ics/`,
裸 id 会与它们撞号(calendar_events 与 task_categories 是两套独立序列)。
### 分类跟随「本」
客户端把资源 PUT 进哪一本,任务就归哪个分类。
这是「按分类拆本」的自然语义,也让「在客户端里移动任务到另一本」等价于改分类。
### 跨本隔离
GET/DELETE 单条时会校验「这条任务确实属于这个本」,
否则改一下 href 就能用 A 本读到 B 本的任务。
## 验证(39 项全过)
真实数据(5 个分类 / 32 条任务):
| 本 | 条数 |
|---|---|
| 家庭事务 | 6 |
| 工作相关 | 19 |
| 个人事务 | 2 |
| 党支部工作 | 1 |
| 轻舟相关 | 1 |
| 收件箱 | 3 |
每本 supported-calendar-component-set 均只含 VTODO,无 VEVENT。
PUT 到 tasks-1.ics 后 Location 返回 /dav/tasks-1.ics/task-40.ics,
GET 读回、更新、完成、删除全部正常。
回归:原有日历本(观影等)PROPFIND/GET 正常,组件类型仍是 VEVENT。
鉴权:无口令 401、不存在的分类本 404、非法段 404。
测试数据已清理,tasks 仍为 32 条。
## 文件
- app/TaskDav.php 重写为多本模型
- app/Ics.php VTODO 导出/解析(上一版已加,本版未改)
- dav.php 五处拦截改多本
## 已知限制
看板名(正在进行/等待反馈/等待结果)通过 CATEGORIES 输出给客户端,
但客户端改它不会反向改看板 —— 标准 VTODO 没有「看板列」概念。
分类则双向(由「在哪一本」决定)。
|
||
|
|
c86658966c |
CalDAV 增加待办(VTODO)双向同步
用户原话:「网页端的 caldav 功能,除了能够同步日程外,增加可以同步任务(待办)的功能」
## 三个设计选择(用户已定)
1. 存放位置:作为 /dav/ 内的一个日历本,不另开 /taskdav/ 入口
2. 同步方向:直接双向读写
3. 字段映射:标准映射
## 字段映射
| VTODO | tasks 列 | 说明 |
|---|---|---|
| UID | 派生 task-<id> | 与日历 UID 空间隔离 |
| SUMMARY | title | 子任务加「└ 」前缀(纯展示) |
| DESCRIPTION | note | |
| DUE | due_at | 零点视为整天,用 VALUE=DATE |
| STATUS | status | todo→NEEDS-ACTION,doing→IN-PROCESS,done→COMPLETED |
| COMPLETED | done_at | 仅 done 时输出 |
| PERCENT-COMPLETE | status | doing=50,done=100 |
| CATEGORIES | 看板名 | 让客户端能归类 |
| RELATED-TO | parent_id | 标准父子表达 |
## 改动
### app/Ics.php:并列增加 VTODO 能力
不改 parse()/export() 的任何行为(VEVENT 路径注释里记着多轮踩坑史),
新增 exportTodos() / vtodo() / parseTodos() / normalizeTodo(),复用底层工具。
### app/TaskDav.php(新建,约 380 行)
虚拟日历本:href 固定 /dav/tasks.ics/,BOOK_ID = -1
(calendar_events.calendar_id 是 INT UNSIGNED 恒 ≥1,负数永不撞)。
权限口径与网页端完全一致,复用 Task::visibleTo / Task::canWrite /
Task::create / Task::update / Task::destroy,不另写一套。
### dav.php:前置拦截,原有日历逻辑零改动
PROPFIND / REPORT / GET / PUT / DELETE 各分支开头判断是否待办本,
是则走 TaskDav 并 exit。另外在 /dav/ 的本清单里追加待办本
(否则客户端根本不知道有这一本)。
## 过程中修掉的 4 个真 bug
1. **Class "TaskDav" not found** → 缺 use App\TaskDav;
autoload 要求带上命名空间前缀,漏了会让整个 dav.php 全线 500。
2. **PUT/GET单条/DELETE 全 404** → 原来全用 isTaskHref(),
它只比「整条路径 == /dav/tasks.ics/」,带子资源时必然 false。
新增 isTaskPath() 做前缀匹配(且要求前缀后必须是 / 或结束,
避免 /dav/tasks.icsXXX 被误判)。
3. **PUT 到不存在的资源名返回 403** → 原实现直接抛「任务不存在」。
按 RFC 4918 §9.7,PUT 到不存在的 URI 就是创建资源。
改为:只有确实存在且有权访问时才走更新分支,否则新建。
4. **注释里的 markdown 加粗导致 PHP 语法错**
`作为 **/dav/ 内的一个日历本**` 里 `**/` 形成 `*/`,提前闭合块注释。
PHP 注释里不要写 markdown 加粗。
## 验证
### Ics 层(34 项全过)
生成 → 解析 → 归一往返,含三种客户端完成态表达
(STATUS:COMPLETED / 只有 COMPLETED 时间戳 / PERCENT-COMPLETE:100)。
### TaskDav 层(45 项全过,真实 32 条任务)
读写、ETag 稳定性、权限、事务回滚。
写操作全在事务里做并回滚,不污染真实数据。
### HTTP 端到端(37 项全过)
OPTIONS → PROPFIND 发现 → REPORT → GET 整本 → PUT 新建
→ GET 读回 → PUT 更新 → DELETE,另含回归与越权检查:
- 原有日历本 PROPFIND/GET 仍正常,组件类型仍是 VEVENT
- 无口令 401、错口令 401、不存在的任务 404
测试按 RFC 跟随 Location(新建时服务端分配 id 并告知真实 href),
这也是真实客户端的行为。
### 数据完整性
测试前后 tasks 均为 32 条,测试数据已清理。
测试用 DAV 口令已 revoke(不影响用户原有口令)。
## 部署
app/Ics.php、app/TaskDav.php、dav.php 三个文件已上传并验证。
⚠️ 服务端文件不在客户端仓库,以后改这些要先比对服务器 md5 再验
(本次踩过:改了没上传,拿旧代码验出假结果)。
|
||
|
|
ef574d82a4 |
v51b DAV 例外:时区换算 + RECURRENCE-ID/EXDATE/多 VEVENT 不再被丢
根因三处(此前一个资源只能有一个 VEVENT、只认前 9 个字段): · Ics::normalizeVevent() 固定 9 字段,没有 recurrence_id/exdate; · dav.php 只取 $list[0](覆盖行全丢); · calendar_events 没有对应列。 修法: · 时区 —— 带 Z / TZID 的值按 zone 换算成本地墙钟,浮动值不动; UNTIL 一起归一(必须与 DTSTART 同值类型/同基准);DTSTAMP/CREATED/ LAST-MODIFIED 仍按 RFC 保留 UTC。 · 例外 —— 新增 calendar_event_overrides 卫星表(uk(event_id,recurrence_id), FK CASCADE)存覆盖行;EXDATE 落既有的 calendar_event_skips(与网页端 「只删这一次」共用);PUT 的例外语义 = 权威替换。 · 未建模属性(X-* 等)落 calendar_events.extra 原样回吐, 表单保存不认识时不动它(防网页编辑抹掉客户端的 X-*)。 · 「RECURRENCE-ID + STATUS:CANCELLED」折算成 EXDATE(RFC 5545 §3.8.4.4 与 EXDATE 等价),并保留"正文里只有覆盖行"的退化路径。 · ETag / ctag 必须含例外,否则只改「某一次」别的设备永远拉不到。 · 网页月历 month() 认覆盖行(按新时间画、原时间不留影、不被 EXDATE 那天画)。 Schema 33 → 34(VERSION 人读 + fingerprint() 才是真正判据)。 验证:Ics 离线单测 30/30;端到端 DAV 例外探针 56/56;v45 既有回归 24/24。 反证:退回旧行为 → 6 条断言变红(网页上"已取消"的那次仍被画出来)。 |
||
|
|
ffde47d476 |
v45c 修复:Thunderbird「找不到日历/联系人」真根因 —— propstat 缺 <d:status>
用户 2026-10-13 再次反馈「还是不行,仍然提示找不到日历」。
按用户要求,在线上装了临时请求日志器(记录 TB 发来的每个请求与我们的每条响应)。
但真正的突破口是**读 Thunderbird 自己的源码**:TB 就在本机,
解开 C:/Program Files\Mozilla Thunderbird\omni.ja,拿到 modules/caldav/*.sys.mjs。
TB 157 的 PROPFIND 解析器(modules/caldav/CalDavRequest.sys.mjs,
PropfindResponse.get data)逐字是:
for (const propStat of response.querySelectorAll(":scope > propstat")) {
const status = propStat.querySelector(":scope > status").textContent; // ← 无 null 保护
...
}
我们的每个 <d:propstat> **都没有 <d:status> 子节点**(RFC 4918 要求有),
于是 querySelector 返回 null → null.textContent 抛 TypeError →
detectCollection() 整条发现链中断 → 客户端报「找不到日历 / 联系人也没有」。
**CalDAV 与 CardDAV 同型**,所以用户看到的「日历和联系人都没有」是同一个 bug。
为什么历史验证全都没抓到(三层叠加):
1. 老 DAV 探针只做 XML 正则匹配,从不校验 RFC 结构;
2. curl 不会因为缺 status 报错 —— 响应看着完全正常(207 + 属性齐全);
3. 只有「把客户端的解析器真跑一遍」才现形。
改动:
· dav.php 12 处 + carddav.php 12 处
`</d:prop></d:propstat>` → `</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat>`
验证(含新增的永久守护):
· 新增 `_tb_parse_repro.js`:用 jsdom 逐字复刻 TB 那段解析逻辑喂真实响应。
修复前:线上 10/10 个 propstat 全部 TypeError;修复后:0 个异常。
· 新增 `_tb_detect_sim.js`:完整复刻 TB 的发现算法(detectCollection /
handlePrincipal / handleHomeSet 三函数照抄)并真发请求。
修复前线上三条路径全部抛异常 ⇒ 0 本日历(与用户症状一致);
修复后线上 **发现 8 本日历**,CardDAV 1 个通讯录。
· `_fb_dav_tb.py` 新增 ⑨「TB 解析器结构守护」5 条断言(纯 Python,不依赖 Node)
→ 探针 39 → 44 项,全绿。
· `_b45_dav_falsify.py` 新增 2 个注入(dav/carddav 各去掉 status)证明断言会红
→ 反向验证 7 → 9 项,全绿。
· `familybook_deploy.sh` 新增静态断言(`</d:prop></d:propstat>` 出现次数必须为 0)。
· 本地 e2e 1510/0;TB 探针 44/0;部署脚本跑通。
⚠️ 顺带修一个反向验证自身的坑:`patch()` 原来只替换第一处出现,
而 `</d:prop><d:status>…</d:propstat>` 在文件里有 12 处、第一处在 /.well-known 分支,
导致注入没落到被测路径上 ⇒ 断言假绿。已改为替换全部出现。
|
||
|
|
feff31baad |
v45b 修复:Thunderbird「找不到日历/联系人」的真根因
用户 2026-10-12 晚:「dav 还是不行,thunderbird 提示找不到日历,联系人也没有。」
真根因(一个由两半无缝衔接构成的 bug):
TB 添加账号 → GET /.well-known/caldav → 我们 301 到 /dav/
→ TB **跟随这个 GET 跳转** → GET /dav/
→ 我们回 **404「没有这本日历」**
→ TB 判定"这里没有日历" ⇒ 报「找不到日历」。
CardDAV 完全同型(/.well-known/carddav → GET /carddav/ → 404)
⇒ 所以日历和联系人是**同一个 bug**。
修法(对齐参照 DAViCal 的状态码语义):
· GET 打到 home 容器(bookId=0)→ **405**(原来 404)
404 = "资源不存在" ⇒ 客户端放弃;405 = "资源在、方法不行" ⇒ 客户端改用 PROPFIND。
· REPORT 打到容器 → **403**(参照实测也是 403)。
· dav.php 与 carddav.php **两处同修**(同型孪生 bug)。
顺带对齐参照(「完全按参照返回」口径):
· 每个 XML 响应都带两行 `DAV:` 头(原来只有 OPTIONS 带)——
CalDav::xmlHeader / CardDav::multis(所有 XML 响应的必经处)。
· Content-Type 改 `text/xml`(参照的字面形态)。
探针补齐(这类 bug 长期漏网的三个原因):
· 原 TB 探针只发 PROPFIND/REPORT,**从不发 GET /dav/**;
· curl 默认不跟随跳转(要 -L);
· 得先 GET .well-known 再跟到 /dav/ 才显形。
⇒ _fb_dav_tb.py 新增 ①c(follow=True 真跟跳转,断言跟随后不能 404、应为 405);
_fb_dav_tb.py 支持 FB_SITE(供反向验证指向注入副本)。
验证:
· TB 探针 35 → **39/0**(新增 4 条 ①c 全过)
· v45b DAV 反向验证 **7/0**(新增 2 个注入:dav/carddav 容器 GET 改回 404
—— 都已证明会让断言变红)
· 全流水线 PUSH_RC=0
· 线上真机复验:跟随 well-known 的 GET 跳转 → CalDAV 405 / CardDAV 405
(修复前都是 404);PROPFIND 带两行 DAV 头 + text/xml;
发现链 207、8 个日历本、1 个通讯录。
|
||
|
|
4ccbcc3360 |
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 批形态)。
|
||
|
|
81dfacaf1b |
v42 第 42 批:十一条反馈(界面 9 条 + CalDAV 两处新根因)
CalDAV(用户「还是不行,thunderbird 提示找不到日历」): - 🔴 `.well-known/caldav` 原来**只回 301**。Thunderbird 建账号时常是 **带鉴权直接 PROPFIND** 这个地址(把它当 DAV 服务入口探),而它对这一跳的 301 **不跟随** ⇒ 判定"这里没有 CalDAV" ⇒ 找不到日历。 ⇒ 按方法分流:带鉴权 PROPFIND → 直接 207 给发现内容(principal / home-set / 日历本清单,一步到位);其余仍是 301。carddav 同修。 - 🔴 `Location` 由**相对** `/dav/` 改成**绝对 URL**(按真实 HTTP_HOST 拼, 协议从 HTTPS / X-Forwarded-Proto 取)。TB 在服务发现这一跳对相对跳转不可靠。 - 线上终验:匿名 GET 301+绝对URL;带鉴权 PROPFIND 直接 207(含 principal/home-set); Depth:0 只回自身;Depth:1 列出 12 本日历。 界面九条: - ① 新建旅程不再带入旧数据(真根因:`_list_body.php` 的 `foreach ($rows as $t)` 循环后 `$t` 残留,而 `form.php` 读的正是 `$t` ⇒ 弹框被填上列表最后一趟的数据)。 修:循环变量改 `$row` + `form.php` 契约兼容(`$tIn` 优先)。 - ②③④ 行程弹框里的 AntD 时间控件 / 出行方式联动车牌与车次 / 高德地址联想全部失效 (真根因:AJAX 片段里的内联 `<script>` **既不执行也不保留**)。 ⇒ 加 `runInlineScripts()` 把片段内联脚本抠出来执行; 另修 `app.js`:`if (!hasMonth) return` 的短路让 `window.fbBootAntd` 从未被定义, 把它提到所有 return 之前。 - ④b⑤ 标签改「照片」「时间范围」。 - ⑥ 简介补"还没有简介,点这里写一句"的占位(简介本身一直在,是那条 summary 为空)。 - ⑦ 资源编辑复用新建弹框(`#resAddCard`),并在其底栏补「共享」「附件」入口 (第 7 条改动后这两个能力一度无入口)。 - ⑧ 纪念日「月/日」→「日期」+ AntD 纯日期控件(新增 mountAntdDate / [data-antd-date]), 存储契约仍是 month + day。 - ⑨ 编辑纪念日弹框关不掉(关闭逻辑原来只在 days/index.php,而 days/edit 是整页) ⇒ 搬进 days/form.php 自包含。 验证:e2e 1510/0;v42 真机 33/0;DAV·TB 35/0;DAV 反向 8/0; b23 55/0;b28 39/0;b32 26/0;b32 反向 5/0;v31 DAV 28/0;b31 反向 8/0; 部署断言空跑 337/0;完整流水线 PUSH_RC=0。 |
||
|
|
7544fb9f6a |
v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」): - 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、 CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在 /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。 Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。 改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。 - 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合, 且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流, Depth:0 只回资源自身、Depth:1 才列出子集合。 界面与交互: - 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移; 弹框底栏改「确定 / 取消」。 - 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色; 编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分); 参与人默认带上自己;车次号/车牌号紧跟交通方式词。 - 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。 - 纪念日:新建改弹框 + 观影式排版。 验证: - e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0; 部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。 - 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0; 线上确认 Depth:0 恰好 1 个 response。 |
||
|
|
5da4de0d20 |
v33 第 33 批:DAV(Thunderbird)三处真根因修复
用户原话: 看看 dav 功能,还是不好用,thunderbird 提示这两个地址下没有任何联系人和日程。 关键教训:v31 那个探针(复刻 SyncCalendar 的 DavClient)线上 28/0 全过, 但用户还是空的 —— 因为 Thunderbird 的请求形状不一样:它真正取数据用的是 REPORT(calendar-multiget / addressbook-multiget),而 v31 探针只走 PROPFIND + GET。复刻了 A 客户端全绿,证明不了 B 客户端能看见东西。 三处真根因(都靠新探针 _fb_dav_tb.py 逐步 dump 原始 XML 抓出): ① CardDAV 的 REPORT 回的是草案名 <card:address-data>,而 RFC 6352 规定是 <card:addressbook-data>。Thunderbird 要后者 → 拿到 0 个数据块 → 通讯录永远空。 修:新增 $cardDataXml 闭包,照客户端点名的属性回正文(没点名默认正式名)。 注:第一版写成两个都发 → 正文重复一份、e2e 的 BEGIN:VCARD 计数翻倍,改成按需回。 ② home-set 的 Depth:1 响应里没有集合自身。RFC 4918 要求 PROPFIND 响应必须包含 被请求的资源本身,原来只在非 home 请求时才输出 → PROPFIND /dav/calendars/ 只回子本、没有自己 → 严格客户端判 home 不存在 → 一本都看不见。 修:dav.php / carddav.php 的 home 分支始终先输出集合自身。 ③ 站点根 PROPFIND / 被 Apache 交给 index.php → 302 跳登录页 → Thunderbird 判定 这个域名下没有日历(RFC 6764 允许从域名根发现)。 修:router.php(本地)+ .htaccess / 部署脚本 5c 段独立标记块(线上): RewriteCond 限住 REQUEST_METHOD + RewriteRule ^$ dav.php; 必须限方法,否则 GET / 被 ^$ 吞掉、首页 404(已加反向守卫)。 并让根响应与两个 principal 都给出两套 home-set(calendar + addressbook)。 验证:线上真机 _fb_dav_tb.py 25/0(站点根通 / home-set 含自身 / CardDAV REPORT 真带回 3 个数据块,修复前是 0);逐本 REPORT 所有集合都能取回数据; 旧诊断(SyncCalendar 序列)28/0 无回归;GET / 仍是 302 未被吞。 本地 e2e 1503/0、v33 DAV 反向验证 7/0(三处各自改回旧行为 → 断言全变红); 部署脚本静态断言段本地空跑通过。 |
||
|
|
be582569dd |
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`。
|
||
|
|
6ceae11293 |
v24 第 22 批:名册加 CardDAV 同步 + 一把通用 DAV 口令(日历与通讯录共用)
用户 2 条原话:
①「名册,增加cardav同步功能。」
②「caldav和cardav,都增加一个创建口令的功能,这样,就使用用户名和口令来同步,
避免在第三方客户端进行同步时自己的密码泄露。」
用户拍板的两个岔路(AskUserQuestion):
· 口令范围 = **一把通用 DAV 口令**(日历 + 通讯录共用),不是两个 ——
手机上填一次就都通;两把会让用户要在两个页面各配一遍、还容易只配一半。
· 同步范围 = 与**名册页完全一致**(Person::roster(kind='member')),不另发明判据。
════ 一、 口令统一:caldav_credentials → dav_credentials(Schema v23)════
表**改名**(不是新建一张 carddav_credentials):
· 老站 RENAME → 已配好的口令一字不动,**所有设备不用重配**;
· 新装时两表都不在 → 跳过(直接 RENAME 一张不存在的表会抛异常、把升级打断);
· 两表并存(上次升级跑一半 / 有人手工建过)→ 搬行(INSERT IGNORE)+ DROP 旧表。
⚠️ 迁移**必须**是 RENAME + 存在性守卫(新增 Schema::hasTable),
只改建表语句里的名字的话,老库会**再建一张空表**、旧表留在原地没人读 →
用户所有已配好的同步设备立刻全部 401(而且改密码也修不好:新口令写进了新表)。
Auth:caldavIssue/Revoke/Auth/hasCalDav → davIssue/davRevoke/davAuth/hasDav,
新增 davLastUsed()(UI 上显示"最近使用")。
口令管理入口搬到**个人设置 → DAV 同步**(它管的是"我的凭据",同时服务日历与通讯录;
放在日历页会让只想同步通讯录的人在日程里找半天)。
· 旧路由 calendar/caldav_token|caldav_revoke 保留为 302 → 老书签不 404;
· 日历本页只留地址 + 一个指路按钮(密码类的东西只该有一个管理入口,
两处各放一套按钮迟早对不上:这页重置了、那页还显示"已配置")。
════ 二、 CardDAV(全新)════
· app/Vcard.php —— 名册 → vCard **3.0**(iOS/DAVx⁵ 推荐;4.0 在 iOS 上会被降级丢字段)。
FN 必须有(缺了 iOS 会**整张卡丢掉**)、UID 用 person id(稳定,不随改名变)、
标签能对上固定词就走 TYPE(手机上显示成"手机/工作"),对不上就原样放进
X-ABLabel(**不猜**成 HOME —— 猜错了用户看到"家"其实是"奶奶家")。
· app/CardDav.php —— books/rows/one/cardOf/etag/parsePath;ROOT='/carddav/'。
· carddav.php —— OPTIONS / PROPFIND(0,1) / GET / REPORT;**写方法一律 403**。
只读是与 CalDAV 同样的取舍:与其"半支持"(客户端以为改成功了、服务端却没落库),
不如明确拒绝,用户至少知道"手机上加的联系人不会同步回来"。
· **地址空间分离、鉴权同一套**:/carddav/ 与 /dav/ 各自独立(资源类型与命名空间
完全不同,混在一棵树里要靠猜客户端意图,猜错的表现是"连上了但一本都看不到");
但两者调**同一个** Auth::davAuth、读**同一张** dav_credentials 表 ——
这正是"一把口令管两种同步"的落点,也保证不会出现"日历能连、通讯录连不上"。
· 路由三处:router.php(本地 php -S)+ .htaccess(书面来源)+ 部署脚本 5c 段。
⚠️ 线上用**独立标记块** `# >>> FamilyBook CardDAV`:老站已经有
`# >>> FamilyBook CalDAV`,把新规则并进那一段会被"标记在不在"的幂等判断
**整段跳过** → /carddav/ 永远 404,而本地(走 router.php)完全正常。
· 打包白名单加 carddav.php(站点根下的独立入口最容易漏 —— dav.php / upload_stage.php
都漏过一次,症状都是"线上那个功能 404、本地一切正常")。
════ 三、 🔴 三个真 bug(全是"本地校验全绿"型,靠 vCard 往返测试抓出来)════
把 App\Vcard(导出)与 App\ContactImport(导入)各自单测都是绿的,
但**导出→导入跑一遍**立刻翻出三个:
① Vcard::fold **漏了"续行补前导空格"**(照抄 Ics::fold 时抄漏了最后一步)。
后果:折出来的那几段变成**独立的属性行** → 客户端把半个名字/半个地址当垃圾丢掉。
而"每行 ≤75 字节""确实折行了"全是绿的 —— 只有「**折行可无损还原**」这条断言能抓。
② ContactImport 用 `explode(';')` 给 ADR / N 分段 —— PHP 的 explode **不认识转义**,
值里写了 `\;`(字面分号)照样在那里拆开 → 地址「青岛市,市南区;某某路1号」被
从中间切断,后半截(含省名)跑到别的段上;导进来的人地址只剩前半句、
而且看着像"省没填"。手机通讯录里带分号的地址("XX路1号;3单元")不算罕见。
→ 新增 `splitVcardSegments()`:**按未转义的分号**切,切完再逐段 unescape。
⚠️ "先 unescape 再分段"是同一个错的另一种写法(本轮第一版就这么改,往返照样红)。
③ Vcard::adrField 的**分号数量对、但位置错一位**(省落到了"邮编"格)。
第一版 `;;` + 街道 + `;;;` + 省 + `;` —— 总数也是 6,可多出来的那个挤在前面 →
解析器读段 4 拿到空串。**段数对、总数对、只有位置错一位**,
所以"数分号个数"的检查发现不了它。
════ 四、 顺带修的一个 ETag 一致性 bug ════
同一张卡在 PROPFIND(清单)与 REPORT(取正文)里原本算出**两个不同的 ETag**
(一个用不带结尾 CRLF 的正文、一个用带 CRLF 的)→ 客户端认为这张卡一直在变、
**反复重拉**(同步永远不收敛),而服务端看不出任何异常(两边都合法)。
→ 新增 `CardDav::cardOf()`,把**正文与 ETag 成对返回**,结尾 CRLF 只加在正文上、
不参与 ETag。修法不是"两处改成一样",而是**让它们没有机会不一样**。
════ 五、 UI ════
· 个人设置页「DAV 同步」卡:两个地址(/dav/ 与 /carddav/)+ 用户名预填 + 一次性明文
(只显示这一次,走一次性会话槽位)+ 生成/重置/撤销 + "最近使用"。
· 名册页 CardDAV 卡:地址 + "这 N 个人"(人数与名册页一致,用户当场能对上)+
指路口令的按钮。**不放**生成表单。
· 日历本页改口径:只留 /dav/ 地址 + 指路链接。
════ 六、 验证 ════
- e2e **1106/0**(本批新增约 45 条:CardDAV 全协议、越权 404、只读 403、
一把口令开两把锁、撤销后两处**同时**失效、口令一次性/不落库、ETag 一致性)
- **vCard 规格体检 36/36**(新工具 `_fb_b22_vcf_spec.php`:转义/折行/多值/标签/
空名兜底 + **导出→导入往返一致**)
- **真机探针 28/0**(新工具 `_fb_b22_ui.py`:两块 UI 渲染、**在浏览器里点按钮**生成
口令、刷新后明文消失但状态留下、名册页人数对得上、日历本页改口径、
撤销后两处 401、全程零 JS 报错)
- 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0 · 结构升级 149/0
- 部署静态断言空跑 **307/0** · 线上部署 134 项自检 0 失败
- **反向验证 14/14**(`_fb_b22_falsify.py`):迁移不干活 / 读旧表 / 断入口 /
去掉 addressbook 声明 / 只读守卫失效 / 不校验口令 / 越权不再查名册 /
折行不补空格 / 分段改回 explode / ADR 错位 / UI 卡拿掉 / 明文不再一次性 /
撤销不删凭据 —— 逐条改回旧行为,对应断言都变红,还原后复绿。
════ 七、 🔴🔴 线上实测挖出的**最重要**的一条:DAV 鉴权在 Apache 上一直是坏的 ════
部署后在线上以 **Web 用户(www)** 身份做端到端验收时,DAV 全部 401 ——
而本地 e2e 全绿。追下去发现两层原因:
① **测试假象**:我把 DAV 请求放进了一个**临时探针文件**里,用 `curl` 打它。
但 Apache 请求 `.php` 文件时**不套用站点根 .htaccess 的 URL 重写** ——
所以探针里的请求根本没被转给 dav.php/carddav.php,拿到的是**探针自己的 401**,
不是 DAV 的。第一轮"全 401"有一半是探针自己造的假象。
② **真问题**:Apache 默认**不把 `Authorization` 头交给 PHP-FPM** ——
`$_SERVER['PHP_AUTH_USER']` 与 `HTTP_AUTHORIZATION` **全是 null**。
实测(这台服务器 Apache 2.4.53,直接监听 80/443,**没有 nginx**):
基线 : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
`CGIPassAuth On`: {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
⇒ **线上 CalDAV 从来没被真实客户端连上过**(服务端代码是对的、环境少了这一行)。
本轮 e2e 用 `php -S`,那里 `PHP_AUTH_USER` 是**自己填好的**,所以一路全绿 ——
又一个"本地好好的、线上不行"。
修法:部署脚本 5c 段追加**独立标记块** `# >>> FamilyBook DAV auth`,内容
`CGIPassAuth On`(Apache **2.4.13+ 的核心指令**,不需要额外模块、**不用动宝塔面板**)。
仓库 `.htaccess` 同步写上(它是那段规则唯一的书面来源)。
修完后线上端到端**全部通过**(以 www 身份、真实 HTTP 服务、真实数据):
· CalDAV:`PROPFIND → 207`、**8 本日历本**、`PUT → 403`
· CardDAV:根 `207` + `<card:addressbook/>`;清单 `207`、**1089 张卡**、1089 个 ETag;
整本 vcf `200`、1089 张、全是 3.0、`text/vcard`;**最长行 75 字节**(真实数据上
折行真的发生了);`REPORT → 207` 带 `address-data`;`PUT → 403`;
`OPTIONS → 200 DAV: 1, 2, 3, addressbook`
· 导出的卡数 **1089 == 名册人数 1089**(口径一致)
· 口令错 → `401`
临时探针用完即删并复验 404;临时插入的口令用完即清(不动用户已配的凭据)。
════ 八、 关于 CalDAV「本来是好的」这件事 ════
严格说:**服务端实现一直是对的**,缺的是 Apache 那一行 `CGIPassAuth`。
也就是说这个仓库里的 CalDAV 之前**上线即不可用**(只是没人用真实客户端试过)。
本批顺手把它一起修好了 —— 现在 /dav/ 与 /carddav/ 都能被 iOS / DAVx⁵ 正常连上。
|
||
|
|
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 条线上探针全过。
|