Files
FamilyBook/.htaccess
T
laoyang 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`。
2026-10-08 20:05:01 +08:00

89 lines
5.7 KiB
ApacheConf
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.
# 家书 FamilyBook · Apache 重写规则
#
# ⚠️ 这个文件**不随包上线**(familybook_push.sh 的打包白名单里没有它)。
# 原因:线上站点根那份 .htaccess 是**宝塔面板生成的**(PHP 版本段、防盗链、
# 缓存段、伪静态…),整份覆盖会把面板的东西清空,而且面板下次重写还会改回来,
# 变成"时好时坏"。
# /dav/ 与 /carddav/ 的重写规则由 `familybook_deploy.sh` 第 5c 段**追加**到线上那份里
# (带 `# >>> FamilyBook CalDAV` 标记,幂等)。
#
# 那这份文件是干什么用的?
# · 新建站点(没有宝塔那层)时,可直接把它作为站点根的 .htaccess;
# · 它是那段规则的**唯一书面来源**,push 脚本会断言部署脚本里的规则串
# 与它完全一致(防两处漂移)。
#
# 只做一件事:把 /dav/ 与 /carddav/ 开头的 URL 分别交给 dav.php / carddav.php。
# 其余一律不动 —— index.php 那套 ?r=xxx 路由不依赖重写,
# 少写规则就少一处"上线才发现 404"的地方。
<IfModule mod_rewrite.c>
RewriteEngine On
# 🔴 v31:`/.well-known/*` 是 RFC 6764 规定的 DAV 发现入口,客户端**第一句**就问这里。
# ⚠️ 它必须排在 `^dav` / `^carddav` 之前 —— 这两条是前缀通配(`^dav(/.*)?$`),
# 而 `.well-known/caldav` 并不以 dav 开头,所以严格说互不干扰;
# 但放在最前面是"发现链优先于资源访问"的意图表达,后人加规则时不易搞错。
# 交给 dav.php / carddav.php 回 301(它们在**鉴权之前**处理这条路径:
# .well-known 是免鉴权入口,在这里 401 会让客户端以为"不支持 DAV")。
RewriteRule ^\.well-known/caldav$ dav.php [L,QSA]
RewriteRule ^\.well-known/carddav$ carddav.php [L,QSA]
# CalDAV:/dav/、/dav/3.ics/、/dav/3.ics/12.ics 全部交给 dav.php
RewriteRule ^dav(/.*)?$ dav.php [L,QSA]
# CardDAV(v24 第 22 批):/carddav/、/carddav/1.vcf/、/carddav/1.vcf/12.vcf 交给 carddav.php
# ⚠️ 两条规则**顺序无关但都必须有**:它们的前缀(dav vs carddav)互不包含,
# 不会互相抢;写成 `^dav` 在前是因为历史顺序,真要调也不能写成 `^dav(/|$)` 之外的
# 宽松形式 —— 那会把 /dav2/ 之类也吞进来。
RewriteRule ^carddav(/.*)?$ carddav.php [L,QSA]
# 🔴 v31:`/.well-known/caldav` 与 `/.well-known/carddav`(RFC 6764)。
# 标准客户端(DAVx⁵ / iOS / Evolution)添加账号时**第一句**就试这里 ——
# 原来这里 404,客户端直接判定"域名下没有 DAV 服务",连口令框都不弹。
# 症状正是用户报的「同步不出任何数据」(账号根本建不起来)。
# ⚠️ 这两条必须**排在 `^dav` / `^carddav` 之前**吗?不需要 ——
# `.well-known/...` 不以 dav/carddav 开头,两条规则互不干扰。
# 但**必须**在站点根的"真实文件"判断之前:Apache 对 .htaccess 里的
# RewriteRule 天然就是先于文件查找生效的,所以这里无需额外处理。
# 交给 dav.php / carddav.php 自己回 301(它们在鉴权**之前**处理,
# 因为 .well-known 是免鉴权入口 —— 在这里 401 会让客户端以为"不支持")。
RewriteRule ^\.well-known/caldav$ dav.php [L,QSA]
RewriteRule ^\.well-known/carddav$ carddav.php [L,QSA]
# 导航展示页(v24 第 24 批 用户第 4 条):/nav/?<短语> —— 全站唯一免登录页面。
# ⚠️ suggest 那条**必须排在前面**:`^nav(/.*)?$` 会把 /nav/suggest.php 也吞进去,
# 顺序反了就变成"每次联想都去渲染展示页"(返回一页 HTML、前端拿到非 JSON)。
# 这也是本批新踩的一条:之前只有 dav/carddav 两条互不包含的前缀,
# 到这里第一次出现"一个前缀包含另一个"的情况,顺序**变成有语义的**了。
RewriteRule ^nav/suggest\.php$ nav_suggest.php [L,QSA]
RewriteRule ^nav/?$ nav_show.php [L,QSA]
</IfModule>
# ⚠️ 导航图标与展示页头像**不需要重写规则**(v24 第 25 批):
# nav_icon.php / nav_avatar.php 是站点根下的**真实文件**,Apache 直接就能执行。
# 展示页里的地址由 Nav::abs() 拼成 `<base_url>/nav_icon.php?f=…`,落在站点根,
# 不经过 /nav/ 这个重写前缀 —— 所以上面那两条规则一个字都不用动。
# (这正是选"站点根下的两个独立入口"而不是"塞进 /nav/ 子路径"的原因:
# 塞进去就得再加两条重写规则,而重写规则的顺序语义已经踩过一次坑了。)
# ⚠️⚠️ DAV 的 Basic 鉴权**必须**靠这一行才能在 Apache 上工作:
# Apache 默认**不把 `Authorization` 头交给 PHP-FPM** → `$_SERVER['PHP_AUTH_USER']`
# 和 `HTTP_AUTHORIZATION` 都是 null → /dav/ 与 /carddav/ 无论口令填得多正确都 401。
# 线上实测(Apache 2.4.53,直接监听 80/443):
# 没有这行 : {"PHP_AUTH_USER":null,"HTTP_AUTHORIZATION":null}
# 加上这行 : {"PHP_AUTH_USER":"abc","HTTP_AUTHORIZATION":"(有)"}
# `CGIPassAuth` 是 Apache **2.4.13+ 的核心指令**,不需要额外模块。
# (本地 `php -S` 会自己填好 PHP_AUTH_USER,所以 e2e 全绿而线上全 401 ——
# 典型的"本地好好的、线上不行"。部署脚本也会把这一段追加到线上那份里。)
<IfModule mod_authz_core.c>
CGIPassAuth On
</IfModule>
# 备份/临时文件永远不给出去(这条线上很要紧:*.php.bak 曾经可被直接下载)
<FilesMatch "\.(bak|old|orig|save|swp|swo|tmp)$">
Require all denied
</FilesMatch>
<FilesMatch "^\.|~$">
Require all denied
</FilesMatch>