Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@
'OCA\\DAV\\Events\\SubscriptionDeletedEvent' => $baseDir . '/../lib/Events/SubscriptionDeletedEvent.php',
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => $baseDir . '/../lib/Events/SubscriptionUpdatedEvent.php',
'OCA\\DAV\\Exception\\ExampleEventException' => $baseDir . '/../lib/Exception/ExampleEventException.php',
'OCA\\DAV\\Exception\\InvalidSyncTokenException' => $baseDir . '/../lib/Exception/InvalidSyncTokenException.php',
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => $baseDir . '/../lib/Exception/ServerMaintenanceMode.php',
'OCA\\DAV\\Exception\\UidConflict' => $baseDir . '/../lib/Exception/UidConflict.php',
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => $baseDir . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Events\\SubscriptionDeletedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionDeletedEvent.php',
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionUpdatedEvent.php',
'OCA\\DAV\\Exception\\ExampleEventException' => __DIR__ . '/..' . '/../lib/Exception/ExampleEventException.php',
'OCA\\DAV\\Exception\\InvalidSyncTokenException' => __DIR__ . '/..' . '/../lib/Exception/InvalidSyncTokenException.php',
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => __DIR__ . '/..' . '/../lib/Exception/ServerMaintenanceMode.php',
'OCA\\DAV\\Exception\\UidConflict' => __DIR__ . '/..' . '/../lib/Exception/UidConflict.php',
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => __DIR__ . '/..' . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
Expand Down
36 changes: 36 additions & 0 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -2886,6 +2886,10 @@ public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limi
return null;
}

if (!$initialSync && $this->isSyncTokenOutdated((int)$calendarId, $calendarType, (int)$syncToken, (int)$currentToken)) {
return null;
}

// evaluate if this is a initial sync and construct appropriate command
if ($initialSync) {
$qb = $this->db->getQueryBuilder();
Expand Down Expand Up @@ -3312,6 +3316,12 @@ public function createSchedulingObject($principalUri, $objectUri, $objectData) {
* @return void
*/
protected function addChanges(int $calendarId, array $objectUris, int $operation, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void {
if ($objectUris === []) {
// Bumping the sync token without a change record would leave a gap that
// looks like pruned history to isSyncTokenOutdated()
return;
}

$this->cachedObjects = [];
$table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions';

Expand Down Expand Up @@ -3347,6 +3357,32 @@ protected function addChanges(int $calendarId, array $objectUris, int $operation
}, $this->db);
}

/**
* Checks whether changes after the given sync token were already removed by
* pruneOutdatedSyncTokens(). A delta sync from such a token would silently
* miss those changes, so the client has to do a full sync instead.
*/
private function isSyncTokenOutdated(int $calendarId, int $calendarType, int $syncToken, int $currentToken): bool {
// Sync tokens start at 1, a token of 0 describes the same (empty) state
$syncToken = max($syncToken, 1);
if ($syncToken >= $currentToken) {
return false;
}

$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->min('synctoken'))
->from('calendarchanges')
->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT)))
->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT)));
$result = $qb->executeQuery();
$oldestSyncToken = $result->fetchOne();
$result->closeCursor();

// Every change bumps the sync token by one (see addChanges), so the history
// is only complete if the oldest change at most is the client's sync token
return $oldestSyncToken === null || $oldestSyncToken === false || (int)$oldestSyncToken > $syncToken;
}

