Skip to content

Commit 7df84bc

Browse files
Olenclaude
andcommitted
fix(dav): Strip DTSTAMP before etag comparison in RefreshWebcalService
Many iCal providers (Google Calendar, Outlook 365, itslearning) set DTSTAMP to the current UTC time on every feed request per RFC 5545. Since etags are computed as md5(serialized_data), every event appears modified on every refresh — generating ~189K false change rows per day for a single subscription with 2,621 events. Strip DTSTAMP from non-VTIMEZONE components before serialization, following the existing pattern used for VALARM and ATTACH stripping. Fixes: #51120 Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: olen <[email protected]>
1 parent 53a6796 commit 7df84bc

File tree

2 files changed

+145
-2
lines changed

2 files changed

+145
-2
lines changed

apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,17 @@ public function refreshSubscription(string $principalUri, string $uri) {
110110
}
111111
}
112112

113+
// Strip DTSTAMP to prevent false positives during change detection.
114+
// Many providers (Google Calendar, Outlook, itslearning) set DTSTAMP
115+
// to the current time on every feed request per RFC 5545, causing
116+
// every event to appear modified on every refresh.
117+
foreach ($vObject->getComponents() as $component) {
118+
if ($component->name === 'VTIMEZONE') {
119+
continue;
120+
}
121+
$component->remove('DTSTAMP');
122+
}
123+
113124
$sObject = $vObject->serialize();
114125
$uid = $vBase->UID->getValue();
115126
$etag = md5($sObject);

apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,139 @@ public function testRunCreateCalendarBadRequest(string $body, string $format, st
460460
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
461461
}
462462

463+
public function testDtstampIsStrippedFromOutput(): void {
464+
$refreshWebcalService = new RefreshWebcalService(
465+
$this->caldavBackend,
466+
$this->logger,
467+
$this->connection,
468+
$this->timeFactory,
469+
$this->importService
470+
);
471+
472+
$this->caldavBackend->expects(self::once())
473+
->method('getSubscriptionsForUser')
474+
->with('principals/users/testuser')
475+
->willReturn([
476+
[
477+
'id' => '42',
478+
'uri' => 'sub123',
479+
RefreshWebcalService::STRIP_TODOS => '1',
480+
RefreshWebcalService::STRIP_ALARMS => '1',
481+
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
482+
'source' => 'webcal://foo.bar/bla2',
483+
'lastmodified' => 0,
484+
],
485+
]);
486+
487+
// Input has DTSTAMP
488+
$body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:strip-test\r\nDTSTAMP:20260209T120000Z\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
489+
$stream = $this->createStreamFromString($body);
490+
491+
$this->connection->expects(self::once())
492+
->method('queryWebcalFeed')
493+
->willReturn(['data' => $stream, 'format' => 'ical']);
494+
495+
$this->caldavBackend->expects(self::once())
496+
->method('getLimitedCalendarObjects')
497+
->willReturn([]);
498+
499+
// Feed the VCalendar with DTSTAMP into the service
500+
$vCalendar = VObject\Reader::read($body);
501+
$generator = function () use ($vCalendar) {
502+
yield $vCalendar;
503+
};
504+
505+
$this->importService->expects(self::once())
506+
->method('importText')
507+
->willReturn($generator());
508+
509+
// Verify DTSTAMP is absent from the data passed to createCalendarObject
510+
$this->caldavBackend->expects(self::once())
511+
->method('createCalendarObject')
512+
->with(
513+
'42',
514+
self::matchesRegularExpression('/^[a-f0-9-]+\.ics$/'),
515+
self::callback(function (string $calendarData): bool {
516+
self::assertStringContainsString('DTSTART', $calendarData);
517+
self::assertStringNotContainsString('DTSTAMP', $calendarData);
518+
return true;
519+
}),
520+
CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION
521+
);
522+
523+
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
524+
}
525+
526+
public function testDtstampChangeDoesNotTriggerUpdate(): void {
527+
$refreshWebcalService = new RefreshWebcalService(
528+
$this->caldavBackend,
529+
$this->logger,
530+
$this->connection,
531+
$this->timeFactory,
532+
$this->importService
533+
);
534+
535+
$this->caldavBackend->expects(self::once())
536+
->method('getSubscriptionsForUser')
537+
->with('principals/users/testuser')
538+
->willReturn([
539+
[
540+
'id' => '42',
541+
'uri' => 'sub123',
542+
RefreshWebcalService::STRIP_TODOS => '1',
543+
RefreshWebcalService::STRIP_ALARMS => '1',
544+
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
545+
'source' => 'webcal://foo.bar/bla2',
546+
'lastmodified' => 0,
547+
],
548+
]);
549+
550+
// Feed body has a new DTSTAMP (as happens on every fetch from Google/Outlook)
551+
$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";
552+
$stream = $this->createStreamFromString($body);
553+
554+
$this->connection->expects(self::once())
555+
->method('queryWebcalFeed')
556+
->willReturn(['data' => $stream, 'format' => 'ical']);
557+
558+
// The stored etag was computed from the DTSTAMP-stripped serialization.
559+
// Reader::read() preserves the original PRODID and does not add CALSCALE.
560+
$strippedBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:dtstamp-test\r\nDTSTART:20260301T100000Z\r\nSUMMARY:Test Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
561+
$existingEtag = md5($strippedBody);
562+
563+
$this->caldavBackend->expects(self::once())
564+
->method('getLimitedCalendarObjects')
565+
->willReturn([
566+
'dtstamp-test' => [
567+
'id' => 1,
568+
'uid' => 'dtstamp-test',
569+
'etag' => $existingEtag,
570+
'uri' => 'dtstamp-test.ics',
571+
],
572+
]);
573+
574+
$vCalendar = VObject\Reader::read($body);
575+
$generator = function () use ($vCalendar) {
576+
yield $vCalendar;
577+
};
578+
579+
$this->importService->expects(self::once())
580+
->method('importText')
581+
->willReturn($generator());
582+
583+
// DTSTAMP-only change must NOT trigger an update
584+
$this->caldavBackend->expects(self::never())
585+
->method('updateCalendarObject');
586+
587+
$this->caldavBackend->expects(self::never())
588+
->method('createCalendarObject');
589+
590+
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
591+
}
592+
463593
public static function identicalDataProvider(): array {
464-
$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";
594+
// DTSTAMP is stripped before etag computation, so the etag body must not contain it
595+
$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\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";
465596
$etag = md5($icalBody);
466597

467598
return [
@@ -486,7 +617,8 @@ public static function runDataProvider(): array {
486617
[
487618
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//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",
488619
'ical',
489-
"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",
620+
// DTSTAMP is stripped before serialization, so the expected result must not contain it
621+
"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\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",
490622
],
491623
];
492624
}

0 commit comments

Comments
 (0)