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