Files
FamilyBook/carddav.php
T
laoyang 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。
2026-10-11 10:12:11 +08:00

463 lines
22 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
/**
* 家书 FamilyBook · CardDAV 入口(v24 第 22 批)
*
* 用户原话:「名册,增加cardav同步功能。」
* 把「我的名册」放进手机通讯录(iOS 通讯录 / DAVx⁵ / Thunderbird / 群晖 Contacts)。
*
* 地址:https://<站点>/carddav/
* 账号:手机号或姓名(同登录)
* 口令:**DAV 通用口令**(在「个人设置 → DAV 同步」生成,不是登录口令)
* ⚠️ 与 CalDAV(/dav/)**共用同一把**(用户 2026-10-06 确认):
* 手机上填一次,日历本与通讯录一起进来。
*
* ⚠️ 为什么是独立文件、独立前缀(/carddav/ 而不是 /dav/card/):
* 见 App\CardDav 的类注释 —— 两种资源类型混在一棵树里要靠猜客户端意图,
* 猜错的表现是"连上了但一本都看不到",比连不上更难查。
* 二者共用的是**鉴权**(Auth::davAuth)与**口令表**(dav_credentials),
* 那才是用户关心的"用用户名和口令来同步"。
*
* ⚠️ 与 dav.php 一样,这里不做"半支持":写方法一律 403。
* 手机上加/改/删联系人**不会**回写到家书 —— 与其让用户以为同步了,不如明说。
*
* ---------------------------------------------------------------------------
* 🔴 v31(2026-10-08):用户报「carddav 和 caldav 好像都不好用,没有任何数据被同步出来」。
*
* 与 CalDAV 是**同一个根因**:发现链(discovery)整段缺失。
* 标准客户端建账号时按 RFC 6764 走:
* ① GET /.well-known/carddav → 301 到 /carddav/principal/
* ② PROPFIND /carddav/ → d:current-user-principal
* ③ PROPFIND <principal> → card:addressbook-home-set
* ④ PROPFIND <home-set> Depth:1 → 列出通讯录
* ⑤ PROPFIND <book> Depth:1 / REPORT → 取每张卡
*
* 实测线上:① 404、②③ 回空 ⇒ 客户端在"填完账号点保存"那一刻就失败。
*
* ⚠️ 本文件**同时**补了一个"路径被当成通讯录 id"的坑:
* CardDav::parsePath('/carddav/principal/') 会把 `principal` 段去 `.vcf` 后缀后
* 转成 int → **0**,于是 principal 路径会被当成"根"、走进"列通讯录"那条路,
* 客户端就永远问不到 addressbook-home-set。所以发现链节点必须先于 parsePath 判定。
*/
declare(strict_types=1);
require __DIR__ . '/app/bootstrap.php';
use App\Auth;
use App\CardDav;
/** 统一的失败出口(DAV 客户端只认状态码,正文给点人话便于排查) */
function cd_fail(int $code, string $msg): void
{
http_response_code($code);
header('Content-Type: text/plain; charset=utf-8');
echo $msg;
exit;
}
// ---- 1. 鉴权:HTTP Basic(与 CalDAV 同一套 —— 一把口令管两种同步)----
$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);
}
}
}
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/carddav/'), PHP_URL_PATH);
/**
* ⚠️ `/.well-known/carddav` 是**唯一免鉴权**的入口(RFC 6764 §6.1)。
* 在这里就 401 的话,很多客户端会直接报"服务器不支持 CardDAV",
* 而不是弹口令框 —— 所以这一跳必须在鉴权**之前**。
*
* ⚠️ 跳转目标选**上下文路径 `/carddav/`**(RFC 6764 原话是
* "redirect to the context path"):它的 PROPFIND 里同时带了
* current-user-principal、addressbook-home-set 与通讯录清单,
* 三种客户端一次都满足。
*/
if ($path === '/.well-known/carddav') {
http_response_code(301);
header('Location: /carddav/');
exit;
}
$who = Auth::davAuth($user, $pass);
if (!$who) {
header('WWW-Authenticate: Basic realm="FamilyBook DAV"');
cd_fail(401, '需要 DAV 口令(在「个人设置 → DAV 同步」页生成,不是登录密码)。');
}
$familyId = (int)$who['family_id'];
$me = (int)$who['person_id'];
/** 统一的尾斜杠形式,供"发现链路径"比对(`/carddav` 与 `/carddav/` 视为同一个) */
$norm = '/' . trim($path, '/') . '/';
/**
* 发现链节点。
* ⚠️⚠️ `$isRoot` **只**指**站点根 `/`**,不含 `/carddav/` —— 与 dav.php 同一个坑
* (本批实测):把 `/carddav/` 也算成 root 的话,`PROPFIND /carddav/`
* 只回一行 principal、**一本通讯录都不列**,客户端仍然同步不出数据。
*/
$isRoot = ($path === '/' || $path === '');
$isPrincipal = ($norm === CardDav::PRINCIPAL_PATH);
$isHome = ($norm === CardDav::HOME_PATH);
$hit = CardDav::parsePath($path);
$bookId = (int)$hit['book'];
$cardId = (int)$hit['card'];
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
// ⚠️ ADDRESSBOOK(老客户端会发这个非标准方法)也一起挡掉:它没有定义,
// 放过去会落到最后的 405,不如在这里给一句明确的"我们只读"。
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCOL', 'POST', 'PATCH', 'ADDRESSBOOK'], true)) {
cd_fail(403, '家书的通讯录目前是只读同步:请在本站的名册里增删改联系人。');
}
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
if ($method === 'OPTIONS') {
http_response_code(200);
// addressbook 是 CardDAV 的能力声明(RFC 6352 §6.1);
// 带上 3, extended-mkcol 让个别客户端不再试写方法。
header('DAV: 1, 2, 3, addressbook');
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
header('MS-Author-Via: DAV');
exit;
}
// ---- 4. 找不到那一本 → 404(不要回 200 空列表:客户端会以为"这本是空的")----
// ⚠️ 发现链节点(principal / addressbooks)**不是通讯录 id**,
// parsePath 会把它们解析成 0 —— 必须先把它们放行,否则下面这句把客户端挡在门外。
$isDiscovery = $isRoot || $isPrincipal || $isHome;
if (!$isDiscovery && $bookId > 0
&& !in_array($bookId, array_column(CardDav::books($familyId, $me), 'id'), true)) {
cd_fail(404, '没有这本通讯录。');
}
$vcfOfBook = static function (int $bid) use ($familyId, $me): string {
// 家庭场景只有一本,任何合法 id 都回同一份内容 —— 但**必须校验 id 合法**,
// 否则 /carddav/999.vcf/ 也能拉到数据,客户端会记下一个"错的地址"。
return CardDav::vcf($familyId, $me);
};
$readOnlyPriv = '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege>'
. '</d:current-user-privilege-set>';
$emit = static function (array $parts): void {
CardDav::multis();
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
. '<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">'
. implode('', $parts) . '</d:multistatus>';
};
// ---- 5. PROPFIND:发现链 + 清单 / 属性 / 资源列表 ----
if ($method === 'PROPFIND') {
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
// 5a. `PROPFIND /`(站点根)→ 只回 current-user-principal
// (RFC 6764 §6:客户端把它当"从这个域名开始找 CardDAV 服务"的起点)
if ($isRoot) {
$emit([
'<d:response><d:href>/</d:href><d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
// 5b. principal:客户端拿 addressbook-home-set 才知道"去哪列通讯录"
// ⚠️ 同样要回指向自己的 current-user-principal(理由见 dav.php 同段注释)。
if ($isPrincipal) {
$emit([
'<d:response><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:principal/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
// 5c. home-set(以及 `/carddav/` 本身):列出这个人可见的通讯录。
// ⚠️ `/carddav/` 也走这条路:用户与老客户端都直接把 /carddav/ 当 home 用。
if ($isHome || $bookId === 0) {
// 🔴🔴 2026-10-11:与 dav.php 同一个协议隐患 —— **Depth:0 只应回资源自身**。
// 原来无论 depth 都把全部通讯录一起回,线上实测 `PROPFIND /carddav/ Depth:0`
// 第一条 response 是通讯录(不含 current-user-principal),
// 严格客户端可能判"拿不到 principal"。
// ⇒ Depth:0 只回 `/carddav/` 自身(principal / home-set 都在里面)。
if ($depth !== '1') {
$emit([
'<d:response><d:href>' . CardDav::x(CardDav::HOME_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
$parts = [];
// 🔴 v33:**始终**先输出 home 集合自身。
// RFC 4918 规定:PROPFIND 的响应**必须包含被请求的资源本身**,
// 哪怕 Depth:1 也一样 —— 只有先有"这个集合存在"的证据,
// 客户端才会继续读它的成员。原来只在 `!$isHome`(即请求 `/carddav/`)时
// 才输出,于是 `PROPFIND /carddav/addressbooks/`(Thunderbird 走的就是这条)
// 只回子集、没有自己 ⇒ 严格客户端判定"这个 home 不存在" ⇒ 通讯录空。
// `/carddav/`(不是 home 路径)那一次附带发现链三件套(保持原样)。
$parts[] = '<d:response><d:href>' . CardDav::x(CardDav::HOME_PATH) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>';
// 首次请求 `/carddav/`(不是 home 路径)时,另加一条指向 `/carddav/` 自身的响应,
// 客户端会拿它当后续请求的 base。
if (!$isHome) {
$parts[] = '<d:response><d:href>/carddav/</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/></d:resourcetype>'
. '<d:displayname>' . CardDav::x('家书通讯录') . '</d:displayname>'
. '<d:current-user-principal><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:current-user-principal>'
. '<d:principal-URL><d:href>' . CardDav::x(CardDav::PRINCIPAL_PATH)
. '</d:href></d:principal-URL>'
. '<card:addressbook-home-set><d:href>' . CardDav::x(CardDav::HOME_PATH)
. '</d:href></card:addressbook-home-set>'
. '</d:prop></d:propstat></d:response>';
}
foreach (CardDav::books($familyId, $me) as $b) {
$parts[] = '<d:response><d:href>' . CardDav::x(CardDav::hrefOf($b)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
. '<d:displayname>' . CardDav::x((string)$b['name']) . '</d:displayname>'
. '<card:addressbook-description>'
. CardDav::x((string)$b['description']) . '</card:addressbook-description>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>';
}
$emit($parts);
exit;
}
if ($depth === '1') {
// 某本里的卡片清单:客户端拿这个 ETag 判断"要不要重新拉"
$rows = CardDav::rows($familyId, $me);
$parts = [];
foreach ($rows as $r) {
$rid = (int)($r['id'] ?? 0);
if ($rid <= 0) {
continue;
}
$card = CardDav::cardOf($r);
// ⚠️ 资源名用 **persons.id**(不是 UID):URL 与正文 UID 可以不同,
// 但用 id 让 href 稳定、且与 CardDav::one 的入参一一对应。
$parts[] = '<d:response><d:href>'
. CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. '<d:resourcetype/>'
. '<d:getcontenttype>text/vcard; charset=utf-8</d:getcontenttype>'
. '<d:getcontentlength>' . strlen($card['body']) . '</d:getcontentlength>'
. '</d:prop></d:propstat></d:response>';
}
$emit($parts);
exit;
}
// Depth 0:这本通讯录自身的属性
$book = null;
foreach (CardDav::books($familyId, $me) as $b) {
if ((int)$b['id'] === $bookId) {
$book = $b;
}
}
$emit([
'<d:response><d:href>' . CardDav::x(CardDav::bookHref($bookId)) . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:resourcetype><d:collection/><card:addressbook/></d:resourcetype>'
. '<d:displayname>' . CardDav::x((string)($book['name'] ?? '家庭名册')) . '</d:displayname>'
. $readOnlyPriv
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
// ---- 6. GET / HEAD:整本导出成一个 vcf,或单张卡 ----
if ($method === 'GET' || $method === 'HEAD') {
if ($bookId === 0) {
cd_fail(404, '没有这本通讯录。');
}
// 6a. 单张卡(/carddav/1.vcf/12.vcf)。
// 🔴 v31:与 CalDAV 同一个坑 —— 原来**没有**这个分支,
// 于是 `GET /carddav/1.vcf/12.vcf` 会落进下面的"整本导出",
// 一次回 801 张卡(162 KB)。客户端取一张卡却收到整本,
// 要么把整本当成这张卡的正文,要么因 UID 不匹配判定"资源不存在"。
if ($cardId > 0) {
$one = CardDav::one($familyId, $me, $cardId);
if (!$one) {
cd_fail(404, '没有这张联系人卡片。');
}
$card = CardDav::cardOf($one);
$etag = $card['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/vcard; charset=utf-8');
header('ETag: ' . $etag);
if ($method === 'GET') {
echo $card['body'] . "\r\n";
}
exit;
}
// 6b. 整本导出成一个 vcf(老客户端走这条)
$vcf = $vcfOfBook($bookId);
$etag = CardDav::etag($vcf);
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
http_response_code(304);
header('ETag: ' . $etag);
exit;
}
http_response_code(200);
// text/vcard(RFC 6352 用这个 MIME;个别老客户端认 text/x-vcard)
header('Content-Type: text/vcard; charset=utf-8');
header('ETag: ' . $etag);
header('Cache-Control: private, max-age=0, must-revalidate');
header('Content-Disposition: inline; filename="familybook.vcf"');
if ($method === 'GET') {
echo $vcf;
}
exit;
}
// ---- 7. REPORT:addressbook-query(按条件) / addressbook-multiget(按 href 取)----
if ($method === 'REPORT') {
if ($bookId === 0) {
cd_fail(404, '没有这本通讯录。');
}
$bodyRaw = (string)file_get_contents('php://input');
/* 🔴 v33:vCard 正文的属性名要**照客户端点名的那个**回。
· `<card:addressbook-data>` 是 **RFC 6352** 的正式名(Thunderbird / Evolution 要它);
· `<card:address-data>` 是早期草案名(老客户端可能还在用)。
原来只发旧名 → Thunderbird 收到 0 个数据块、**通讯录永远是空的**
(用户报「thunderbird 提示没有任何联系人」的真根因)。
⚠️ 别两个都无脑发:正文会重复一份,客户端侧"卡数"翻倍
(e2e 第22条 `BEGIN:VCARD` 计数当场抓到)。
⚠️ 客户端没点名时(如 `addressbook-query` 空 body)默认发**正式名**。 */
$cardDataXml = static function (array $card) use ($bodyRaw): string {
$wantAB = stripos($bodyRaw, 'addressbook-data') !== false;
$wantAD = stripos($bodyRaw, 'address-data') !== false;
if (!$wantAB && !$wantAD) {
$wantAB = true; // 没点名 → RFC 正式名
}
$body = CardDav::x($card['body'] . "\r\n"); // 结尾 CRLF 只加在正文,不参与 ETag
$xml = '';
if ($wantAB) {
$xml .= '<card:addressbook-data>' . $body . '</card:addressbook-data>';
}
if ($wantAD) {
$xml .= '<card:address-data>' . $body . '</card:address-data>';
}
return $xml;
};
// 单张卡请求(/carddav/1.vcf/12.vcf)
if ($cardId > 0) {
$one = CardDav::one($familyId, $me, $cardId);
if (!$one) {
cd_fail(404, '没有这张联系人卡片。');
}
/* ⚠️ 正文与 ETag 都从 cardOf 取(**同一处**算),别在这里另算一次 ——
v24 第 22 批实测:PROPFIND 用不带结尾 CRLF 的正文算 ETag、这里用带 CRLF 的算,
同一张卡两个 ETag → 客户端认为它一直在变、反复重拉(同步不收敛)。
结尾那条 CRLF 只加在**正文**(数据属性要有完整的 vCard 文本),
不参与 ETag。
🔴 v33:属性名由 `$cardDataXml` 按客户端点名决定(见上面的闭包)。 */
$card = CardDav::cardOf($one);
$emit([
'<d:response><d:href>'
. CardDav::x(CardDav::bookHref($bookId) . $cardId . '.vcf') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. $cardDataXml($card)
. '</d:prop></d:propstat></d:response>',
]);
exit;
}
// ⚠️ multiget 时客户端会点明要哪几张(href 里带 id)。
// v31 起**真的按 href 过滤**(原来"整本给、客户端自己挑")——
// href 现在完全确定(`/carddav/<book>/<persons.id>.vcf`),
// 只要最后一段就能反查,不必猜"绝对 URL / 前缀 / 编码"。
$wantsMulti = stripos($bodyRaw, 'addressbook-multiget') !== false;
$wantIds = [];
if ($wantsMulti && 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('/\.vcf$/', '', (string)basename(rtrim($h, '/')));
if ($seg !== '' && ctype_digit($seg)) {
$wantIds[(int)$seg] = true;
}
}
}
$rows = CardDav::rows($familyId, $me);
$parts = [];
foreach ($rows as $r) {
$rid = (int)($r['id'] ?? 0);
if ($rid <= 0) {
continue;
}
if ($wantsMulti && $wantIds && !isset($wantIds[$rid])) {
continue; // multiget:只要点名的
}
$card = CardDav::cardOf($r);
$parts[] = '<d:response><d:href>'
. CardDav::x(CardDav::bookHref($bookId) . $rid . '.vcf') . '</d:href>'
. '<d:propstat><d:prop>'
. '<d:getetag>' . CardDav::x($card['etag']) . '</d:getetag>'
. $cardDataXml($card)
. '</d:prop></d:propstat></d:response>';
}
unset($bodyRaw);
$emit($parts);
exit;
}
// ---- 8. 其它方法一律 405(并告知支持哪些)----
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
cd_fail(405, '不支持这个请求方法。');