Skip to content

Commit 85a1fa5

Browse files
fix(dav): return RFC 4791 no-uid-conflict on duplicate calendar UID
Signed-off-by: Nico Donath <ndo84bw@gmx.de> Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
1 parent 42defb7 commit 85a1fa5

9 files changed

Lines changed: 378 additions & 48 deletions

File tree

3rdparty

Submodule 3rdparty updated 573 files

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@
313313
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => $baseDir . '/../lib/Events/SubscriptionUpdatedEvent.php',
314314
'OCA\\DAV\\Exception\\ExampleEventException' => $baseDir . '/../lib/Exception/ExampleEventException.php',
315315
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => $baseDir . '/../lib/Exception/ServerMaintenanceMode.php',
316+
'OCA\\DAV\\Exception\\UidConflict' => $baseDir . '/../lib/Exception/UidConflict.php',
316317
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => $baseDir . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
317318
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => $baseDir . '/../lib/Files/BrowserErrorPagePlugin.php',
318319
'OCA\\DAV\\Files\\FileSearchBackend' => $baseDir . '/../lib/Files/FileSearchBackend.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ class ComposerStaticInitDAV
328328
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionUpdatedEvent.php',
329329
'OCA\\DAV\\Exception\\ExampleEventException' => __DIR__ . '/..' . '/../lib/Exception/ExampleEventException.php',
330330
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => __DIR__ . '/..' . '/../lib/Exception/ServerMaintenanceMode.php',
331+
'OCA\\DAV\\Exception\\UidConflict' => __DIR__ . '/..' . '/../lib/Exception/UidConflict.php',
331332
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => __DIR__ . '/..' . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
332333
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => __DIR__ . '/..' . '/../lib/Files/BrowserErrorPagePlugin.php',
333334
'OCA\\DAV\\Files\\FileSearchBackend' => __DIR__ . '/..' . '/../lib/Files/FileSearchBackend.php',

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use OCA\DAV\Events\SubscriptionCreatedEvent;
3232
use OCA\DAV\Events\SubscriptionDeletedEvent;
3333
use OCA\DAV\Events\SubscriptionUpdatedEvent;
34+
use OCA\DAV\Exception\UidConflict;
3435
use OCP\AppFramework\Db\TTransactional;
3536
use OCP\Calendar\CalendarExportOptions;
3637
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
@@ -1488,6 +1489,35 @@ public function getMultipleCalendarObjects($calendarId, array $uris, $calendarTy
14881489
return $objects;
14891490
}
14901491

