From b6d184280b6054aeb224a20b7fe128b4f054c66a Mon Sep 17 00:00:00 2001 From: Nico Donath Date: Fri, 26 Jun 2026 13:34:29 +0000 Subject: [PATCH] fix(dav): return RFC 4791 no-uid-conflict on duplicate calendar UID Assisted-by: ClaudeCode:claude-opus-4-8[1m] Signed-off-by: Nico Donath --- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + apps/dav/lib/CalDAV/CalDavBackend.php | 13 +++-- apps/dav/lib/CalDAV/Exception/UidConflict.php | 41 ++++++++++++++++ .../tests/unit/CalDAV/CalDavBackendTest.php | 2 +- .../unit/CalDAV/Exception/UidConflictTest.php | 48 +++++++++++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 apps/dav/lib/CalDAV/Exception/UidConflict.php create mode 100644 apps/dav/tests/unit/CalDAV/Exception/UidConflictTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 2ca5cf66f901f..c5288e2473719 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -67,6 +67,7 @@ 'OCA\\DAV\\CalDAV\\EventReader' => $baseDir . '/../lib/CalDAV/EventReader.php', 'OCA\\DAV\\CalDAV\\EventReaderRDate' => $baseDir . '/../lib/CalDAV/EventReaderRDate.php', 'OCA\\DAV\\CalDAV\\EventReaderRRule' => $baseDir . '/../lib/CalDAV/EventReaderRRule.php', + 'OCA\\DAV\\CalDAV\\Exception\\UidConflict' => $baseDir . '/../lib/CalDAV/Exception/UidConflict.php', 'OCA\\DAV\\CalDAV\\Export\\ExportService' => $baseDir . '/../lib/CalDAV/Export/ExportService.php', 'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationConfig' => $baseDir . '/../lib/CalDAV/Federation/CalendarFederationConfig.php', 'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationNotifier' => $baseDir . '/../lib/CalDAV/Federation/CalendarFederationNotifier.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index c35dd97c02c0e..52936507ca5e1 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -82,6 +82,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\EventReader' => __DIR__ . '/..' . '/../lib/CalDAV/EventReader.php', 'OCA\\DAV\\CalDAV\\EventReaderRDate' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRDate.php', 'OCA\\DAV\\CalDAV\\EventReaderRRule' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRRule.php', + 'OCA\\DAV\\CalDAV\\Exception\\UidConflict' => __DIR__ . '/..' . '/../lib/CalDAV/Exception/UidConflict.php', 'OCA\\DAV\\CalDAV\\Export\\ExportService' => __DIR__ . '/..' . '/../lib/CalDAV/Export/ExportService.php', 'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationConfig' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/CalendarFederationConfig.php', 'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationNotifier' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/CalendarFederationNotifier.php', diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 9254ba2ad102a..238cee28dcd4f 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -13,6 +13,7 @@ use DateTimeInterface; use Generator; use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\Exception\UidConflict; use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity; use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper; use OCA\DAV\CalDAV\Sharing\Backend; @@ -1525,18 +1526,20 @@ public function createCalendarObject($calendarId, $objectUri, $calendarData, $ca return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) { // Try to detect duplicates $qb = $this->db->getQueryBuilder(); - $qb->select($qb->func()->count('*')) + $qb->select('uri') ->from('calendarobjects') ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid']))) ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) - ->andWhere($qb->expr()->isNull('deleted_at')); + ->andWhere($qb->expr()->isNull('deleted_at')) + ->setMaxResults(1); $result = $qb->executeQuery(); - $count = (int)$result->fetchOne(); + $existingUri = $result->fetchOne(); $result->closeCursor(); - if ($count !== 0) { - throw new BadRequest('Calendar object with uid already exists in this calendar collection.'); + if ($existingUri !== false) { + // RFC 4791 no-uid-conflict (409) reporting the existing object's href. + throw new UidConflict((string)$existingUri); } // For a more specific error message we also try to explicitly look up the UID but as a deleted entry $qbDel = $this->db->getQueryBuilder(); diff --git a/apps/dav/lib/CalDAV/Exception/UidConflict.php b/apps/dav/lib/CalDAV/Exception/UidConflict.php new file mode 100644 index 0000000000000..873f163115b78 --- /dev/null +++ b/apps/dav/lib/CalDAV/Exception/UidConflict.php @@ -0,0 +1,41 @@ +getRequestUri()); + $href = $server->getBaseUri() . $collection . '/' . $this->existingObjectUri; + + $document = $errorNode->ownerDocument; + $conflict = $document->createElementNS('urn:ietf:params:xml:ns:caldav', 'cal:no-uid-conflict'); + $hrefNode = $document->createElementNS('DAV:', 'd:href'); + $hrefNode->appendChild($document->createTextNode($href)); + $conflict->appendChild($hrefNode); + $errorNode->appendChild($conflict); + } +} diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index e0f507fbe358d..9438ea63abac7 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -264,7 +264,7 @@ public function testCalendarObjectsOperations(): void { } public function testMultipleCalendarObjectsWithSameUID(): void { - $this->expectException(\Sabre\DAV\Exception\BadRequest::class); + $this->expectException(\OCA\DAV\CalDAV\Exception\UidConflict::class); $this->expectExceptionMessage('Calendar object with uid already exists in this calendar collection.'); $calendarId = $this->createTestCalendar(); diff --git a/apps/dav/tests/unit/CalDAV/Exception/UidConflictTest.php b/apps/dav/tests/unit/CalDAV/Exception/UidConflictTest.php new file mode 100644 index 0000000000000..1176f566656d1 --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/Exception/UidConflictTest.php @@ -0,0 +1,48 @@ +getHTTPCode()); + } + + public function testSerializeReportsNoUidConflictWithExistingHref(): void { + $server = $this->createMock(Server::class); + $server->method('getRequestUri')->willReturn('calendars/alice/personal/guessed.ics'); + $server->method('getBaseUri')->willReturn('/remote.php/dav/'); + + $document = new \DOMDocument('1.0', 'utf-8'); + $errorNode = $document->createElementNS('DAV:', 'd:error'); + $document->appendChild($errorNode); + + $exception = new UidConflict('sabredav-1234.ics'); + $exception->serialize($server, $errorNode); + + $conflict = $errorNode->getElementsByTagNameNS('urn:ietf:params:xml:ns:caldav', 'no-uid-conflict'); + self::assertSame(1, $conflict->length); + + $href = $errorNode->getElementsByTagNameNS('DAV:', 'href'); + self::assertSame(1, $href->length); + self::assertSame( + '/remote.php/dav/calendars/alice/personal/sabredav-1234.ics', + $href->item(0)->textContent, + ); + } +}