Skip to content

Commit 6d10452

Browse files
Olenclaude
andcommitted
fix(dav): strip DTSTAMP only from etag, preserve in stored data
DTSTAMP is a required property per RFC 5545 and must not be removed from stored calendar data. Strip it only from the serialized string used for etag computation via preg_replace, leaving the data passed to createCalendarObject/updateCalendarObject unchanged. 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 9645a3a commit 6d10452

File tree

2 files changed

+12
-83
lines changed

2 files changed

+12
-83
lines changed

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

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

113-
// Strip DTSTAMP to prevent false positives during change detection.
113+
$sObject = $vObject->serialize();
114+
$uid = $vBase->UID->getValue();
115+
// Strip DTSTAMP lines for etag computation only.
114116
// Many providers (Google Calendar, Outlook, itslearning) set DTSTAMP
115117
// to the current time on every feed request per RFC 5545, causing
116118
// 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-
124-
$sObject = $vObject->serialize();
125-
$uid = $vBase->UID->getValue();
126-
$etag = md5($sObject);
119+
// DTSTAMP is kept in the stored data as it is a required property.
120+
$sObjectForEtag = preg_replace('/^DTSTAMP:.*\r?\n/m', '', $sObject);
121+
$etag = md5($sObjectForEtag);
127122

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

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

Lines changed: 6 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -460,69 +460,6 @@ 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-
526463
public function testDtstampChangeDoesNotTriggerUpdate(): void {
527464
$refreshWebcalService = new RefreshWebcalService(
528465
$this->caldavBackend,
@@ -555,10 +492,8 @@ public function testDtstampChangeDoesNotTriggerUpdate(): void {
555492
->method('queryWebcalFeed')
556493
->willReturn(['data' => $stream, 'format' => 'ical']);
557494

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);
495+
// The stored etag was computed from the DTSTAMP-stripped serialization
496+
$existingEtag = md5(preg_replace('/^DTSTAMP:.*\r?\n/m', '', $body));
562497

563498
$this->caldavBackend->expects(self::once())
564499
->method('getLimitedCalendarObjects')
@@ -591,9 +526,9 @@ public function testDtstampChangeDoesNotTriggerUpdate(): void {
591526
}
592527

593528
public static function identicalDataProvider(): array {
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";
596-
$etag = md5($icalBody);
529+
$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";
530+
// Etag is computed from DTSTAMP-stripped serialization
531+
$etag = md5(preg_replace('/^DTSTAMP:.*\r?\n/m', '', $icalBody));
597532

598533
return [
599534
[
@@ -617,8 +552,7 @@ public static function runDataProvider(): array {
617552
[
618553
"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",
619554
'ical',
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",
555+
"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",
622556
],
623557
];
624558
}

0 commit comments

Comments
 (0)