1492+
/**
1493+
* Find an existing calendar object that already carries the given UID in a calendar collection
1494+
*
1495+
* @param int $calendarId
1496+
* @param string $uid
1497+
* @param int $calendarType
1498+
* @param bool|null $deleted Whether to match trashed objects: false for live objects only, true for trashed only, null for any
1499+
* @return array|null The existing object, or null when no match is found
1500+
*/
1501+
public function findCalendarObjectByUid(int $calendarId, string $uid, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?bool $deleted = false): ?array {
1502+
$qb = $this->db->getQueryBuilder();
1503+
$qb->select('*')
1504+
->from('calendarobjects')
1505+
->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT)))
1506+
->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)))
1507+
->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT)))
1508+
->setMaxResults(1);
1509+
if ($deleted === false) {
1510+
$qb->andWhere($qb->expr()->isNull('deleted_at'));
1511+
} elseif ($deleted === true) {
1512+
$qb->andWhere($qb->expr()->isNotNull('deleted_at'));
1513+
}
1514+
$result = $qb->executeQuery();
1515+
$row = $result->fetch();
1516+
$result->closeCursor();
1517+
1518+
return $row === false ? null : $this->rowToCalendarObject($row);
1519+
}
1520+
14911521
/**
14921522
* Creates a new calendar object.
14931523
*
@@ -1512,35 +1542,15 @@ public function createCalendarObject($calendarId, $objectUri, $calendarData, $ca
15121542
$extraData = $this->getDenormalizedData($calendarData);
15131543

15141544
return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) {
1515-
// Try to detect duplicates
1516-
$qb = $this->db->getQueryBuilder();
1517-
$qb->select($qb->func()->count('*'))
1518-
->from('calendarobjects')
1519-
->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)))
1520-
->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid'])))
1521-
->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)))
1522-
->andWhere($qb->expr()->isNull('deleted_at'));
1523-
$result = $qb->executeQuery();
1524-
$count = (int)$result->fetchOne();
1525-
$result->closeCursor();
1526-
1527-
if ($count !== 0) {
1528-
throw new BadRequest('Calendar object with uid already exists in this calendar collection.');
1529-
}
1530-
// For a more specific error message we also try to explicitly look up the UID but as a deleted entry
1531-
$qbDel = $this->db->getQueryBuilder();
1532-
$qbDel->select('*')
1533-
->from('calendarobjects')
1534-
->where($qbDel->expr()->eq('calendarid', $qbDel->createNamedParameter($calendarId)))
1535-
->andWhere($qbDel->expr()->eq('uid', $qbDel->createNamedParameter($extraData['uid'])))
1536-
->andWhere($qbDel->expr()->eq('calendartype', $qbDel->createNamedParameter($calendarType)))
1537-
->andWhere($qbDel->expr()->isNotNull('deleted_at'));
1538-
$result = $qbDel->executeQuery();
1539-
$found = $result->fetch();
1540-
$result->closeCursor();
1541-
if ($found !== false) {
1542-
// the object existed previously but has been deleted
1543-
// remove the trashbin entry and continue as if it was a new object
1545+
// Try to detect duplicate uids in the target collection
1546+
$existing = $this->findCalendarObjectByUid($calendarId, $extraData['uid'], $calendarType);
1547+
if ($existing !== null) {
1548+
// RFC 4791 no-uid-conflict (409) reporting the existing object's href.
1549+
throw UidConflict::forCalendar($existing['uri']);
1550+
}
1551+
// The UID may still belong to a trashed object; delete it and replace it with the new object.
1552+
$found = $this->findCalendarObjectByUid($calendarId, $extraData['uid'], $calendarType, true);
1553+
if ($found !== null) {
15441554
$this->deleteCalendarObject($calendarId, $found['uri'], $calendarType, true);
15451555
}
15461556

@@ -1670,6 +1680,13 @@ public function moveCalendarObject(string $sourcePrincipalUri, int $sourceObject
16701680

16711681
$sourceCalendarId = $object['calendarid'];
16721682
$sourceObjectUri = $object['uri'];
1683+
$sourceObjectUid = $object['uid'];
1684+
1685+
// Try to detect duplicate uids in the target collection
1686+
$existing = $this->findCalendarObjectByUid($targetCalendarId, $sourceObjectUid, $calendarType);
1687+
if ($existing !== null) {
1688+
throw UidConflict::forCalendar($existing['uri']);
1689+
}
16731690

16741691
$query = $this->db->getQueryBuilder();
16751692
$query->update('calendarobjects')
@@ -2620,7 +2637,7 @@ public function getCalendarObjectByUID($principalUri, $uid, $calendarUri = null)
26202637

26212638
public function getCalendarObjectById(string $principalUri, int $id): ?array {
26222639
$query = $this->db->getQueryBuilder();
2623-
$query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at'])
2640+
$query->select(['co.id', 'co.uri', 'co.uid', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at'])
26242641
->selectAlias('c.uri', 'calendaruri')
26252642
->from('calendarobjects', 'co')
26262643
->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
@@ -2637,6 +2654,7 @@ public function getCalendarObjectById(string $principalUri, int $id): ?array {
26372654
return [
26382655
'id' => $row['id'],
26392656
'uri' => $row['uri'],
2657+
'uid' => $row['uid'],
26402658
'lastmodified' => $row['lastmodified'],
26412659
'etag' => '"' . $row['etag'] . '"',
26422660
'calendarid' => $row['calendarid'],

apps/dav/lib/CardDAV/CardDavBackend.php

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OCA\DAV\Events\CardDeletedEvent;
2020
use OCA\DAV\Events\CardMovedEvent;
2121
use OCA\DAV\Events\CardUpdatedEvent;
22+
use OCA\DAV\Exception\UidConflict;
2223
use OCP\AppFramework\Db\TTransactional;
2324
use OCP\DB\Exception;
2425
use OCP\DB\QueryBuilder\IQueryBuilder;
@@ -541,6 +542,37 @@ public function getCard($addressBookId, $cardUri) {
541542
return $row;
542543
}
543544

545+
/**
546+
* Returns a card that already has a given UID in an address book collection.
547+
*
548+
* @param int $addressBookId
549+
* @param string $uid
550+
* @return array|null The existing card, or null when the UID is free
551+
*/
552+
public function getCardByUid(int $addressBookId, string $uid): ?array {
553+
$q = $this->db->getQueryBuilder();
554+
$q->select('*')
555+
->from($this->dbCardsTable)
556+
->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
557+
->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid, IQueryBuilder::PARAM_STR)))
558+
->setMaxResults(1);
559+
$result = $q->executeQuery();
560+
$row = $result->fetch();
561+
$result->closeCursor();
562+
if ($row === false) {
563+
return null;
564+
}
565+
566+
$row['etag'] = '"' . $row['etag'] . '"';
567+
$modified = false;
568+
$row['carddata'] = $this->readBlob($row['carddata'], $modified);
569+
if ($modified) {
570+
$row['size'] = strlen($row['carddata']);
571+
}
572+
573+
return $row;
574+
}
575+
544576
/**
545577
* Returns a list of cards.
546578
*
@@ -610,25 +642,19 @@ public function getMultipleCards($addressBookId, array $uris) {
610642
* @param mixed $addressBookId
611643
* @param string $cardUri
612644
* @param string $cardData
613-
* @param bool $checkAlreadyExists
645+
* @param bool $checkUidConflict
614646
* @return string
615647
*/
616-
public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
648+
public function createCard($addressBookId, $cardUri, $cardData, bool $checkUidConflict = true) {
617649
$etag = md5($cardData);
618650
$uid = $this->getUID($cardData);
619-
return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
620-
if ($checkAlreadyExists) {
621-
$q = $this->db->getQueryBuilder();
622-
$q->select('uid')
623-
->from($this->dbCardsTable)
624-
->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
625-
->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
626-
->setMaxResults(1);
627-
$result = $q->executeQuery();
628-
$count = (bool)$result->fetchOne();
629-
$result->closeCursor();
630-
if ($count) {
631-
throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
651+
return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkUidConflict, $etag, $uid) {
652+
// Try to detect duplicate uids in the target collection
653+
if ($checkUidConflict) {
654+
$existing = $this->getCardByUid($addressBookId, $uid);
655+
if ($existing !== null) {
656+
// RFC 6352 no-uid-conflict (409) reporting the existing object's href.
657+
throw UidConflict::forAddressBook($existing['uri']);
632658
}
633659
}
634660

@@ -732,6 +758,12 @@ public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int
732758
}
733759
$sourceObjectId = (int)$card['id'];
734760

761+
// Try to detect duplicate uids in the target collection
762+
$existing = $this->getCardByUid($targetAddressBookId, $card['uid']);
763+
if ($existing !== null) {
764+
throw UidConflict::forAddressBook($existing['uri']);
765+
}
766+
735767
$query = $this->db->getQueryBuilder();
736768
$query->update('cards')
737769
->set('addressbookid', $query->createNamedParameter($targetAddressBookId, IQueryBuilder::PARAM_INT))
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\DAV\Exception;
11+
12+
use Sabre\CalDAV\Plugin as CalDAVPlugin;
13+
use Sabre\CardDAV\Plugin as CardDAVPlugin;
14+
use Sabre\DAV\Exception\Conflict;
15+
use Sabre\DAV\Server;
16+
17+
/**
18+
* Duplicate iCalendar or vCard UID in the target collection.
19+
*
20+
* Reports the no-uid-conflict precondition with a DAV:href to the existing
21+
* object, as 409 Conflict (resolvable by the client):
22+
* - CALDAV:no-uid-conflict for calendar collections (RFC 4791 5.3.2.1)
23+
* - CARDDAV:no-uid-conflict for address book collections (RFC 6352 6.3.2.1)
24+
*/
25+
class UidConflict extends Conflict {
26+
private function __construct(
27+
private readonly string $namespace,
28+
private readonly string $prefix,
29+
private readonly string $existingObjectUri,
30+
string $message,
31+
) {
32+
parent::__construct($message);
33+
}
34+
35+
/**
36+
* RFC 4791 CALDAV:no-uid-conflict for a calendar object collection.
37+
*/
38+
public static function forCalendar(string $existingObjectUri): self {
39+
return new self(
40+
CalDAVPlugin::NS_CALDAV,
41+
'cal',
42+
$existingObjectUri,
43+
'Calendar object with uid already exists in this calendar collection.',
44+
);
45+
}
46+
47+
/**
48+
* RFC 6352 CARDDAV:no-uid-conflict for an address book collection.
49+
*/
50+
public static function forAddressBook(string $existingObjectUri): self {
51+
return new self(
52+
CardDAVPlugin::NS_CARDDAV,
53+
'card',
54+
$existingObjectUri,
55+
'VCard object with uid already exists in this addressbook collection.',
56+
);
57+
}
58+
59+
#[\Override]
60+
public function serialize(Server $server, \DOMElement $errorNode) {
61+
// The conflicting object lives in the collection the resource is written
62+
// to. For PUT that is the request collection; for COPY and MOVE it is the
63+
// collection referenced by the Destination header.
64+
$method = $server->httpRequest->getMethod();
65+
if (($method === 'COPY' || $method === 'MOVE')
66+
&& $server->httpRequest->getHeader('Destination') !== null) {
67+
$targetPath = $server->calculateUri($server->httpRequest->getHeader('Destination'));
68+
} else {
69+
$targetPath = $server->getRequestUri();
70+
}
71+
[$collection] = \Sabre\Uri\split($targetPath);
72+
$href = $server->getBaseUri() . $collection . '/' . $this->existingObjectUri;
73+
74+
$document = $errorNode->ownerDocument;
75+
$conflict = $document->createElementNS($this->namespace, $this->prefix . ':no-uid-conflict');
76+
$hrefNode = $document->createElementNS('DAV:', 'd:href');
77+
$hrefNode->appendChild($document->createTextNode($href));
78+
$conflict->appendChild($hrefNode);
79+
$errorNode->appendChild($conflict);
80+
}
81+
}

0 commit comments

Comments
 (0)