Skip to content

Commit 496095a

Browse files
Olenclaude
andcommitted
fix(dav): exclude DTSTAMP from etag computation in webcal refresh
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 <regopa@gmail.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: olen <ola@nytt.no>
1 parent 9cd337b commit 496095a

File tree

2 files changed

+141
-2
lines changed

2 files changed

+141
-2
lines changed

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,13 @@ public function refreshSubscription(string $principalUri, string $uri) {
112112

113113
$sObject = $vObject->serialize();
114114
$uid = $vBase->UID->getValue();
115-
$etag = md5($sObject);
115+
// Strip DTSTAMP lines for etag computation only.
116+
// Many providers (Google Calendar, Outlook, itslearning) set DTSTAMP
117+
// to the current time on every feed request per RFC 5545, causing
118+
// every event to appear modified on every refresh.
119+
// DTSTAMP is kept in the stored data as it is a required property.
120+
$sObjectForEtag = preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $sObject);
121+
$etag = md5($sObjectForEtag);
116122

117123
// No existing object with this UID, create it
118124
if (!isset($existingObjects[$uid])) {

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

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

463+
public function testDtstampChangeDoesNotTriggerUpdate(): 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+
// Feed body has a new DTSTAMP (as happens on every fetch from Google/Outlook)
488+
$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";
489+
$stream = $this->createStreamFromString($body);
490+
491+
$this->connection->expects(self::once())
492+
->method('queryWebcalFeed')
493+
->willReturn(['data' => $stream, 'format' => 'ical']);
494+
495+
// The stored etag was computed from the DTSTAMP-stripped serialization
496+
$existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $body));
497+
498+
$this->caldavBackend->expects(self::once())
499+
->method('getLimitedCalendarObjects')
500+
->willReturn([
501+
'dtstamp-test' => [
502+
'id' => 1,
503+
'uid' => 'dtstamp-test',
504+
'etag' => $existingEtag,
505+
'uri' => 'dtstamp-test.ics',
506+
],
507+
]);
508+
509+
$vCalendar = VObject\Reader::read($body);
510+
$generator = function () use ($vCalendar) {
511+
yield $vCalendar;
512+
};
513+
514+
$this->importService->expects(self::once())
515+
->method('importText')
516+
->willReturn($generator());
517+
518+
// DTSTAMP-only change must NOT trigger an update
519+
$this->caldavBackend->expects(self::never())
520+
->method('updateCalendarObject');
521+
522+
$this->caldavBackend->expects(self::never())
523+
->method('createCalendarObject');
524+
525+
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
526+
}
527+
528+
public function testFoldedDtstampChangeDoesNotTriggerUpdate(): void {
529+
$refreshWebcalService = new RefreshWebcalService(
530+
$this->caldavBackend,
531+
$this->logger,
532+
$this->connection,
533+
$this->timeFactory,
534+
$this->importService
535+
);
536+
537+
$this->caldavBackend->expects(self::once())
538+
->method('getSubscriptionsForUser')
539+
->with('principals/users/testuser')
540+
->willReturn([
541+
[
542+
'id' => '42',
543+
'uri' => 'sub123',
544+
RefreshWebcalService::STRIP_TODOS => '1',
545+
RefreshWebcalService::STRIP_ALARMS => '1',
546+
RefreshWebcalService::STRIP_ATTACHMENTS => '1',
547+
'source' => 'webcal://foo.bar/bla2',
548+
'lastmodified' => 0,
549+
],
550+
]);
551+
552+
// DTSTAMP with TZID parameter exceeds 75 bytes, triggering RFC 5545 content line folding
553+
$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";
554+
$stream = $this->createStreamFromString($body);
555+
556+
$this->connection->expects(self::once())
557+
->method('queryWebcalFeed')
558+
->willReturn(['data' => $stream, 'format' => 'ical']);
559+
560+
// Compute etag from the serialized output (which will be folded) minus DTSTAMP
561+
$vCalForEtag = VObject\Reader::read($body);
562+
$serialized = $vCalForEtag->serialize();
563+
$existingEtag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $serialized));
564+
565+
$this->caldavBackend->expects(self::once())
566+
->method('getLimitedCalendarObjects')
567+
->willReturn([
568+
'folded-dtstamp-test' => [
569+
'id' => 1,
570+
'uid' => 'folded-dtstamp-test',
571+
'etag' => $existingEtag,
572+
'uri' => 'folded-dtstamp-test.ics',
573+
],
574+
]);
575+
576+
$vCalendar = VObject\Reader::read($body);
577+
$generator = function () use ($vCalendar) {
578+
yield $vCalendar;
579+
};
580+
581+
$this->importService->expects(self::once())
582+
->method('importText')
583+
->willReturn($generator());
584+
585+
// Folded DTSTAMP change must NOT trigger an update
586+
$this->caldavBackend->expects(self::never())
587+
->method('updateCalendarObject');
588+
589+
$this->caldavBackend->expects(self::never())
590+
->method('createCalendarObject');
591+
592+
$refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123');
593+
}
594+
463595
public static function identicalDataProvider(): array {
464596
$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";
465-
$etag = md5($icalBody);
597+
// Etag is computed from DTSTAMP-stripped serialization
598+
$etag = md5(preg_replace('/^DTSTAMP[;:].*\r?\n([ \t].*\r?\n)*/m', '', $icalBody));
466599

467600
return [
468601
[

0 commit comments

Comments
 (0)