Release: CalDAV sync bundle hardening and LMW sync
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, '/');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.'"';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user