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
+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.'"';
}
}