Skip to content

Commit bc38796

Browse files
Merge pull request #61659 from nextcloud/fix/dav-report-uid-conflict
fix(dav): return RFC 4791 no-uid-conflict on duplicate calendar UID
2 parents 9aed6ce + 8c9bbbb commit bc38796

8 files changed

Lines changed: 379 additions & 49 deletions

File tree

apps/dav/composer/composer/autoload_classmap.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@
317317
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => $baseDir . '/../lib/Events/SubscriptionUpdatedEvent.php',
318318
'OCA\\DAV\\Exception\\ExampleEventException' => $baseDir . '/../lib/Exception/ExampleEventException.php',
319319
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => $baseDir . '/../lib/Exception/ServerMaintenanceMode.php',
320+
'OCA\\DAV\\Exception\\UidConflict' => $baseDir . '/../lib/Exception/UidConflict.php',
320321
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => $baseDir . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
321322
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => $baseDir . '/../lib/Files/BrowserErrorPagePlugin.php',
322323
'OCA\\DAV\\Files\\FileSearchBackend' => $baseDir . '/../lib/Files/FileSearchBackend.php',

apps/dav/composer/composer/autoload_static.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
class ComposerStaticInitDAV
88
{
99
public static $prefixLengthsPsr4 = array (
10-
'O' =>
10+
'O' =>
1111
array (
1212
'OCA\\DAV\\' => 8,
1313
),
1414
);
1515

1616
public static $prefixDirsPsr4 = array (
17-
'OCA\\DAV\\' =>
17+
'OCA\\DAV\\' =>
1818
array (
1919
0 => __DIR__ . '/..' . '/../lib',
2020
),
@@ -332,6 +332,7 @@ class ComposerStaticInitDAV
332332
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionUpdatedEvent.php',
333333
'OCA\\DAV\\Exception\\ExampleEventException' => __DIR__ . '/..' . '/../lib/Exception/ExampleEventException.php',
334334
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => __DIR__ . '/..' . '/../lib/Exception/ServerMaintenanceMode.php',
335+
'OCA\\DAV\\Exception\\UidConflict' => __DIR__ . '/..' . '/../lib/Exception/UidConflict.php',
335336
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => __DIR__ . '/..' . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
336337
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => __DIR__ . '/..' . '/../lib/Files/BrowserErrorPagePlugin.php',
337338
'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
@@ -32,6 +32,7 @@
3232
use OCA\DAV\Events\SubscriptionCreatedEvent;
3333
use OCA\DAV\Events\SubscriptionDeletedEvent;
3434
use OCA\DAV\Events\SubscriptionUpdatedEvent;
35+
use OCA\DAV\Exception\UidConflict;
3536
use OCP\AppFramework\Db\TTransactional;
3637
use OCP\Calendar\CalendarExportOptions;
3738
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
@@ -1498,6 +1499,35 @@ public function getMultipleCalendarObjects($calendarId, array $uris, $calendarTy
14981499
return $objects;
14991500
}
15001501

1502+
/**
1503+
* Find an existing calendar object that already carries the given UID in a calendar collection
1504+
*
1505+
* @param int $calendarId
1506+
* @param string $uid
1507+
* @param int $calendarType
1508+
* @param bool|null $deleted Whether to match trashed objects: false for live objects only, true for trashed only, null for any
1509+
* @return array|null The existing object, or null when no match is found
1510+
*/
1511+
public function findCalendarObjectByUid(int $calendarId, string $uid, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?bool $deleted = false): ?array {
1512+
$qb = $this->db->getQueryBuilder();
1513+
$qb->select('*')
1514+
->from('calendarobjects')
1515+
->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId, IQueryBuilder::PARAM_INT)))
1516+
->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR)))
1517+
->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT)))
1518+
->setMaxResults(1);
1519+
if ($deleted === false) {
1520+
$qb->andWhere($qb->expr()->isNull('deleted_at'));
1521+
} elseif ($deleted === true) {
1522+
$qb->andWhere($qb->expr()->isNotNull('deleted_at'));
1523+
}
1524+
$result = $qb->executeQuery();
1525+
$row = $result->fetchAssociative();
1526+
$result->closeCursor();
1527+
1528+
return $row === false ? null : $this->rowToCalendarObject($row);
1529+
}
1530+
15011531
/**
15021532
* Creates a new calendar object.
15031533
*
@@ -1523,35 +1553,15 @@ public function createCalendarObject($calendarId, $objectUri, $calendarData, $ca
15231553
$extraData = $this->getDenormalizedData($calendarData);
15241554

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

@@ -1702,6 +1712,13 @@ public function moveCalendarObject(string $sourcePrincipalUri, int $sourceObject
17021712

17031713
$sourceCalendarId = $object['calendarid'];
17041714
$sourceObjectUri = $object['uri'];
1715+
$sourceObjectUid = $object['uid'];
1716+
1717+
// Try to detect duplicate uids in the target collection
1718+
$existing = $this->findCalendarObjectByUid($targetCalendarId, $sourceObjectUid, $calendarType);
1719+
if ($existing !== null) {
1720+
throw UidConflict::forCalendar($existing['uri']);
1721+
}
17051722

17061723
$query = $this->db->getQueryBuilder();
17071724
$query->update('calendarobjects')
@@ -2659,7 +2676,7 @@ public function getCalendarObjectByUID($principalUri, $uid, $calendarUri = null)
26592676

26602677
public function getCalendarObjectById(string $principalUri, int $id): ?array {
26612678
$query = $this->db->getQueryBuilder();
2662-
$query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at'])
2679+
$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'])
26632680
->selectAlias('c.uri', 'calendaruri')
26642681
->from('calendarobjects', 'co')
26652682
->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
@@ -2676,6 +2693,7 @@ public function getCalendarObjectById(string $principalUri, int $id): ?array {
26762693
return [
26772694
'id' => $row['id'],
26782695
'uri' => $row['uri'],
2696+
'uid' => $row['uid'],
26792697
'lastmodified' => $row['lastmodified'],
26802698
'etag' => '"' . $row['etag'] . '"',
26812699
'calendarid' => $row['calendarid'],

apps/dav/lib/CardDAV/CardDavBackend.php

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use OCA\DAV\Events\CardDeletedEvent;
2121
use OCA\DAV\Events\CardMovedEvent;
2222
use OCA\DAV\Events\CardUpdatedEvent;
23+
use OCA\DAV\Exception\UidConflict;
2324
use OCP\AppFramework\Db\TTransactional;
2425
use OCP\DB\Exception;
2526
use OCP\DB\QueryBuilder\IQueryBuilder;
@@ -545,6 +546,37 @@ public function getCard($addressBookId, $cardUri) {
545546
return $row;
546547
}
547548

549+
/**
550+
* Returns a card that already has a given UID in an address book collection.
551+
*
552+
* @param int $addressBookId
553+
* @param string $uid
554+
* @return array|null The existing card, or null when the UID is free
555+
*/
556+
public function getCardByUid(int $addressBookId, string $uid): ?array {
557+
$q = $this->db->getQueryBuilder();
558+
$q->select('*')
559+
->from($this->dbCardsTable)
560+
->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId, IQueryBuilder::PARAM_INT)))
561+
->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid, IQueryBuilder::PARAM_STR)))
562+
->setMaxResults(1);
563+
$result = $q->executeQuery();
564+
$row = $result->fetchAssociative();
565+
$result->closeCursor();
566+
if ($row === false) {
567+
return null;
568+
}
569+
570+
$row['etag'] = '"' . $row['etag'] . '"';
571+
$modified = false;
572+
$row['carddata'] = $this->readBlob($row['carddata'], $modified);
573+
if ($modified) {
574+
$row['size'] = strlen($row['carddata']);
575+
}
576+
577+
return $row;
578+
}
579+
548580
/**
549581
* Returns a list of cards.
550582
*
@@ -615,26 +647,20 @@ public function getMultipleCards($addressBookId, array $uris) {
615647
* @param mixed $addressBookId
616648
* @param string $cardUri
617649
* @param string $cardData
618-
* @param bool $checkAlreadyExists
650+
* @param bool $checkUidConflict
619651
* @return string
620652
*/
621653
#[\Override]
622-
public function createCard($addressBookId, $cardUri, $cardData, bool $checkAlreadyExists = true) {
654+
public function createCard($addressBookId, $cardUri, $cardData, bool $checkUidConflict = true) {
623655
$etag = md5($cardData);
624656
$uid = $this->getUID($cardData);
625-
return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkAlreadyExists, $etag, $uid) {
626-
if ($checkAlreadyExists) {
627-
$q = $this->db->getQueryBuilder();
628-
$q->select('uid')
629-
->from($this->dbCardsTable)
630-
->where($q->expr()->eq('addressbookid', $q->createNamedParameter($addressBookId)))
631-
->andWhere($q->expr()->eq('uid', $q->createNamedParameter($uid)))
632-
->setMaxResults(1);
633-
$result = $q->executeQuery();
634-
$count = (bool)$result->fetchOne();
635-
$result->closeCursor();
636-
if ($count) {
637-
throw new \Sabre\DAV\Exception\BadRequest('VCard object with uid already exists in this addressbook collection.');
657+
return $this->atomic(function () use ($addressBookId, $cardUri, $cardData, $checkUidConflict, $etag, $uid) {
658+
// Try to detect duplicate uids in the target collection
659+
if ($checkUidConflict) {
660+
$existing = $this->getCardByUid($addressBookId, $uid);
661+
if ($existing !== null) {
662+
// RFC 6352 no-uid-conflict (409) reporting the existing object's href.
663+
throw UidConflict::forAddressBook($existing['uri']);
638664
}
639665
}
640666

@@ -739,6 +765,12 @@ public function moveCard(int $sourceAddressBookId, string $sourceObjectUri, int
739765
}
740766
$sourceObjectId = (int)$card['id'];
741767

768+
// Try to detect duplicate uids in the target collection
769+
$existing = $this->getCardByUid($targetAddressBookId, $card['uid']);
770+
if ($existing !== null) {
771+
throw UidConflict::forAddressBook($existing['uri']);
772+
}
773+
742774
$query = $this->db->getQueryBuilder();
743775
$query->update('cards')
744776
->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)