Release: LMW delta sync hardening, CTAG windowing, calendar matching guard

This commit is contained in:
Jürgen Mummert
2026-03-28 16:59:58 +01:00
parent c6f63a56a9
commit c0d3bf4c82
15 changed files with 775 additions and 171 deletions
+25 -1
View File
@@ -9,15 +9,23 @@ final class EventMatchResolver
/**
* @param list<array<string,mixed>> $localEvents
*/
public function resolve(array $localEvents, RemoteEvent $remoteEvent): ?array
public function resolve(array $localEvents, RemoteEvent $remoteEvent, ?int $expectedCalendarId = null): ?array
{
foreach ($localEvents as $localEvent) {
if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) {
continue;
}
if ('' !== (string) ($localEvent['caldavHref'] ?? '') && (string) $localEvent['caldavHref'] === $remoteEvent->href) {
return $localEvent;
}
}
foreach ($localEvents as $localEvent) {
if (!$this->matchesExpectedCalendar($localEvent, $expectedCalendarId)) {
continue;
}
if ('' !== (string) ($localEvent['caldavUid'] ?? '') && (string) $localEvent['caldavUid'] === $remoteEvent->uid) {
return $localEvent;
}
@@ -25,4 +33,20 @@ final class EventMatchResolver
return null;
}
/**
* @param array<string,mixed> $row
*/
private function matchesExpectedCalendar(array $row, ?int $expectedCalendarId): bool
{
if (null === $expectedCalendarId) {
return true;
}
if (!array_key_exists('pid', $row)) {
return true;
}
return (int) $row['pid'] === $expectedCalendarId;
}
}
+60 -9
View File
@@ -34,13 +34,30 @@ final readonly class LocalToRemoteSynchronizer
$allLocalEvents,
fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')),
));
$remoteEvents = $this->remoteReader->readEvents($config);
$remotePseudoLocalRows = $this->buildRemotePseudoLocalRows($remoteEvents);
$knownHrefEtags = [];
foreach ($localEvents as $localEvent) {
$href = trim((string) ($localEvent['caldavHref'] ?? ''));
if ('' === $href) {
continue;
}
$knownHrefEtags[$href] = trim((string) ($localEvent['caldavEtag'] ?? ''));
}
$remoteData = $this->remoteReader->readEvents($config, $knownHrefEtags, false);
$remoteEvents = $remoteData->events;
$remotePseudoLocalRows = $this->buildRemotePseudoLocalRows($remoteEvents, $remoteData->hrefEtags);
$localHrefs = [];
$localUids = [];
foreach ($localEvents as $localEvent) {
if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) {
++$result->skipped;
continue;
}
$targetCalendarUrl = $config->resolveTargetCalendarForLocalEvent((string) ($localEvent['caldavCalendarHref'] ?? ''));
if (null === $targetCalendarUrl || $targetCalendarUrl !== $config->caldavUrl) {
++$result->skipped;
@@ -59,7 +76,7 @@ final readonly class LocalToRemoteSynchronizer
$localUids[$uid] = true;
}
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent);
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault());
$currentHash = $this->hashGenerator->generate($localSyncFields);
$storedHash = (string) ($localEvent['caldavSyncHash'] ?? '');
$localChanged = '' === $storedHash || $storedHash !== $currentHash;
@@ -69,7 +86,7 @@ final readonly class LocalToRemoteSynchronizer
continue;
}
$remoteMatch = $this->matchResolver->resolve($remotePseudoLocalRows, $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault()));
$remoteMatch = $this->matchResolver->resolve($remotePseudoLocalRows, $this->fieldExtractor->toRemoteEvent($localEvent, $config->timezoneOrDefault()), $config->calendarId);
$matchingRemoteEvent = $this->resolveRemoteFromMatch($remoteEvents, $remoteMatch);
if (null !== $matchingRemoteEvent && '' !== $storedHash) {
@@ -164,19 +181,19 @@ final readonly class LocalToRemoteSynchronizer
}
$localUids[$payloadEvent->uid] = true;
if (null === $matchingRemoteEvent) {
if (null === $matchingRemoteEvent && null === $remoteMatch) {
++$result->created;
} else {
++$result->updated;
}
}
foreach ($remoteEvents as $remoteEvent) {
if (isset($localHrefs[$remoteEvent->href]) || isset($localUids[$remoteEvent->uid])) {
foreach ($remoteData->hrefEtags as $remoteHref => $remoteEtag) {
if (isset($localHrefs[$remoteHref])) {
continue;
}
$this->remoteWriter->deleteEvent($config, $remoteEvent->href, $remoteEvent->etag, $dryRun);
$this->remoteWriter->deleteEvent($config, $remoteHref, $remoteEtag, $dryRun);
++$result->deleted;
}
@@ -185,13 +202,21 @@ final readonly class LocalToRemoteSynchronizer
/**
* @param list<RemoteEvent> $remoteEvents
* @param array<string,string> $hrefEtags
*
* @return list<array<string,mixed>>
*/
private function buildRemotePseudoLocalRows(array $remoteEvents): array
private function buildRemotePseudoLocalRows(array $remoteEvents, array $hrefEtags): array
{
$rows = [];
foreach ($hrefEtags as $href => $_etag) {
$rows[] = [
'caldavHref' => $href,
'caldavUid' => '',
];
}
foreach ($remoteEvents as $remoteEvent) {
$rows[] = [
'caldavHref' => $remoteEvent->href,
@@ -240,4 +265,30 @@ final readonly class LocalToRemoteSynchronizer
return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS);
}
private function isEventWithinConfiguredWindow(array $localEvent, CalendarSyncConfig $config): bool
{
if (!$config->hasTimeWindow()) {
return true;
}
$start = (int) ($localEvent['startTime'] ?? 0);
if ($start <= 0) {
$start = (int) ($localEvent['startDate'] ?? 0);
}
if ($start <= 0) {
return true;
}
if (null !== $config->syncFromTimestamp && $start < $config->syncFromTimestamp) {
return false;
}
if (null !== $config->syncUntilTimestamp && $start > $config->syncUntilTimestamp) {
return false;
}
return true;
}
}
+65 -3
View File
@@ -6,6 +6,7 @@ namespace Mummert\CalDavSyncBundle\Sync;
use Mummert\CalDavSyncBundle\CalDav\Remote\RemoteCalendarReader;
use Mummert\CalDavSyncBundle\Config\CalendarSyncConfig;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarRepository;
use Mummert\CalDavSyncBundle\Repository\ContaoCalendarEventRepository;
use Psr\Log\LoggerInterface;
@@ -15,6 +16,7 @@ final readonly class RemoteToLocalSynchronizer
public function __construct(
private RemoteCalendarReader $remoteReader,
private ContaoCalendarRepository $calendarRepository,
private ContaoCalendarEventRepository $eventRepository,
private EventMatchResolver $matchResolver,
private SyncFieldExtractor $fieldExtractor,
@@ -26,21 +28,51 @@ final readonly class RemoteToLocalSynchronizer
public function synchronize(CalendarSyncConfig $config, bool $dryRun = false): SyncResult
{
$result = new SyncResult();
$remoteEvents = $this->remoteReader->readEvents($config);
$allLocalEvents = $this->eventRepository->findByCalendarId($config->calendarId);
$localEvents = array_values(array_filter(
$allLocalEvents,
fn (array $event): bool => $config->shouldManageEventForThisRemoteCalendar((string) ($event['caldavCalendarHref'] ?? '')),
));
$knownHrefEtags = [];
foreach ($localEvents as $localEvent) {
$href = trim((string) ($localEvent['caldavHref'] ?? ''));
if ('' === $href) {
continue;
}
$knownHrefEtags[$href] = trim((string) ($localEvent['caldavEtag'] ?? ''));
}
$remoteData = $this->remoteReader->readEvents($config, $knownHrefEtags, true);
$remoteEvents = $remoteData->events;
if (!$dryRun && null !== $remoteData->collectionTag) {
$this->calendarRepository->updateCollectionTagForCalendarUrl(
$config->calendarId,
$config->caldavUrl,
$remoteData->collectionTag,
);
}
if ($remoteData->collectionTagUnchanged) {
$result->skipped += count($localEvents);
return $result;
}
$seenHrefs = [];
$seenUids = [];
foreach (array_keys($remoteData->hrefEtags) as $knownRemoteHref) {
$seenHrefs[$knownRemoteHref] = true;
}
foreach ($remoteEvents as $remoteEvent) {
$seenHrefs[$remoteEvent->href] = true;
$seenUids[$remoteEvent->uid] = true;
$localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent);
$localEvent = $this->matchResolver->resolve($localEvents, $remoteEvent, $config->calendarId);
$remoteSyncFields = $this->fieldExtractor->extractFromRemoteEvent($remoteEvent);
$remoteHash = $this->hashGenerator->generate($remoteSyncFields);
$expectedLocalFields = $this->fieldExtractor->applyRemoteToLocalFields($remoteEvent);
@@ -75,7 +107,7 @@ final readonly class RemoteToLocalSynchronizer
continue;
}
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent);
$localSyncFields = $this->fieldExtractor->extractFromLocalEvent($localEvent, $config->timezoneOrDefault());
$localCurrentHash = $this->hashGenerator->generate($localSyncFields);
$storedHash = (string) ($localEvent['caldavSyncHash'] ?? '');
$storedEtag = (string) ($localEvent['caldavEtag'] ?? '');
@@ -182,6 +214,10 @@ final readonly class RemoteToLocalSynchronizer
continue;
}
if (!$this->isEventWithinConfiguredWindow($localEvent, $config)) {
continue;
}
if (isset($seenHrefs[$localHref]) || ('' !== $localUid && isset($seenUids[$localUid]))) {
continue;
}
@@ -213,4 +249,30 @@ final readonly class RemoteToLocalSynchronizer
return $localModifiedAt > ($remoteModifiedAt + self::MODIFIED_TIME_SKEW_SECONDS);
}
private function isEventWithinConfiguredWindow(array $localEvent, CalendarSyncConfig $config): bool
{
if (!$config->hasTimeWindow()) {
return true;
}
$start = (int) ($localEvent['startTime'] ?? 0);
if ($start <= 0) {
$start = (int) ($localEvent['startDate'] ?? 0);
}
if ($start <= 0) {
return true;
}
if (null !== $config->syncFromTimestamp && $start < $config->syncFromTimestamp) {
return false;
}
if (null !== $config->syncUntilTimestamp && $start > $config->syncUntilTimestamp) {
return false;
}
return true;
}
}
+106 -33
View File
@@ -44,25 +44,22 @@ final class SyncFieldExtractor
/**
* @return array<string,mixed>
*/
public function extractFromLocalEvent(array $localEvent): array
public function extractFromLocalEvent(array $localEvent, ?string $timezone = null): array
{
$allDay = '1' !== (string) ($localEvent['addTime'] ?? '');
$start = (int) ($localEvent['startTime'] ?? 0);
if ($start <= 0) {
$start = (int) ($localEvent['startDate'] ?? 0);
}
$allDay = $this->resolveLocalAllDay($localEvent, $timezone);
if ($allDay) {
$start = $this->resolveAllDayStartTimestamp($localEvent);
$end = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $start);
} else {
$start = (int) ($localEvent['startTime'] ?? 0);
if ($start <= 0) {
$start = (int) ($localEvent['startDate'] ?? 0);
}
$end = (int) ($localEvent['endTime'] ?? 0);
$usesEndDateFallback = false;
if ($end <= 0) {
$end = (int) ($localEvent['endDate'] ?? 0);
$usesEndDateFallback = $end > 0;
}
// Contao stores endDate inclusive for all-day events, CalDAV uses exclusive DTEND.
if ($allDay && $usesEndDateFallback) {
$end += 86400;
$end = (int) ($localEvent['endTime'] ?? 0);
if ($end <= 0) {
$end = (int) ($localEvent['endDate'] ?? 0);
}
}
return $this->normalize([
@@ -99,23 +96,20 @@ final class SyncFieldExtractor
$uid = sprintf('contao-%d-%d@local', (int) ($localEvent['pid'] ?? 0), (int) ($localEvent['id'] ?? 0));
}
$allDay = '1' !== (string) ($localEvent['addTime'] ?? '');
$startAt = (int) ($localEvent['startTime'] ?? 0);
if ($startAt <= 0) {
$startAt = (int) ($localEvent['startDate'] ?? 0);
}
$allDay = $this->resolveLocalAllDay($localEvent, $timezone);
if ($allDay) {
$startAt = $this->resolveAllDayStartTimestamp($localEvent);
$endAt = $this->resolveAllDayExclusiveEndTimestamp($localEvent, $startAt);
} else {
$startAt = (int) ($localEvent['startTime'] ?? 0);
if ($startAt <= 0) {
$startAt = (int) ($localEvent['startDate'] ?? 0);
}
$endAt = (int) ($localEvent['endTime'] ?? 0);
$usesEndDateFallback = false;
if ($endAt <= 0) {
$endAt = (int) ($localEvent['endDate'] ?? 0);
$usesEndDateFallback = $endAt > 0;
}
// Convert inclusive local endDate to exclusive CalDAV DTEND for all-day events.
if ($allDay && $usesEndDateFallback) {
$endAt += 86400;
$endAt = (int) ($localEvent['endTime'] ?? 0);
if ($endAt <= 0) {
$endAt = (int) ($localEvent['endDate'] ?? 0);
}
}
if ($endAt <= $startAt) {
@@ -257,4 +251,83 @@ final class SyncFieldExtractor
{
return gmdate('Ymd', $leftTimestamp) === gmdate('Ymd', $rightTimestamp);
}
private function resolveLocalAllDay(array $localEvent, ?string $timezone = null): bool
{
if ('1' !== (string) ($localEvent['addTime'] ?? '')) {
return true;
}
// Fallback for synced events where Contao save can leave addTime=1
// although timestamps still represent an all-day event.
if (!$this->hasSyncMarker($localEvent)) {
return false;
}
$startDate = (int) ($localEvent['startDate'] ?? 0);
$endDate = (int) ($localEvent['endDate'] ?? 0);
$startTime = (int) ($localEvent['startTime'] ?? 0);
$endTime = (int) ($localEvent['endTime'] ?? 0);
if ($startDate <= 0 || $startTime <= 0) {
return false;
}
$sameStartBoundary = $startTime === $startDate || $this->isMidnightInTimezone($startTime, $timezone);
$singleDayOrEmptyEndDate = $endDate <= 0 || $endDate === $startDate;
$endAtBoundary = $endTime <= 0
|| $endTime === $startTime
|| $endTime === $startDate
|| ($endDate > 0 && $endTime === $endDate)
|| $this->isMidnightInTimezone($endTime, $timezone)
;
return $sameStartBoundary && $singleDayOrEmptyEndDate && $endAtBoundary;
}
private function hasSyncMarker(array $localEvent): bool
{
return '' !== trim((string) ($localEvent['caldavHref'] ?? ''))
|| '' !== trim((string) ($localEvent['caldavUid'] ?? ''))
|| '' !== trim((string) ($localEvent['caldavOrigin'] ?? ''));
}
private function isMidnightInTimezone(int $timestamp, ?string $timezone): bool
{
if ($timestamp <= 0) {
return false;
}
try {
$tz = new DateTimeZone(null !== $timezone && '' !== trim($timezone) ? $timezone : 'UTC');
} catch (\Throwable) {
$tz = new DateTimeZone('UTC');
}
return '00:00:00' === (new DateTimeImmutable('@'.$timestamp))->setTimezone($tz)->format('H:i:s');
}
private function resolveAllDayStartTimestamp(array $localEvent): int
{
$startDate = (int) ($localEvent['startDate'] ?? 0);
if ($startDate > 0) {
return $startDate;
}
return (int) ($localEvent['startTime'] ?? 0);
}
private function resolveAllDayExclusiveEndTimestamp(array $localEvent, int $startTimestamp): int
{
$endDate = (int) ($localEvent['endDate'] ?? 0);
if ($endDate > 0) {
if ($endDate >= $startTimestamp) {
return $endDate + 86400;
}
return $startTimestamp + 86400;
}
return $startTimestamp + 86400;
}
}