Skip to content
Open
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 @@ -59,6 +59,7 @@
'OCA\\DAV\\CalDAV\\CalendarImpl' => $baseDir . '/../lib/CalDAV/CalendarImpl.php',
'OCA\\DAV\\CalDAV\\CalendarManager' => $baseDir . '/../lib/CalDAV/CalendarManager.php',
'OCA\\DAV\\CalDAV\\CalendarObject' => $baseDir . '/../lib/CalDAV/CalendarObject.php',
'OCA\\DAV\\CalDAV\\CalendarObjectEtagHelper' => $baseDir . '/../lib/CalDAV/CalendarObjectEtagHelper.php',
'OCA\\DAV\\CalDAV\\CalendarProvider' => $baseDir . '/../lib/CalDAV/CalendarProvider.php',
'OCA\\DAV\\CalDAV\\CalendarRoot' => $baseDir . '/../lib/CalDAV/CalendarRoot.php',
'OCA\\DAV\\CalDAV\\DefaultCalendarValidator' => $baseDir . '/../lib/CalDAV/DefaultCalendarValidator.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 @@ -74,6 +74,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\CalendarImpl' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarImpl.php',
'OCA\\DAV\\CalDAV\\CalendarManager' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarManager.php',
'OCA\\DAV\\CalDAV\\CalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarObject.php',
'OCA\\DAV\\CalDAV\\CalendarObjectEtagHelper' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarObjectEtagHelper.php',
'OCA\\DAV\\CalDAV\\CalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarProvider.php',
'OCA\\DAV\\CalDAV\\CalendarRoot' => __DIR__ . '/..' . '/../lib/CalDAV/CalendarRoot.php',
'OCA\\DAV\\CalDAV\\DefaultCalendarValidator' => __DIR__ . '/..' . '/../lib/CalDAV/DefaultCalendarValidator.php',
Expand Down
18 changes: 11 additions & 7 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -1537,7 +1537,7 @@ public function findCalendarObjectByUid(int $calendarId, string $uid, int $calen
#[\Override]
public function createCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) {
$this->cachedObjects = [];
$extraData = $this->getDenormalizedData($calendarData);
$extraData = $this->getDenormalizedData($calendarData, $calendarType);

return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) {
// Try to detect duplicate uids in the target collection
Expand Down Expand Up @@ -1615,7 +1615,7 @@ public function createCalendarObject($calendarId, $objectUri, $calendarData, $ca
#[\Override]
public function updateCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) {
$this->cachedObjects = [];
$extraData = $this->getDenormalizedData($calendarData);
$extraData = $this->getDenormalizedData($calendarData, $calendarType);

return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) {
// Read the object before overwriting it so the update event can carry
Expand Down Expand Up @@ -3401,18 +3401,22 @@ public function restoreChanges(int $calendarId, int $calendarType = self::CALEND
* * uid - value of the UID property
*
* @param string $calendarData
* @param int $calendarType
* @return array
*/
public function getDenormalizedData(string $calendarData): array {
public function getDenormalizedData(string $calendarData, int $calendarType = self::CALENDAR_TYPE_CALENDAR): array {

$derived = [
'etag' => md5($calendarData),
'size' => strlen($calendarData),
];
// validate data and extract base component
/** @var VCalendar $vObject */
$vObject = Reader::read($calendarData);

$derived = [
'etag' => $calendarType === self::CALENDAR_TYPE_SUBSCRIPTION
? CalendarObjectEtagHelper::computeWithoutDtstamp($vObject)
: md5($calendarData),
'size' => strlen($calendarData),
];

// Extracts componentType, uid, classification, firstOccurence and lastOccurence from a single event/todo/journal component.
// RECURRENCE-ID is irrelevant here: it plays no part in this computation, so it works just as well on a recurrence exception as
// it does on a series master or a non-recurring component.
Expand Down
26 changes: 26 additions & 0 deletions apps/dav/lib/CalDAV/CalendarObjectEtagHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?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;

use Sabre\VObject\Component\VCalendar;

class CalendarObjectEtagHelper {

/**
* md5 of the calendar data with DTSTAMP removed from all components
*/
public static function computeWithoutDtstamp(VCalendar $vObject): string {
$vObject = clone $vObject;
foreach ($vObject->getComponents() as $component) {
unset($component->DTSTAMP);
}
return md5($vObject->serialize());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
namespace OCA\DAV\CalDAV\WebcalCaching;

use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarObjectEtagHelper;
use OCA\DAV\CalDAV\Import\ImportService;
use OCP\AppFramework\Utility\ITimeFactory;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -113,7 +114,8 @@ public function refreshSubscription(string $principalUri, string $uri) {

$sObject = $vObject->serialize();
$uid = $vBase->UID->getValue();
$etag = md5($sObject);

$etag = CalendarObjectEtagHelper::computeWithoutDtstamp($vObject);

// No existing object with this UID, create it
if (!isset($existingObjects[$uid])) {
Expand Down
35 changes: 35 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use DateTimeZone;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\CalendarObjectEtagHelper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\DAV\Sharing\Plugin as SharingPlugin;
use OCA\DAV\Events\CalendarDeletedEvent;
Expand All @@ -27,6 +28,7 @@
use Sabre\DAV\PropPatch;
use Sabre\DAV\Xml\Property\Href;
use Sabre\DAVACL\IACL;
use Sabre\VObject\Reader;
use function time;

/**
Expand Down Expand Up @@ -1184,6 +1186,39 @@ public function testSameUriSameIdForDifferentCalendarTypes(): void {
$this->assertEquals($calData2, $this->backend->getCalendarObject($subscriptionId, $uri, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION)['calendardata']);
}

public function testEtagIgnoresDtstampOnlyForSubscriptions(): void {
$calendarId = $this->createTestCalendar();
$subscriptionId = $this->createTestSubscription();

$uri = static::getUniqueID('calobj');
$calData = <<<EOD
BEGIN:VCALENDAR
VERSION:2.0
PRODID:Nextcloud Calendar
BEGIN:VEVENT
UID:47d15e3ec8
DTSTAMP;VALUE=DATE-TIME:20130910T125139Z
SUMMARY:Test Event
DTSTART;VALUE=DATE-TIME:20130912T130000Z
DTEND;VALUE=DATE-TIME:20130912T140000Z
END:VEVENT
END:VCALENDAR
EOD;
$calDataNewDtstamp = str_replace('20130910T125139Z', '20260209T120000Z', $calData);

$this->backend->createCalendarObject($calendarId, $uri, $calData);
$this->backend->createCalendarObject($subscriptionId, $uri, $calData, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);

$this->assertEquals('"' . md5($calData) . '"', $this->backend->getCalendarObject($calendarId, $uri)['etag']);
$this->assertEquals('"' . CalendarObjectEtagHelper::computeWithoutDtstamp(Reader::read($calData)) . '"', $this->backend->getCalendarObject($subscriptionId, $uri, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION)['etag']);

$calendarEtag = $this->backend->updateCalendarObject($calendarId, $uri, $calDataNewDtstamp);
$subscriptionEtag = $this->backend->updateCalendarObject($subscriptionId, $uri, $calDataNewDtstamp, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION);

$this->assertEquals('"' . md5($calDataNewDtstamp) . '"', $calendarEtag);
$this->assertEquals('"' . CalendarObjectEtagHelper::computeWithoutDtstamp(Reader::read($calData)) . '"', $subscriptionEtag);
}

public function testPurgeAllCachedEventsForSubscription(): void {
$subscriptionId = $this->createTestSubscription();
$uri = static::getUniqueID('calobj');
Expand Down
53 changes: 53 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalendarObjectEtagHelperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?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;

use OCA\DAV\CalDAV\CalendarObjectEtagHelper;
use Sabre\VObject\Reader;
use Test\TestCase;

class CalendarObjectEtagHelperTest extends TestCase {
private const CALENDAR_DATA = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:etag-test\r\nDTSTAMP:20260101T080000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";

public function testIgnoresDtstamp(): void {
$changed = str_replace('DTSTAMP:20260101T080000Z', 'DTSTAMP:20260209T120000Z', self::CALENDAR_DATA);

$this->assertSame(
CalendarObjectEtagHelper::computeWithoutDtstamp(Reader::read(self::CALENDAR_DATA)),
CalendarObjectEtagHelper::computeWithoutDtstamp(Reader::read($changed)),
);
}

public function testDetectsContentChange(): void {
$changed = str_replace('SUMMARY:Test Event', 'SUMMARY:Renamed Event', self::CALENDAR_DATA);

$this->assertNotSame(
CalendarObjectEtagHelper::computeWithoutDtstamp(Reader::read(self::CALENDAR_DATA)),
CalendarObjectEtagHelper::computeWithoutDtstamp(Reader::read($changed)),
);
}

public function testMatchesAfterSerializeRoundTrip(): void {
$vObject = Reader::read("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:etag-test\r\nDTSTAMP;X-VOBJ-ORIGINAL-TZID=America/Argentina/Buenos_Aires:20260209T120000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:A summary that is long enough to be folded when the calendar object gets serialized\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");

$this->assertSame(
CalendarObjectEtagHelper::computeWithoutDtstamp($vObject),
CalendarObjectEtagHelper::computeWithoutDtstamp(Reader::read($vObject->serialize())),
);
}

public function testDoesNotModifyInput(): void {
$vObject = Reader::read(self::CALENDAR_DATA);

CalendarObjectEtagHelper::computeWithoutDtstamp($vObject);

$this->assertSame('20260101T080000Z', $vObject->VEVENT->DTSTAMP->getValue());
}
}
Loading
Loading