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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems a bit odd. I think we should rather use the data from $vObject to calculate an etag instead of altering the serialized object with regex.

I'm also seeing the changing dtstamp with my google test account:

Request 1

BEGIN:VEVENT
DTSTART:20250905T163000Z
DTEND:20250905T173000Z
DTSTAMP:20260210T144443Z
UID:1234-1234-1234-1234
CREATED:20250904T204927Z
LAST-MODIFIED:20250904T204927Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Testing 3
TRANSP:OPAQUE
END:VEVENT

Request 2 (a few seconds later)

BEGIN:VEVENT
DTSTART:20250905T163000Z
DTEND:20250905T173000Z
DTSTAMP:20260210T144446Z
UID:1234-1234-1234-1234
CREATED:20250904T204927Z
LAST-MODIFIED:20250904T204927Z
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:Testing 3
TRANSP:OPAQUE
END:VEVENT

What do you think to calculate the etag from "UID + Last-Modified + Sequence + DTSTART + DTSEND" @tcitworld @SebastianKrupinski?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kesselb

I think that approach would work... as long as the source updates those fields properly... which is a big IF...

My idea would be to just serialize the object twice... once with the DTSTAMP once with out... But you idea is more efficient

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just let me know if you decide you want to build the etag differently. I will update the PR to match what you think is best. I only noticed because the table was growing huge with updates for some of my calendars.

$etag = md5($sObjectForEtag);

// No existing object with this UID, create it
if (!isset($existingObjects[$uid])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
[
Expand Down
Loading