Release: CalDAV sync bundle hardening and LMW sync

This commit is contained in:
Jürgen Mummert
2026-03-27 22:16:48 +01:00
commit c6f63a56a9
36 changed files with 2993 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Parser;
use DOMDocument;
use DOMXPath;
use RuntimeException;
final class CalDavXmlParser
{
/**
* @return list<array{href:string, etag:string, calendarData:string}>
*/
public function parseCalendarMultistatus(string $xml): array
{
if ('' === trim($xml)) {
return [];
}
$document = new DOMDocument();
$loaded = @$document->loadXML($xml);
if (false === $loaded) {
throw new RuntimeException('Invalid WebDAV XML response.');
}
$xpath = new DOMXPath($document);
$xpath->registerNamespace('d', 'DAV:');
$xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav');
$responseNodes = $xpath->query('/d:multistatus/d:response');
$items = [];
if (false === $responseNodes) {
return [];
}
foreach ($responseNodes as $responseNode) {
$href = trim((string) $xpath->evaluate('string(d:href)', $responseNode));
$etag = trim((string) $xpath->evaluate('string(d:propstat/d:prop/d:getetag)', $responseNode));
$calendarData = trim((string) $xpath->evaluate('string(d:propstat/d:prop/c:calendar-data)', $responseNode));
if ('' === $href || '' === $calendarData) {
continue;
}
$items[] = [
'href' => $href,
'etag' => trim($etag, '"'),
'calendarData' => $calendarData,
];
}
return $items;
}
public function parseCurrentUserPrincipalHref(string $xml): ?string
{
$xpath = $this->createXPath($xml);
return $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/d:current-user-principal/d:href)'));
}
public function parseCalendarHomeSetHref(string $xml): ?string
{
$xpath = $this->createXPath($xml);
return $this->stringOrNull($xpath->evaluate('string(/d:multistatus/d:response/d:propstat/d:prop/c:calendar-home-set/d:href)'));
}
/**
* @return list<array{href:string,displayName:string}>
*/
public function parseCalendarCollections(string $xml): array
{
$xpath = $this->createXPath($xml);
$responseNodes = $xpath->query('/d:multistatus/d:response');
if (false === $responseNodes) {
return [];
}
$collections = [];
foreach ($responseNodes as $responseNode) {
$isCalendar = (bool) $xpath->evaluate('boolean(d:propstat/d:prop/d:resourcetype/c:calendar)', $responseNode);
if (!$isCalendar) {
continue;
}
$href = trim((string) $xpath->evaluate('string(d:href)', $responseNode));
if ('' === $href) {
continue;
}
$displayName = trim((string) $xpath->evaluate('string(d:propstat/d:prop/d:displayname)', $responseNode));
if ('' === $displayName) {
$displayName = $href;
}
$collections[] = [
'href' => $href,
'displayName' => $displayName,
];
}
return $collections;
}
private function createXPath(string $xml): DOMXPath
{
if ('' === trim($xml)) {
throw new RuntimeException('Empty WebDAV XML response.');
}
$document = new DOMDocument();
$loaded = @$document->loadXML($xml);
if (false === $loaded) {
throw new RuntimeException('Invalid WebDAV XML response.');
}
$xpath = new DOMXPath($document);
$xpath->registerNamespace('d', 'DAV:');
$xpath->registerNamespace('c', 'urn:ietf:params:xml:ns:caldav');
return $xpath;
}
private function stringOrNull(mixed $value): ?string
{
$normalized = trim((string) $value);
return '' !== $normalized ? $normalized : null;
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Parser;
use DateTimeImmutable;
use DateTimeZone;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use RuntimeException;
use Sabre\VObject\Reader;
final class ICalendarParser
{
public function parseEvent(string $href, string $etag, string $ics, string $defaultTimezone): ?RemoteEvent
{
$vcalendar = Reader::read($ics);
$components = $vcalendar->getComponents();
foreach ($components as $component) {
if ('VEVENT' !== $component->name) {
continue;
}
$uid = trim((string) ($component->UID ?? ''));
if ('' === $uid) {
continue;
}
$hasTitle = isset($component->SUMMARY);
$hasDescription = isset($component->DESCRIPTION);
$hasLocation = isset($component->LOCATION);
$hasUrl = isset($component->URL);
$title = trim((string) ($component->SUMMARY ?? ''));
$description = trim((string) ($component->DESCRIPTION ?? ''));
$location = trim((string) ($component->LOCATION ?? ''));
$url = isset($component->URL) ? trim((string) $component->URL) : null;
$url = '' === $url ? null : $url;
$dtStart = $component->DTSTART ?? null;
$dtEnd = $component->DTEND ?? null;
$lastModifiedAt = $this->resolveLastModifiedTimestamp($component);
if (null === $dtStart || null === $dtEnd) {
throw new RuntimeException(sprintf('Invalid VEVENT without DTSTART/DTEND for href %s', $href));
}
$startDate = DateTimeImmutable::createFromInterface($dtStart->getDateTime());
$endDate = DateTimeImmutable::createFromInterface($dtEnd->getDateTime());
$allDay = 'DATE' === strtoupper((string) $dtStart['VALUE']);
$timezone = $defaultTimezone;
if (isset($dtStart['TZID'])) {
$timezone = (string) $dtStart['TZID'];
}
if ($allDay) {
$tz = new DateTimeZone($timezone);
$startDate = $startDate->setTimezone($tz)->setTime(0, 0);
$endDate = $endDate->setTimezone($tz)->setTime(0, 0);
}
return new RemoteEvent(
$href,
$uid,
$etag,
$lastModifiedAt,
$hasTitle,
$hasDescription,
$hasLocation,
$hasUrl,
$title,
$description,
$location,
$url,
$startDate->getTimestamp(),
$endDate->getTimestamp(),
$allDay,
$timezone,
);
}
return null;
}
private function resolveLastModifiedTimestamp(object $component): int
{
try {
if (isset($component->{'LAST-MODIFIED'})) {
return DateTimeImmutable::createFromInterface($component->{'LAST-MODIFIED'}->getDateTime())->getTimestamp();
}
if (isset($component->DTSTAMP)) {
return DateTimeImmutable::createFromInterface($component->DTSTAMP->getDateTime())->getTimestamp();
}
} catch (\Throwable) {
// Use fallback below.
}
return 0;
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Parser;
use DateTimeImmutable;
use DateTimeZone;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use Sabre\VObject\Component\VCalendar;
final class ICalendarSerializer
{
public function serializeEvent(RemoteEvent $event): string
{
$lastModifiedAt = $event->lastModifiedAt > 0 ? $event->lastModifiedAt : time();
$vcalendar = new VCalendar();
$vevent = $vcalendar->add('VEVENT', [
'UID' => $event->uid,
'SUMMARY' => $event->title,
'DESCRIPTION' => $event->description,
'LOCATION' => $event->location,
'LAST-MODIFIED' => gmdate('Ymd\\THis\\Z', $lastModifiedAt),
'DTSTAMP' => gmdate('Ymd\\THis\\Z'),
]);
if (null !== $event->url && '' !== trim($event->url)) {
$vevent->add('URL', $event->url);
}
if ($event->allDay) {
$start = (new DateTimeImmutable('@'.$event->startAt))->setTimezone(new DateTimeZone($event->timezone));
$end = (new DateTimeImmutable('@'.$event->endAt))->setTimezone(new DateTimeZone($event->timezone));
if ($end <= $start) {
$end = $start->modify('+1 day');
}
$vevent->add('DTSTART', $start->format('Ymd'), ['VALUE' => 'DATE']);
$vevent->add('DTEND', $end->format('Ymd'), ['VALUE' => 'DATE']);
} else {
$startUtc = (new DateTimeImmutable('@'.$event->startAt))->setTimezone(new DateTimeZone('UTC'));
$endUtc = (new DateTimeImmutable('@'.$event->endAt))->setTimezone(new DateTimeZone('UTC'));
if ($endUtc <= $startUtc) {
$endUtc = $startUtc->modify('+1 hour');
}
$vevent->add('DTSTART', $startUtc->format('Ymd\\THis\\Z'));
$vevent->add('DTEND', $endUtc->format('Ymd\\THis\\Z'));
}
return $vcalendar->serialize();
}
}
+244
View File
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
use Mummert\CalDavSyncBundle\CalDav\Parser\CalDavXmlParser;
use Mummert\CalDavSyncBundle\CalDav\Transport\CalDavTransportInterface;
use Psr\Log\LoggerInterface;
final readonly class RemoteCalendarLister
{
public function __construct(
private CalDavTransportInterface $transport,
private CalDavXmlParser $xmlParser,
private LoggerInterface $logger,
) {
}
/**
* @return array<string,string>
*/
public function listCalendarOptions(string $baseUrl, string $username, string $password): array
{
$baseUrl = trim($baseUrl);
if ('' === $baseUrl || '' === trim($username)) {
return [];
}
$candidates = array_values(array_unique(array_filter([
$this->discoverCalendarHomeUrl($baseUrl, $username, $password),
$this->inferBaikalCalendarHomeUrl($baseUrl, $username),
$baseUrl,
rtrim($baseUrl, '/').'/',
$this->parentUrl($baseUrl),
])));
$collections = [];
$collectionBaseUrl = $baseUrl;
foreach ($candidates as $candidate) {
$candidateResult = $this->fetchCalendarCollections($candidate, $username, $password);
if ([] === $candidateResult) {
$this->logger->info('CalDAV discovery candidate produced no calendar collections.', ['candidate' => $candidate]);
continue;
}
$collections = $candidateResult;
$collectionBaseUrl = $candidate;
break;
}
$options = [];
foreach ($collections as $collection) {
$absoluteHref = $this->absoluteUrl($collectionBaseUrl, $collection['href']);
$options[$absoluteHref] = sprintf('%s (%s)', $collection['displayName'], $absoluteHref);
}
if ([] === $options) {
$this->logger->warning('CalDAV discovery returned no remote calendars.', ['baseUrl' => $baseUrl]);
$options[$baseUrl] = $baseUrl;
}
return $options;
}
private function discoverCalendarHomeUrl(string $baseUrl, string $username, string $password): ?string
{
try {
$principalResponse = $this->transport->propfind(
$baseUrl,
$username,
$password,
$this->buildPrincipalPropfindBody(),
0,
['Content-Type' => 'application/xml; charset=utf-8'],
);
if (!in_array($principalResponse->statusCode, [200, 207], true)) {
$this->logger->info('CalDAV principal discovery failed.', ['url' => $baseUrl, 'statusCode' => $principalResponse->statusCode]);
return null;
}
$principalHref = $this->xmlParser->parseCurrentUserPrincipalHref($principalResponse->body);
if (null === $principalHref) {
$this->logger->info('CalDAV principal href not found in response.', ['url' => $baseUrl]);
return null;
}
$principalUrl = $this->absoluteUrl($baseUrl, $principalHref);
$homeResponse = $this->transport->propfind(
$principalUrl,
$username,
$password,
$this->buildCalendarHomeSetPropfindBody(),
0,
['Content-Type' => 'application/xml; charset=utf-8'],
);
if (!in_array($homeResponse->statusCode, [200, 207], true)) {
$this->logger->info('CalDAV calendar-home-set discovery failed.', ['url' => $principalUrl, 'statusCode' => $homeResponse->statusCode]);
return null;
}
$homeHref = $this->xmlParser->parseCalendarHomeSetHref($homeResponse->body);
return null !== $homeHref ? $this->absoluteUrl($baseUrl, $homeHref) : null;
} catch (\Throwable) {
$this->logger->info('CalDAV calendar-home-set discovery threw an exception.', ['url' => $baseUrl]);
return null;
}
}
/**
* @return list<array{href:string,displayName:string}>
*/
private function fetchCalendarCollections(string $url, string $username, string $password): array
{
try {
$response = $this->transport->propfind(
$url,
$username,
$password,
$this->buildCollectionPropfindBody(),
1,
['Content-Type' => 'application/xml; charset=utf-8'],
);
if (!in_array($response->statusCode, [200, 207], true)) {
$this->logger->info('CalDAV collection PROPFIND failed.', ['url' => $url, 'statusCode' => $response->statusCode]);
return [];
}
return $this->xmlParser->parseCalendarCollections($response->body);
} catch (\Throwable) {
$this->logger->info('CalDAV collection PROPFIND threw an exception.', ['url' => $url]);
return [];
}
}
private function parentUrl(string $url): ?string
{
$parts = parse_url($url);
if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
return null;
}
$path = isset($parts['path']) ? trim((string) $parts['path']) : '';
if ('' === $path || '/' === $path) {
return null;
}
$segments = explode('/', trim($path, '/'));
array_pop($segments);
$parentPath = '/'.implode('/', $segments);
if ('/' === $parentPath || '' === trim($parentPath, '/')) {
return null;
}
$prefix = $parts['scheme'].'://'.$parts['host'];
if (isset($parts['port'])) {
$prefix .= ':'.$parts['port'];
}
return $prefix.$parentPath;
}
private function inferBaikalCalendarHomeUrl(string $baseUrl, string $username): ?string
{
$parts = parse_url($baseUrl);
if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
return null;
}
$path = (string) ($parts['path'] ?? '');
if ('' === $path) {
return null;
}
$prefix = $parts['scheme'].'://'.$parts['host'];
if (isset($parts['port'])) {
$prefix .= ':'.$parts['port'];
}
return $prefix.'/'.trim($path, '/').'/calendars/'.rawurlencode($username).'/';
}
private function buildPrincipalPropfindBody(): string
{
return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:current-user-principal />
</d:prop>
</d:propfind>
XML;
}
private function buildCalendarHomeSetPropfindBody(): string
{
return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<c:calendar-home-set />
</d:prop>
</d:propfind>
XML;
}
private function buildCollectionPropfindBody(): string
{
return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:displayname />
<d:resourcetype />
</d:prop>
</d:propfind>
XML;
}
private function absoluteUrl(string $baseUrl, string $href): string
{
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
return $href;
}
$base = parse_url($baseUrl);
if (!is_array($base) || !isset($base['scheme'], $base['host'])) {
return $href;
}
$prefix = $base['scheme'].'://'.$base['host'];
if (isset($base['port'])) {
$prefix .= ':'.$base['port'];
}
return $prefix.'/'.ltrim($href, '/');
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
use Mummert\CalDavSyncBundle\CalDav\Parser\CalDavXmlParser;
use Mummert\CalDavSyncBundle\CalDav\Parser\ICalendarParser;
use Mummert\CalDavSyncBundle\CalDav\Transport\CalDavTransportInterface;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use Psr\Log\LoggerInterface;
final readonly class RemoteCalendarReader
{
public function __construct(
private CalDavTransportInterface $transport,
private CalDavXmlParser $xmlParser,
private ICalendarParser $icalendarParser,
private LoggerInterface $logger,
) {
}
/**
* @return list<RemoteEvent>
*/
public function readEvents(CalendarSyncConfig $config): array
{
$response = $this->transport->report(
$config->caldavUrl,
$config->caldavUsername,
$config->caldavPassword,
$this->buildCalendarQueryBody(),
1,
['Content-Type' => 'application/xml; charset=utf-8'],
);
if (!in_array($response->statusCode, [200, 207], true)) {
$this->logger->warning('CalDAV calendar query failed.', [
'calendarId' => $config->calendarId,
'url' => $config->caldavUrl,
'statusCode' => $response->statusCode,
]);
return [];
}
$parsedItems = $this->xmlParser->parseCalendarMultistatus($response->body);
$events = [];
foreach ($parsedItems as $item) {
$event = $this->icalendarParser->parseEvent(
$item['href'],
$item['etag'],
$item['calendarData'],
$config->timezoneOrDefault(),
);
if (null !== $event) {
$events[] = $event;
}
}
return $events;
}
public function readEventByHref(CalendarSyncConfig $config, string $href): ?RemoteEvent
{
$response = $this->transport->get(
$this->absoluteUrl($config->caldavUrl, $href),
$config->caldavUsername,
$config->caldavPassword,
['Accept' => 'text/calendar'],
);
if (200 !== $response->statusCode) {
$this->logger->warning('CalDAV GET by href failed.', [
'calendarId' => $config->calendarId,
'url' => $href,
'statusCode' => $response->statusCode,
]);
return null;
}
return $this->icalendarParser->parseEvent(
$href,
trim((string) $response->header('etag'), '"'),
$response->body,
$config->timezoneOrDefault(),
);
}
private function buildCalendarQueryBody(): string
{
return <<<'XML'
<?xml version="1.0" encoding="utf-8" ?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag />
<c:calendar-data />
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT" />
</c:comp-filter>
</c:filter>
</c:calendar-query>
XML;
}
private function absoluteUrl(string $calendarUrl, string $href): string
{
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
return $href;
}
$base = parse_url($calendarUrl);
if (!is_array($base) || !isset($base['scheme'], $base['host'])) {
return $href;
}
$prefix = $base['scheme'].'://'.$base['host'];
if (isset($base['port'])) {
$prefix .= ':'.$base['port'];
}
return $prefix.$href;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Remote;
use Mummert\CalDavSyncBundle\CalDav\Parser\ICalendarSerializer;
use Mummert\CalDavSyncBundle\CalDav\Transport\CalDavTransportInterface;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Sync\RemoteEvent;
use RuntimeException;
final readonly class RemoteCalendarWriter
{
public function __construct(
private CalDavTransportInterface $transport,
private ICalendarSerializer $serializer,
) {
}
/**
* @return array{href:string,etag:string}
*/
public function upsertEvent(CalendarSyncConfig $config, RemoteEvent $event, ?string $href, ?string $etag, bool $dryRun): array
{
$targetHref = $href ?? $this->buildHref($event->uid);
$targetUrl = $this->absoluteUrl($config->caldavUrl, $targetHref);
if ($dryRun) {
return ['href' => $targetHref, 'etag' => $etag ?? 'dry-run'];
}
$headers = [
'Content-Type' => 'text/calendar; charset=utf-8',
'If-Match' => '' !== trim((string) $etag) ? $this->formatEntityTag((string) $etag) : '*',
];
if ('' === trim((string) $etag)) {
unset($headers['If-Match']);
$headers['If-None-Match'] = '*';
}
$response = $this->transport->put(
$targetUrl,
$config->caldavUsername,
$config->caldavPassword,
$this->serializer->serializeEvent($event),
$headers,
);
if ($response->statusCode < 200 || $response->statusCode >= 300) {
throw new RuntimeException(sprintf('CalDAV PUT failed for %s (status %d).', $targetUrl, $response->statusCode));
}
$newEtag = trim((string) $response->header('etag'), '"');
return [
'href' => $targetHref,
'etag' => '' !== $newEtag ? $newEtag : ($etag ?? ''),
];
}
public function deleteEvent(CalendarSyncConfig $config, string $href, ?string $etag, bool $dryRun): void
{
if ($dryRun) {
return;
}
$headers = [];
if ('' !== trim((string) $etag)) {
$headers['If-Match'] = (string) $etag;
}
$this->transport->delete(
$this->absoluteUrl($config->caldavUrl, $href),
$config->caldavUsername,
$config->caldavPassword,
$headers,
);
}
private function buildHref(string $uid): string
{
return '/'.rawurlencode($uid).'.ics';
}
private function absoluteUrl(string $calendarUrl, string $href): string
{
if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
return $href;
}
if (str_starts_with($href, '/')) {
$base = parse_url($calendarUrl);
if (is_array($base) && isset($base['scheme'], $base['host'])) {
$prefix = $base['scheme'].'://'.$base['host'];
if (isset($base['port'])) {
$prefix .= ':'.$base['port'];
}
return $prefix.$href;
}
}
$trimmedCalendarUrl = rtrim($calendarUrl, '/');
$trimmedHref = ltrim($href, '/');
return $trimmedCalendarUrl.'/'.$trimmedHref;
}
private function formatEntityTag(string $etag): string
{
$trimmed = trim($etag);
if ('' === $trimmed || '*' === $trimmed) {
return $trimmed;
}
if (preg_match('/^W?\/.+$/', $trimmed) || str_starts_with($trimmed, '"')) {
return $trimmed;
}
return '"'.$trimmed.'"';
}
}
+188
View File
@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
use RuntimeException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final readonly class CalDavTransport implements CalDavTransportInterface
{
public function __construct(private HttpClientInterface $httpClient)
{
}
public function propfind(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse
{
$headers = array_merge(['Depth' => (string) $depth], $headers);
return $this->request('PROPFIND', $url, $username, $password, $body, $headers);
}
public function report(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse
{
$headers = array_merge(['Depth' => (string) $depth], $headers);
return $this->request('REPORT', $url, $username, $password, $body, $headers);
}
public function get(string $url, string $username, string $password, array $headers = []): TransportResponse
{
return $this->request('GET', $url, $username, $password, null, $headers);
}
public function put(string $url, string $username, string $password, string $body, array $headers = []): TransportResponse
{
return $this->request('PUT', $url, $username, $password, $body, $headers);
}
public function delete(string $url, string $username, string $password, array $headers = []): TransportResponse
{
return $this->request('DELETE', $url, $username, $password, null, $headers);
}
/**
* @param array<string, string> $headers
*/
private function request(string $method, string $url, string $username, string $password, ?string $body, array $headers): TransportResponse
{
$requestHeaders = [
'Accept' => 'application/xml,text/xml,text/calendar,*/*',
...$headers,
];
try {
$response = $this->httpClient->request($method, $url, [
'auth_basic' => [$username, $password],
'headers' => $requestHeaders,
'body' => $body,
]);
$statusCode = $response->getStatusCode();
$rawHeaders = $response->getHeaders(false);
$normalizedHeaders = [];
foreach ($rawHeaders as $name => $values) {
$normalizedHeaders[strtolower($name)] = implode(', ', $values);
}
if (401 === $statusCode && isset($normalizedHeaders['www-authenticate']) && str_contains(strtolower($normalizedHeaders['www-authenticate']), 'digest')) {
$digestAuthorization = $this->buildDigestAuthorizationHeader(
$normalizedHeaders['www-authenticate'],
$method,
$url,
$username,
$password,
);
if (null !== $digestAuthorization) {
$retryResponse = $this->httpClient->request($method, $url, [
'headers' => [
...$requestHeaders,
'Authorization' => $digestAuthorization,
],
'body' => $body,
]);
$statusCode = $retryResponse->getStatusCode();
$rawHeaders = $retryResponse->getHeaders(false);
$normalizedHeaders = [];
foreach ($rawHeaders as $name => $values) {
$normalizedHeaders[strtolower($name)] = implode(', ', $values);
}
$content = $retryResponse->getContent(false);
return new TransportResponse($statusCode, $content, $normalizedHeaders);
}
}
$content = $response->getContent(false);
return new TransportResponse($statusCode, $content, $normalizedHeaders);
} catch (TransportExceptionInterface $e) {
throw new RuntimeException(sprintf('CalDAV transport error for %s %s: %s', $method, $url, $e->getMessage()), 0, $e);
}
}
private function buildDigestAuthorizationHeader(string $wwwAuthenticateHeader, string $method, string $url, string $username, string $password): ?string
{
$challenge = trim($wwwAuthenticateHeader);
$digestPos = stripos($challenge, 'Digest ');
if (false === $digestPos) {
return null;
}
$challengeBody = trim(substr($challenge, $digestPos + 7));
preg_match_all('/([a-zA-Z0-9_-]+)=("[^"]*"|[^,]+)/', $challengeBody, $matches, PREG_SET_ORDER);
$params = [];
foreach ($matches as $match) {
$params[strtolower($match[1])] = trim($match[2], " \t\n\r\0\x0B\"");
}
$realm = $params['realm'] ?? null;
$nonce = $params['nonce'] ?? null;
if (null === $realm || null === $nonce) {
return null;
}
$qop = null;
if (isset($params['qop'])) {
$qopCandidates = array_map('trim', explode(',', $params['qop']));
$qop = in_array('auth', $qopCandidates, true) ? 'auth' : ('' !== ($qopCandidates[0] ?? '') ? $qopCandidates[0] : null);
}
$algorithm = strtoupper((string) ($params['algorithm'] ?? 'MD5'));
$uri = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
$query = parse_url($url, PHP_URL_QUERY);
if (is_string($query) && '' !== $query) {
$uri .= '?'.$query;
}
$cnonce = bin2hex(random_bytes(8));
$nc = '00000001';
$ha1 = md5($username.':'.$realm.':'.$password);
if ('MD5-SESS' === $algorithm) {
$ha1 = md5($ha1.':'.$nonce.':'.$cnonce);
}
$ha2 = md5($method.':'.$uri);
if (null !== $qop) {
$response = md5($ha1.':'.$nonce.':'.$nc.':'.$cnonce.':'.$qop.':'.$ha2);
} else {
$response = md5($ha1.':'.$nonce.':'.$ha2);
}
$headerParts = [
'Digest username="'.$this->escapeDigestValue($username).'"',
'realm="'.$this->escapeDigestValue($realm).'"',
'nonce="'.$this->escapeDigestValue($nonce).'"',
'uri="'.$this->escapeDigestValue($uri).'"',
'response="'.$response.'"',
'algorithm='.$algorithm,
];
if (isset($params['opaque'])) {
$headerParts[] = 'opaque="'.$this->escapeDigestValue((string) $params['opaque']).'"';
}
if (null !== $qop) {
$headerParts[] = 'qop='.$qop;
$headerParts[] = 'nc='.$nc;
$headerParts[] = 'cnonce="'.$cnonce.'"';
}
return implode(', ', $headerParts);
}
private function escapeDigestValue(string $value): string
{
return addcslashes($value, "\\\"");
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
interface CalDavTransportInterface
{
/**
* @param array<string, string> $headers
*/
public function propfind(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function report(string $url, string $username, string $password, string $body, int $depth = 1, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function get(string $url, string $username, string $password, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function put(string $url, string $username, string $password, string $body, array $headers = []): TransportResponse;
/**
* @param array<string, string> $headers
*/
public function delete(string $url, string $username, string $password, array $headers = []): TransportResponse;
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Mummert\CalDavSyncBundle\CalDav\Transport;
final readonly class TransportResponse
{
/**
* @param array<string, string> $headers
*/
public function __construct(
public int $statusCode,
public string $body,
public array $headers,
) {
}
public function header(string $name): ?string
{
$normalized = strtolower($name);
return $this->headers[$normalized] ?? null;
}
}