Skip to content

Commit a60fdd6

Browse files
Merge pull request #62068 from nextcloud/backport/61659/stable33
[stable33] fix(dav): return RFC 4791 no-uid-conflict on duplicate calendar UID
2 parents 97eceab + 62ad5f3 commit a60fdd6

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
@@ -316,6 +316,7 @@
316316
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => $baseDir . '/../lib/Events/SubscriptionUpdatedEvent.php',
317317
'OCA\\DAV\\Exception\\ExampleEventException' => $baseDir . '/../lib/Exception/ExampleEventException.php',
318318
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => $baseDir . '/../lib/Exception/ServerMaintenanceMode.php',
319+
'OCA\\DAV\\Exception\\UidConflict' => $baseDir . '/../lib/Exception/UidConflict.php',
319320
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => $baseDir . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
320321
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => $baseDir . '/../lib/Files/BrowserErrorPagePlugin.php',
321322
'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
),
@@ -331,6 +331,7 @@ class ComposerStaticInitDAV
331331
'OCA\\DAV\\Events\\SubscriptionUpdatedEvent' => __DIR__ . '/..' . '/../lib/Events/SubscriptionUpdatedEvent.php',
332332
'OCA\\DAV\\Exception\\ExampleEventException' => __DIR__ . '/..' . '/../lib/Exception/ExampleEventException.php',
333333
'OCA\\DAV\\Exception\\ServerMaintenanceMode' => __DIR__ . '/..' . '/../lib/Exception/ServerMaintenanceMode.php',
334+
'OCA\\DAV\\Exception\\UidConflict' => __DIR__ . '/..' . '/../lib/Exception/UidConflict.php',
334335
'OCA\\DAV\\Exception\\UnsupportedLimitOnInitialSyncException' => __DIR__ . '/..' . '/../lib/Exception/UnsupportedLimitOnInitialSyncException.php',
335336
'OCA\\DAV\\Files\\BrowserErrorPagePlugin' => __DIR__ . '/..' . '/../lib/Files/BrowserErrorPagePlugin.php',
336337
'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->fetchAssociative();
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->fetchAssociative();
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')
@@ -2624,7 +2641,7 @@ public function getCalendarObjectByUID($principalUri, $uid, $calendarUri = null)
26242641

26252642
public function getCalendarObjectById(string $principalUri, int $id): ?array {
26262643
$query = $this->db->getQueryBuilder();
2627-
$query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at'])
2644+
$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'])
26282645
->selectAlias('c.uri', 'calendaruri')
26292646
->from('calendarobjects', 'co')
26302647
->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
@@ -2641,6 +2658,7 @@ public function getCalendarObjectById(string $principalUri, int $id): ?array {
26412658
return [
26422659
'id' => $row['id'],
26432660
'uri' => $row['uri'],
2661+
'uid' => $row['uid'],
26442662
'lastmodified' => $row['lastmodified'],
26452663
'etag' => '"' . $row['etag'] . '"',
26462664
'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;
@@ -540,6 +541,37 @@ public function getCard($addressBookId, $cardUri) {
540541
return $row;
541542
}
542543

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

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

760+
// Try to detect duplicate uids in the target collection
761+
$existing = $this->getCardByUid($targetAddressBookId, $card['uid']);
762+
if ($existing !== null) {
763+
throw UidConflict::forAddressBook($existing['uri']);
764+
}
765+
734766
$query = $this->db->getQueryBuilder();
735767
$query->update('cards')
736768
->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)