Improve Thunderbird sync deletes and gate CalDAV tracing by diagnostics

This commit is contained in:
Adrian Stephens 2026-04-03 10:52:15 +01:00
parent 6354c8b4c5
commit 1b803f077b
4 changed files with 249 additions and 11 deletions

View File

@ -2014,12 +2014,20 @@ HTML
private function serveCalDavPath(string $path, string $method): void
{
$userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$caldavUser = $this->resolveCalDavUserForRequest(null);
$this->caldavTrace('request', [
'method' => $method,
'path' => $path,
'user_agent' => $userAgent,
'authorized' => $caldavUser !== null,
]);
if ($caldavUser === null) {
http_response_code(401);
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>auth required</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 401, 'reason' => 'auth_required']);
return;
}
@ -2046,6 +2054,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_head']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2053,11 +2062,13 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource]);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource]);
return;
}
}
@ -2078,6 +2089,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_get']);
return;
}
}
@ -2086,6 +2098,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'options']);
return;
}
@ -2094,18 +2107,22 @@ HTML
http_response_code(207);
if ($path === $root || $path === $root . '/') {
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_root']);
return;
}
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal_collection']);
return;
}
if ($path === $principal || $path === rtrim($principal, '/')) {
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal']);
return;
}
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_calendars_root']);
return;
}
if ($path === rtrim($collection, '/')) {
@ -2113,6 +2130,7 @@ HTML
}
if ($path === $collection) {
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_collection']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2121,21 +2139,33 @@ HTML
if ($obj === null) {
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'kind' => 'propfind_not_found']);
return;
}
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
$body = (string) file_get_contents('php://input');
$reportType = $this->caldavReportType($body);
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo $this->caldavReportXml($collection, $body, $this->caldavSyncToken());
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => 207,
'kind' => 'report',
'report_type' => $reportType,
'body_bytes' => strlen($body),
]);
return;
}
@ -2145,12 +2175,14 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
echo (string) ($obj['ics'] ?? '');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
if ($method === 'PUT') {
@ -2169,6 +2201,14 @@ HTML
http_response_code((int) ($error['status'] ?? 500));
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>' . esc_html((string) ($error['message'] ?? 'error')) . '</message></error>';
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
$status = (int) ($result['status'] ?? 204);
@ -2177,6 +2217,14 @@ HTML
header('ETag: ' . (string) $event['etag']);
}
http_response_code($status);
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => $status,
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
if ($method === 'DELETE') {
@ -2184,15 +2232,24 @@ HTML
if (isset($result['error'])) {
$error = (array) $result['error'];
http_response_code((int) ($error['status'] ?? 500));
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_delete',
]);
return;
}
http_response_code(204);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 204, 'resource' => $resource, 'kind' => 'object_delete']);
return;
}
}
http_response_code(405);
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 405, 'reason' => 'method_not_allowed']);
}
private function caldavPropfindRootXml(string $root, string $principal, string $calendarsRoot, string $collection): string
@ -2319,6 +2376,10 @@ HTML
$bodyLower = strtolower($xmlBody);
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
$items = [];
$reportType = $this->caldavReportType($xmlBody);
$includeDeleted = false;
$deletedCount = 0;
$clientSyncToken = '';
if (str_contains($bodyLower, 'sync-collection')) {
$clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
@ -2326,9 +2387,18 @@ HTML
if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) {
$items = [];
} else {
// Fallback implementation: emit current objects for initial/out-of-date tokens.
// Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures.
// Emit current objects for initial/out-of-date tokens.
$items = $this->calDavService->multiget($resources);
// For incremental syncs, include a bounded set of deleted hrefs so clients can remove local copies.
if ($clientSyncToken !== '') {
$includeDeleted = true;
// Keep incremental deletes bounded to avoid overwhelming strict clients.
$deletedResources = $this->calDavService->listDeletedResources(20);
$deletedCount = count($deletedResources);
foreach ($deletedResources as $deletedResource) {
$items[] = ['resource' => $deletedResource, 'status' => 404];
}
}
}
} elseif (str_contains($bodyLower, 'calendar-query')) {
$items = $this->calDavService->multiget($resources);
@ -2359,17 +2429,38 @@ HTML
}
$responses = '';
$okCount = 0;
$notFoundCount = 0;
foreach ($items as $item) {
$status = (int) ($item['status'] ?? 404);
$resource = (string) ($item['resource'] ?? '');
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>';
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href>';
if ($status === 200) {
$okCount++;
$responses .= '<D:propstat><D:prop>';
$responses .= '<D:getetag>' . htmlspecialchars((string) ($item['etag'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
. '<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">' . htmlspecialchars((string) ($item['ics'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</C:calendar-data>';
$responses .= '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>';
} else {
$notFoundCount++;
// For sync-collection deletions, clients expect bare status (no propstat block).
if ($reportType === 'sync-collection') {
$responses .= '<D:status>HTTP/1.1 404 Not Found</D:status>';
} else {
$responses .= '<D:propstat><D:prop></D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>';
}
$responses .= '</D:prop><D:status>HTTP/1.1 ' . $status . ($status === 200 ? ' OK' : ' Not Found')
. '</D:status></D:propstat></D:response>';
}
$responses .= '</D:response>';
}
$this->caldavTrace('report', [
'report_type' => $reportType,
'client_sync_token_present' => $clientSyncToken !== '',
'client_sync_token_matches' => $clientSyncToken !== '' && $clientSyncToken === $syncToken,
'include_deleted' => $includeDeleted,
'deleted_included_count' => $deletedCount,
'response_ok_count' => $okCount,
'response_not_found_count' => $notFoundCount,
]);
return '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:"><D:sync-token>'
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
. '</D:sync-token>'
@ -2425,6 +2516,34 @@ HTML
return $token;
}
private function caldavReportType(string $xmlBody): string
{
$bodyLower = strtolower($xmlBody);
if (str_contains($bodyLower, 'sync-collection')) {
return 'sync-collection';
}
if (str_contains($bodyLower, 'calendar-query')) {
return 'calendar-query';
}
if (str_contains($bodyLower, 'calendar-multiget')) {
return 'calendar-multiget';
}
return 'other';
}
private function caldavTrace(string $event, array $context = []): void
{
if (!$this->isDiagnosticsEnabled()) {
return;
}
$payload = [
'event' => $event,
'at' => gmdate('c'),
'context' => $context,
];
error_log('[calendar-plugin][caldav] ' . wp_json_encode($payload));
}
private function unwrapServiceResult(array $result): array|\WP_Error
{
if (isset($result['error']) && is_array($result['error'])) {

View File

@ -15,6 +15,6 @@ e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastr
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
0cc96b252ac86e91a724e73beecfe6f57a1afdb6f5d9a26e44aed00a3f0e20f0 ./src/Plugin.php
b0e95460e705b9059bf2f197c17f17c51c9bd5492ca69eaa073ebbb808542fe0 ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -2014,12 +2014,20 @@ HTML
private function serveCalDavPath(string $path, string $method): void
{
$userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$caldavUser = $this->resolveCalDavUserForRequest(null);
$this->caldavTrace('request', [
'method' => $method,
'path' => $path,
'user_agent' => $userAgent,
'authorized' => $caldavUser !== null,
]);
if ($caldavUser === null) {
http_response_code(401);
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>auth required</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 401, 'reason' => 'auth_required']);
return;
}
@ -2046,6 +2054,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_head']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2053,11 +2062,13 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource]);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource]);
return;
}
}
@ -2078,6 +2089,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_get']);
return;
}
}
@ -2086,6 +2098,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'options']);
return;
}
@ -2094,18 +2107,22 @@ HTML
http_response_code(207);
if ($path === $root || $path === $root . '/') {
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_root']);
return;
}
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal_collection']);
return;
}
if ($path === $principal || $path === rtrim($principal, '/')) {
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal']);
return;
}
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_calendars_root']);
return;
}
if ($path === rtrim($collection, '/')) {
@ -2113,6 +2130,7 @@ HTML
}
if ($path === $collection) {
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_collection']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2121,21 +2139,33 @@ HTML
if ($obj === null) {
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'kind' => 'propfind_not_found']);
return;
}
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
$body = (string) file_get_contents('php://input');
$reportType = $this->caldavReportType($body);
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo $this->caldavReportXml($collection, $body, $this->caldavSyncToken());
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => 207,
'kind' => 'report',
'report_type' => $reportType,
'body_bytes' => strlen($body),
]);
return;
}
@ -2145,12 +2175,14 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
echo (string) ($obj['ics'] ?? '');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
if ($method === 'PUT') {
@ -2169,6 +2201,14 @@ HTML
http_response_code((int) ($error['status'] ?? 500));
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>' . esc_html((string) ($error['message'] ?? 'error')) . '</message></error>';
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
$status = (int) ($result['status'] ?? 204);
@ -2177,6 +2217,14 @@ HTML
header('ETag: ' . (string) $event['etag']);
}
http_response_code($status);
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => $status,
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
if ($method === 'DELETE') {
@ -2184,15 +2232,24 @@ HTML
if (isset($result['error'])) {
$error = (array) $result['error'];
http_response_code((int) ($error['status'] ?? 500));
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_delete',
]);
return;
}
http_response_code(204);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 204, 'resource' => $resource, 'kind' => 'object_delete']);
return;
}
}
http_response_code(405);
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 405, 'reason' => 'method_not_allowed']);
}
private function caldavPropfindRootXml(string $root, string $principal, string $calendarsRoot, string $collection): string
@ -2319,6 +2376,10 @@ HTML
$bodyLower = strtolower($xmlBody);
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
$items = [];
$reportType = $this->caldavReportType($xmlBody);
$includeDeleted = false;
$deletedCount = 0;
$clientSyncToken = '';
if (str_contains($bodyLower, 'sync-collection')) {
$clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
@ -2326,9 +2387,18 @@ HTML
if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) {
$items = [];
} else {
// Fallback implementation: emit current objects for initial/out-of-date tokens.
// Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures.
// Emit current objects for initial/out-of-date tokens.
$items = $this->calDavService->multiget($resources);
// For incremental syncs, include a bounded set of deleted hrefs so clients can remove local copies.
if ($clientSyncToken !== '') {
$includeDeleted = true;
// Keep incremental deletes bounded to avoid overwhelming strict clients.
$deletedResources = $this->calDavService->listDeletedResources(20);
$deletedCount = count($deletedResources);
foreach ($deletedResources as $deletedResource) {
$items[] = ['resource' => $deletedResource, 'status' => 404];
}
}
}
} elseif (str_contains($bodyLower, 'calendar-query')) {
$items = $this->calDavService->multiget($resources);
@ -2359,17 +2429,38 @@ HTML
}
$responses = '';
$okCount = 0;
$notFoundCount = 0;
foreach ($items as $item) {
$status = (int) ($item['status'] ?? 404);
$resource = (string) ($item['resource'] ?? '');
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>';
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href>';
if ($status === 200) {
$okCount++;
$responses .= '<D:propstat><D:prop>';
$responses .= '<D:getetag>' . htmlspecialchars((string) ($item['etag'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
. '<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">' . htmlspecialchars((string) ($item['ics'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</C:calendar-data>';
$responses .= '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>';
} else {
$notFoundCount++;
// For sync-collection deletions, clients expect bare status (no propstat block).
if ($reportType === 'sync-collection') {
$responses .= '<D:status>HTTP/1.1 404 Not Found</D:status>';
} else {
$responses .= '<D:propstat><D:prop></D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>';
}
$responses .= '</D:prop><D:status>HTTP/1.1 ' . $status . ($status === 200 ? ' OK' : ' Not Found')
. '</D:status></D:propstat></D:response>';
}
$responses .= '</D:response>';
}
$this->caldavTrace('report', [
'report_type' => $reportType,
'client_sync_token_present' => $clientSyncToken !== '',
'client_sync_token_matches' => $clientSyncToken !== '' && $clientSyncToken === $syncToken,
'include_deleted' => $includeDeleted,
'deleted_included_count' => $deletedCount,
'response_ok_count' => $okCount,
'response_not_found_count' => $notFoundCount,
]);
return '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:"><D:sync-token>'
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
. '</D:sync-token>'
@ -2425,6 +2516,34 @@ HTML
return $token;
}
private function caldavReportType(string $xmlBody): string
{
$bodyLower = strtolower($xmlBody);
if (str_contains($bodyLower, 'sync-collection')) {
return 'sync-collection';
}
if (str_contains($bodyLower, 'calendar-query')) {
return 'calendar-query';
}
if (str_contains($bodyLower, 'calendar-multiget')) {
return 'calendar-multiget';
}
return 'other';
}
private function caldavTrace(string $event, array $context = []): void
{
if (!$this->isDiagnosticsEnabled()) {
return;
}
$payload = [
'event' => $event,
'at' => gmdate('c'),
'context' => $context,
];
error_log('[calendar-plugin][caldav] ' . wp_json_encode($payload));
}
private function unwrapServiceResult(array $result): array|\WP_Error
{
if (isset($result['error']) && is_array($result['error'])) {