public function restoreChanges(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void {
$this->cachedObjects = [];

Expand Down
50 changes: 43 additions & 7 deletions apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\DAV\CalDAV\Federation;

use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\Exception\InvalidSyncTokenException;
use OCA\DAV\Service\ASyncService;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Http;
Expand Down Expand Up @@ -64,15 +65,33 @@ private function getCredentials(FederatedCalendarEntity $calendar): array {
*/
public function syncOne(FederatedCalendarEntity $calendar): int {
$credentials = $this->getCredentials($calendar);
$syncToken = $calendar->getSyncTokenForSabre();
// Never synced before: request an initial sync instead of replaying the remote's
// change history from the start, which might have been pruned already
$syncToken = $calendar->getSyncToken() === 0 ? null : $calendar->getSyncTokenForSabre();
$fullSync = $syncToken === null;

try {
$response = $this->requestSyncReport(
$credentials['remoteUrl'],
$credentials['username'],
$credentials['token'],
$syncToken,
);
try {
$response = $this->requestSyncReport(
$credentials['remoteUrl'],
$credentials['username'],
$credentials['token'],
$syncToken,
);
} catch (InvalidSyncTokenException $e) {
// The remote no longer has the changes since our sync token, e.g. because it pruned them.
// A delta sync would leave us with an incomplete calendar, so start over.
$this->logger->warning("Sync token for {$credentials['remoteUrl']} was rejected by the remote server, performing a full sync", [
'exception' => $e,
]);
$fullSync = true;
$response = $this->requestSyncReport(
$credentials['remoteUrl'],
$credentials['username'],
$credentials['token'],
null,
);
}
} catch (ClientExceptionInterface $ex) {
if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) {
// Remote server revoked access to the calendar => remove it
Expand All @@ -88,8 +107,10 @@ public function syncOne(FederatedCalendarEntity $calendar): int {

// Process changes from remote
$downloadedEvents = 0;
$remoteObjectUris = [];
foreach ($response['response'] as $resource => $status) {
$objectUri = basename($resource);
$remoteObjectUris[$objectUri] = true;
if (isset($status[200])) {
// Object created or updated
$absoluteUrl = $this->prepareUri($credentials['remoteUrl'], $resource);
Expand Down Expand Up @@ -128,6 +149,21 @@ public function syncOne(FederatedCalendarEntity $calendar): int {
}
}

if ($fullSync) {
// A full sync only lists existing objects, so drop everything the remote does not have anymore
$localObjects = $this->backend->getCalendarObjects($calendar->getId(), CalDavBackend::CALENDAR_TYPE_FEDERATED);
foreach ($localObjects as $localObject) {
if (!isset($remoteObjectUris[$localObject['uri']])) {
$this->backend->deleteCalendarObject(
$calendar->getId(),
$localObject['uri'],
CalDavBackend::CALENDAR_TYPE_FEDERATED,
true
);
}
}
}

$newSyncToken = $response['token'];

// Check sync token format and extract the actual sync token integer
Expand Down
40 changes: 37 additions & 3 deletions apps/dav/lib/CardDAV/CardDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -897,7 +897,7 @@ public function deleteCard($addressBookId, $cardUri) {
* @param string $syncToken
* @param int $syncLevel
* @param int|null $limit
* @return array
* @return array|null
*/
#[\Override]
public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel, $limit = null) {
Expand Down Expand Up @@ -929,6 +929,9 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel,
$syncValues = explode('_', $syncToken);
$lastID = $syncValues[1];
$initialSyncToken = $syncValues[2];
if ($this->isSyncTokenOutdated((int)$addressBookId, (int)$initialSyncToken, (int)$currentToken)) {
return null;
}
$qb = $this->db->getQueryBuilder();
$qb->select('id', 'uri')
->from('cards')
Expand All @@ -952,6 +955,10 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel,
$result['result_truncated'] = count($result['added']) >= $limit;
}
} elseif ($syncToken) {
if ($this->isSyncTokenOutdated((int)$addressBookId, (int)$syncToken, (int)$currentToken)) {
return null;
}

$qb = $this->db->getQueryBuilder();
$qb->select('uri', 'operation', 'synctoken')
->from('addressbookchanges')
Expand All @@ -969,7 +976,8 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel,

// Fetching all changes
$stmt = $qb->executeQuery();
$rowCount = $stmt->rowCount();
// Count the fetched rows ourselves, rowCount() is not reliable for SELECT statements
$rowCount = 0;

$changes = [];
$highestSyncToken = 0;
Expand All @@ -979,6 +987,7 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel,
while ($row = $stmt->fetchAssociative()) {
$changes[$row['uri']] = $row['operation'];
$highestSyncToken = $row['synctoken'];
$rowCount++;
}

$stmt->closeCursor();
Expand Down Expand Up @@ -1011,7 +1020,7 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel,
*
* Therefore, we differentiate between truncated and non-truncated results when returning the synctoken.
*/
if ($rowCount === $limit && $highestSyncToken < $currentToken) {
if ($limit > 0 && $rowCount === $limit && $highestSyncToken < $currentToken) {
$result['syncToken'] = $highestSyncToken;
$result['result_truncated'] = true;
}
Expand Down Expand Up @@ -1045,6 +1054,31 @@ public function getChangesForAddressBook($addressBookId, $syncToken, $syncLevel,
}, $this->db);
}

/**
* Checks whether changes after the given sync token were already removed by
* pruneOutdatedSyncTokens(). A delta sync from such a token would silently
* miss those changes, so the client has to do a full sync instead.
*/
private function isSyncTokenOutdated(int $addressBookId, int $syncToken, int $currentToken): bool {
// Sync tokens start at 1, a token of 0 describes the same (empty) state
$syncToken = max($syncToken, 1);
if ($syncToken >= $currentToken) {
return false;
}

$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->min('synctoken'))
->from('addressbookchanges')
->where($qb->expr()->eq('addressbookid', $qb->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)));
$result = $qb->executeQuery();
$oldestSyncToken = $result->fetchOne();
$result->closeCursor();

// Every change bumps the sync token by one (see addChange), so the history
// is only complete if the oldest change at most is the client's sync token
return $oldestSyncToken === null || $oldestSyncToken === false || (int)$oldestSyncToken > $syncToken;
}

/**
* Adds a change record to the addressbookchanges table.
*
Expand Down
18 changes: 18 additions & 0 deletions apps/dav/lib/Exception/InvalidSyncTokenException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\Exception;

/**
* The remote server rejected the sync token of a sync-collection report
* (DAV:valid-sync-token precondition), e.g. because its change history was
* pruned. The collection has to be synced from scratch.
*/
class InvalidSyncTokenException extends \Exception {
}
30 changes: 25 additions & 5 deletions apps/dav/lib/Service/ASyncService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

namespace OCA\DAV\Service;

use GuzzleHttp\Exception\BadResponseException;
use OCA\DAV\Exception\InvalidSyncTokenException;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
Expand Down Expand Up @@ -70,6 +72,7 @@ protected function prepareUri(string $host, string $path): string {

/**
* @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
* @throws InvalidSyncTokenException If the remote server rejected the sync token
*/
protected function requestSyncReport(
string $absoluteUrl,
Expand All @@ -93,18 +96,35 @@ protected function requestSyncReport(
),
];

$response = $client->request(
'REPORT',
$absoluteUrl,
$options,
);
try {
$response = $client->request(
'REPORT',
$absoluteUrl,
$options,
);
} catch (BadResponseException $e) {
if ($syncToken !== null && $this->isInvalidSyncTokenResponse($e)) {
throw new InvalidSyncTokenException("Sync token $syncToken was rejected by $absoluteUrl", $e->getCode(), $e);
}
throw $e;
}

$body = $response->getBody();
assert(is_string($body));

return $this->parseMultiStatus($body, $absoluteUrl);
}

/**
* RFC 6578 3.2: An expired or unknown sync token is answered with
* 403 Forbidden and the DAV:valid-sync-token precondition.
*/
private function isInvalidSyncTokenResponse(BadResponseException $e): bool {
$response = $e->getResponse();
return $response->getStatusCode() === 403
&& str_contains((string)$response->getBody(), 'valid-sync-token');
}

protected function download(
string $absoluteUrl,
string $userName,
Expand Down
34 changes: 24 additions & 10 deletions apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1565,12 +1565,12 @@ public function testPruneOutdatedSyncTokens(): void {
$deleted = $this->backend->pruneOutdatedSyncTokens(0, 0);
// At least one from the object creation and one from the object update
$this->assertGreaterThanOrEqual(2, $deleted);
$changes = $this->backend->getChangesForCalendar($calendarId, $syncToken, 1);
$this->assertEmpty($changes['added']);
$this->assertEmpty($changes['modified']);
$this->assertEmpty($changes['deleted']);
// The changes since the sync token are gone, so it must be rejected instead of returning an incomplete delta
$this->assertNull($this->backend->getChangesForCalendar($calendarId, $syncToken, 1));

// Test that objects remain
// Start over with an initial sync
$changes = $this->backend->getChangesForCalendar($calendarId, '', 1);
$syncToken = $changes['syncToken'];

// Currently changes are empty
$changes = $this->backend->getChangesForCalendar($calendarId, $syncToken, 100);
Expand Down Expand Up @@ -1601,6 +1601,7 @@ public function testPruneOutdatedSyncTokens(): void {
$this->assertEquals(1, count($changes['added']));
$this->assertEmpty($changes['modified']);
$this->assertEmpty($changes['deleted']);
$syncTokenAfterAdd = $changes['syncToken'];

// update the card
$calData = <<<'EOD'
Expand Down Expand Up @@ -1631,8 +1632,11 @@ public function testPruneOutdatedSyncTokens(): void {
$deleted = $this->backend->pruneOutdatedSyncTokens(1, 0);
$this->assertEquals(1, $deleted); // We had two changes before, now one

// The add is gone, so a delta from before it must be rejected
$this->assertNull($this->backend->getChangesForCalendar($calendarId, $syncToken, 100));

// Only update should remain
$changes = $this->backend->getChangesForCalendar($calendarId, $syncToken, 100);
$changes = $this->backend->getChangesForCalendar($calendarId, $syncTokenAfterAdd, 100);
$this->assertEmpty($changes['added']);
$this->assertEquals(1, count($changes['modified']));
$this->assertEmpty($changes['deleted']);
Expand Down Expand Up @@ -1792,10 +1796,20 @@ public function testRestoreChanges(): void {

$this->backend->restoreChanges($calendarId);

$changesAfter = $this->backend->getChangesForCalendar($calendarId, $changesBefore['syncToken'], 1);
self::assertEquals([], $changesAfter['added']);
self::assertEqualsCanonicalizing([$uri1, $uri3], $changesAfter['modified']);
self::assertEquals([$uri2], $changesAfter['deleted']);
// The original changes are gone, so a client with an old sync token has to do a full sync
self::assertNull($this->backend->getChangesForCalendar($calendarId, $changesBefore['syncToken'], 1));

$query = $this->db->getQueryBuilder();
$query->select('uri', 'operation')
->from('calendarchanges')
->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId)));
$restored = [];
foreach ($query->executeQuery()->fetchAllAssociative() as $row) {
$restored[(int)$row['operation']][] = $row['uri'];
}
self::assertArrayNotHasKey(1, $restored);
self::assertEqualsCanonicalizing([$uri1, $uri3], $restored[2]);
self::assertEquals([$uri2], $restored[3]);
}

public function testSearchWithLimitAndTimeRange(): void {
Expand Down
Loading
Loading