From 496095a7552eab68bb40ea2cf3fe0c700d2e7c47 Mon Sep 17 00:00:00 2001 From: olen Date: Mon, 9 Feb 2026 21:02:37 +0100 Subject: [PATCH] fix(dav): exclude DTSTAMP from etag computation in webcal refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many iCal providers (Google Calendar, Outlook, itslearning) set DTSTAMP to the current time on every feed request per RFC 5545, causing every event to appear modified on every subscription refresh. Strip DTSTAMP lines from a copy of the serialized data used for etag computation only. The stored calendar data is unchanged — DTSTAMP is preserved as it is a required property per RFC 5545. The regex handles DTSTAMP with parameters (DTSTAMP;TZID=...) and RFC 5545 content line folding where long lines are split with CRLF followed by a space or tab. Signed-off-by: Olen Co-Authored-By: Claude Opus 4.6 Signed-off-by: olen --- .../WebcalCaching/RefreshWebcalService.php | 8 +- .../RefreshWebcalServiceTest.php | 135 +++++++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php index 7b22adc889e10..19a770731d107 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php @@ -112,7 +112,13 @@ public function refreshSubscription(string $principalUri, string $uri) { $sObject = $vObject->serialize(); $uid = $vBase->UID->getValue(); - $etag = md5($sObject); + // Strip DTSTAMP lines for etag computation only. + // Many providers (Google Calendar, Outlook, itslearning) set DTSTAMP + // to the current time on every feed request per RFC 5545, causing + // every event to appear modified on every refresh. + // DTSTAMP is kept in the stored data as it is a required property. + $sObjectForEtag = preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $sObject); + $etag = md5($sObjectForEtag); // No existing object with this UID, create it if (!isset($existingObjects[$uid])) { diff --git a/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php b/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php index 06f04019d8e1d..27054676dff22 100644 --- a/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php @@ -460,9 +460,142 @@ public function testRunCreateCalendarBadRequest(string $body, string $format, st $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); } + public function testDtstampChangeDoesNotTriggerUpdate(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); + + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/testuser') + ->willReturn([ + [ + 'id' => '42', + 'uri' => 'sub123', + RefreshWebcalService::STRIP_TODOS => '1', + RefreshWebcalService::STRIP_ALARMS => '1', + RefreshWebcalService::STRIP_ATTACHMENTS => '1', + 'source' => 'webcal://foo.bar/bla2', + 'lastmodified' => 0, + ], + ]); + + // Feed body has a new DTSTAMP (as happens on every fetch from Google/Outlook) + $body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:dtstamp-test\r\nDTSTAMP:20260209T120000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $stream = $this->createStreamFromString($body); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(['data' => $stream, 'format' => 'ical']); + + // The stored etag was computed from the DTSTAMP-stripped serialization + $existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $body)); + + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn([ + 'dtstamp-test' => [ + 'id' => 1, + 'uid' => 'dtstamp-test', + 'etag' => $existingEtag, + 'uri' => 'dtstamp-test.ics', + ], + ]); + + $vCalendar = VObject\Reader::read($body); + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + + // DTSTAMP-only change must NOT trigger an update + $this->caldavBackend->expects(self::never()) + ->method('updateCalendarObject'); + + $this->caldavBackend->expects(self::never()) + ->method('createCalendarObject'); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + + public function testFoldedDtstampChangeDoesNotTriggerUpdate(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); + + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/testuser') + ->willReturn([ + [ + 'id' => '42', + 'uri' => 'sub123', + RefreshWebcalService::STRIP_TODOS => '1', + RefreshWebcalService::STRIP_ALARMS => '1', + RefreshWebcalService::STRIP_ATTACHMENTS => '1', + 'source' => 'webcal://foo.bar/bla2', + 'lastmodified' => 0, + ], + ]); + + // DTSTAMP with TZID parameter exceeds 75 bytes, triggering RFC 5545 content line folding + $body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:folded-dtstamp-test\r\nDTSTAMP;X-VOBJ-ORIGINAL-TZID=America/Argentina/Buenos_Aires:20260209T120000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $stream = $this->createStreamFromString($body); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(['data' => $stream, 'format' => 'ical']); + + // Compute etag from the serialized output (which will be folded) minus DTSTAMP + $vCalForEtag = VObject\Reader::read($body); + $serialized = $vCalForEtag->serialize(); + $existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $serialized)); + + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn([ + 'folded-dtstamp-test' => [ + 'id' => 1, + 'uid' => 'folded-dtstamp-test', + 'etag' => $existingEtag, + 'uri' => 'folded-dtstamp-test.ics', + ], + ]); + + $vCalendar = VObject\Reader::read($body); + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + + // Folded DTSTAMP change must NOT trigger an update + $this->caldavBackend->expects(self::never()) + ->method('updateCalendarObject'); + + $this->caldavBackend->expects(self::never()) + ->method('createCalendarObject'); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + public static function identicalDataProvider(): array { $icalBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; - $etag = md5($icalBody); + // Etag is computed from DTSTAMP-stripped serialization + $etag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $icalBody)); return [ [