Skip to content
Closed
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 @@ -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',
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 @@ -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',
Expand Down
13 changes: 8 additions & 5 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Comment thread
ndo84bw marked this conversation as resolved.
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();
Expand Down
41 changes: 41 additions & 0 deletions apps/dav/lib/CalDAV/Exception/UidConflict.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\CalDAV\Exception;

use Sabre\DAV\Exception\Conflict;
use Sabre\DAV\Server;

/**
* Duplicate iCalendar UID in the target calendar collection.
*
* Reports the RFC 4791 (5.3.2.1) CALDAV:no-uid-conflict precondition with a
* DAV:href to the existing object, as 409 Conflict (RFC 4791 1.3: resolvable).
*/
class UidConflict extends Conflict {
public function __construct(
private readonly string $existingObjectUri,
) {
parent::__construct('Calendar object with uid already exists in this calendar collection.');
}

#[\Override]
public function serialize(Server $server, \DOMElement $errorNode) {
// The conflicting object lives in the same collection as the request.
[$collection] = \Sabre\Uri\split($server->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);
}
}
2 changes: 1 addition & 1 deletion apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
48 changes: 48 additions & 0 deletions apps/dav/tests/unit/CalDAV/Exception/UidConflictTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php

declare(strict_types=1);

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

namespace OCA\DAV\Tests\unit\CalDAV\Exception;

use OCA\DAV\CalDAV\Exception\UidConflict;
use Sabre\DAV\Exception\Conflict;
use Sabre\DAV\Server;
use Test\TestCase;

class UidConflictTest extends TestCase {
public function testHttpCodeIsConflict(): void {
$exception = new UidConflict('sabredav-1234.ics');

// Must stay a Conflict: CalendarImpl catches it for the OCP createFromString contract.
self::assertInstanceOf(Conflict::class, $exception);
self::assertSame(409, $exception->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,
);
}
}
Loading