diff options
author | Daniel Kesselberg <mail@danielkesselberg.de> | 2025-02-06 22:24:08 +0100 |
---|---|---|
committer | Daniel Kesselberg <mail@danielkesselberg.de> | 2025-05-22 18:19:36 +0200 |
commit | b86d600d029be113cd30696d849b48dde71c117e (patch) | |
tree | e279ae54578908d12844aa097e33d920f56ba31c | |
parent | fb4a06fef86a8a5d06075aad82dd4b3bfafe4d9a (diff) | |
download | nextcloud-server-b86d600d029be113cd30696d849b48dde71c117e.tar.gz nextcloud-server-b86d600d029be113cd30696d849b48dde71c117e.zip |
fix: handle cancellation message with more than one attendee
1. make it possible to process imip cancellation messages with more than one attendee
2. create a specialized version of the original event containing only the relevant data to cancel the actual copy of the event for the given attendee
Signed-off-by: Daniel Kesselberg <mail@danielkesselberg.de>
8 files changed, 179 insertions, 22 deletions
diff --git a/lib/private/Calendar/Manager.php b/lib/private/Calendar/Manager.php index 21370e74d54..d87c9277ffc 100644 --- a/lib/private/Calendar/Manager.php +++ b/lib/private/Calendar/Manager.php @@ -34,8 +34,10 @@ use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Component\VFreeBusy; use Sabre\VObject\ParseException; +use Sabre\VObject\Property\ICalendar\CalAddress; use Sabre\VObject\Property\VCard\DateTime; use Sabre\VObject\Reader; +use Sabre\VObject\Writer; use Throwable; use function array_map; use function array_merge; @@ -236,7 +238,7 @@ class Manager implements IManager { $this->logger->warning('iMip message could not be processed because user has no calendars'); return false; } - + try { /** @var VCalendar $vObject|null */ $calendarObject = Reader::read($calendarData); @@ -255,7 +257,7 @@ class Manager implements IManager { return false; } - /** @var VEvent|null $vEvent */ + /** @var VEvent $eventObject */ $eventObject = $calendarObject->VEVENT; if (!isset($eventObject->UID)) { @@ -273,14 +275,7 @@ class Manager implements IManager { return false; } - foreach ($eventObject->ATTENDEE as $entry) { - $address = trim(str_replace('mailto:', '', $entry->getValue())); - if ($address === $recipient) { - $attendee = $address; - break; - } - } - if (!isset($attendee)) { + if (!$this->isRecipientAnAttendee($eventObject, $recipient)) { $this->logger->warning('iMip message event does not contain a attendee that matches the recipient'); return false; } @@ -457,8 +452,8 @@ class Manager implements IManager { return false; } - /** @var VEvent|null $vEvent */ - $vEvent = $vObject->{'VEVENT'}; + /** @var VEvent $vEvent */ + $vEvent = $vObject->VEVENT; if (!isset($vEvent->UID)) { $this->logger->warning('iMip message event dose not contains a UID'); @@ -475,8 +470,7 @@ class Manager implements IManager { return false; } - $attendee = substr($vEvent->{'ATTENDEE'}->getValue(), 7); - if (strcasecmp($recipient, $attendee) !== 0) { + if (!$this->isRecipientAnAttendee($vEvent, $recipient)) { $this->logger->warning('iMip message event could not be processed because recipient must be an ATTENDEE of this event'); return false; } @@ -522,8 +516,10 @@ class Manager implements IManager { return false; } + $cancelEvent = $this->createCancelEvent($vEvent, $recipient); + try { - $found->handleIMipMessage($name, $calendarData); // sabre will handle the scheduling behind the scenes + $found->handleIMipMessage($name, $cancelEvent->serialize()); // sabre will handle the scheduling behind the scenes return true; } catch (CalendarException $e) { $this->logger->error('An error occurred while processing the iMip message event', ['exception' => $e]); @@ -531,6 +527,41 @@ class Manager implements IManager { } } + private function createCancelEvent(VEvent $event, string $recipient): VCalendar { + $newVcalendar = new VCalendar(); + $newVcalendar->{'METHOD'} = 'CANCEL'; + + /** @var VEvent $newVevent */ + $newVevent = $newVcalendar->create('VEVENT'); + $newVevent->{'ATTENDEE'} = 'mailto:' . $recipient; + $newVevent->{'DTSTAMP'} = $event->{'DTSTAMP'}; + $newVevent->{'ORGANIZER'} = $event->{'ORGANIZER'}; + $newVevent->{'SEQUENCE'} = $event->{'SEQUENCE'}; + $newVevent->{'UID'} = $event->{'UID'}; + + /* + * Only if referring to an instance of a recurring calendar component. + * Otherwise, it MUST NOT be present. + */ + $recurrenceId = $event->{'RECURRENCE-ID'}; + if ($recurrenceId !== null) { + $newVevent->{'RECURRENCE-ID'} = $recurrenceId; + } + + /* + * MUST be set to CANCELLED to cancel the entire event. + * If uninviting specific Attendees then MUST NOT be included. + */ + $status = $event->{'STATUS'}; + if ($status !== null) { + $newVevent->{'STATUS'} = $status; + } + + $newVcalendar->add($newVevent); + + return $newVcalendar; + } + public function createEventBuilder(): ICalendarEventBuilder { $uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC); return new CalendarEventBuilder($uid, $this->timeFactory); @@ -618,4 +649,15 @@ class Manager implements IManager { return $result; } + + private function isRecipientAnAttendee(VEvent $event, string $recipientEmail): bool { + foreach ($event->{'ATTENDEE'} as $attendee) { + /** @var CalAddress $attendee */ + $attendeeEmail = substr($attendee->getValue(), 7); + if ($recipientEmail === $attendeeEmail) { + return true; + } + } + return false; + } } diff --git a/tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics b/tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics new file mode 100644 index 00000000000..59e31171b42 --- /dev/null +++ b/tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics @@ -0,0 +1,15 @@ +BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.6//EN
+CALSCALE:GREGORIAN
+METHOD:CANCEL
+BEGIN:VEVENT
+ATTENDEE:mailto:pierre@general-store.com
+DTSTAMP:20210820T080000Z
+ORGANIZER;CN=admin:mailto:linus@stardew-tent-living.com
+SEQUENCE:3
+UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff
+STATUS:CANCELLED
+END:VEVENT
+END:VCALENDAR
+
diff --git a/tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics.license b/tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics.license new file mode 100644 index 00000000000..f2ed5575dd3 --- /dev/null +++ b/tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics b/tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics new file mode 100644 index 00000000000..9aa0a68104c --- /dev/null +++ b/tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics @@ -0,0 +1,16 @@ +BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.6//EN
+CALSCALE:GREGORIAN
+METHOD:CANCEL
+BEGIN:VEVENT
+ATTENDEE:mailto:pierre@general-store.com
+DTSTAMP:20210820T080000Z
+ORGANIZER;CN=admin:mailto:linus@stardew-tent-living.com
+SEQUENCE:3
+UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff
+RECURRENCE-ID:20240701
+STATUS:CANCELLED
+END:VEVENT
+END:VCALENDAR
+
diff --git a/tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics.license b/tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics.license new file mode 100644 index 00000000000..f2ed5575dd3 --- /dev/null +++ b/tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/tests/data/ics/imip-handle-imip-cancel.ics b/tests/data/ics/imip-handle-imip-cancel.ics new file mode 100644 index 00000000000..59e31171b42 --- /dev/null +++ b/tests/data/ics/imip-handle-imip-cancel.ics @@ -0,0 +1,15 @@ +BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Sabre//Sabre VObject 4.5.6//EN
+CALSCALE:GREGORIAN
+METHOD:CANCEL
+BEGIN:VEVENT
+ATTENDEE:mailto:pierre@general-store.com
+DTSTAMP:20210820T080000Z
+ORGANIZER;CN=admin:mailto:linus@stardew-tent-living.com
+SEQUENCE:3
+UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff
+STATUS:CANCELLED
+END:VEVENT
+END:VCALENDAR
+
diff --git a/tests/data/ics/imip-handle-imip-cancel.ics.license b/tests/data/ics/imip-handle-imip-cancel.ics.license new file mode 100644 index 00000000000..f2ed5575dd3 --- /dev/null +++ b/tests/data/ics/imip-handle-imip-cancel.ics.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/tests/lib/Calendar/ManagerTest.php b/tests/lib/Calendar/ManagerTest.php index 1fb64d97f47..5661902cae1 100644 --- a/tests/lib/Calendar/ManagerTest.php +++ b/tests/lib/Calendar/ManagerTest.php @@ -27,6 +27,7 @@ use Psr\Log\LoggerInterface; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; use Test\TestCase; /* @@ -124,8 +125,9 @@ class ManagerTest extends TestCase { // construct calendar with a event for reply $this->vCalendar3a = new VCalendar(); /** @var VEvent $vEvent */ - $vEvent = $this->vCalendar3a->add('VEVENT', []); + $vEvent = $this->vCalendar3a->add('VEVENT'); $vEvent->UID->setValue('dcc733bf-b2b2-41f2-a8cf-550ae4b67aff'); + $vEvent->DTSTAMP->setValue('20210820T080000Z'); $vEvent->add('DTSTART', '20210820'); $vEvent->add('DTEND', '20220821'); $vEvent->add('SUMMARY', 'berry basket'); @@ -1532,8 +1534,8 @@ class ManagerTest extends TestCase { $this->assertFalse($result); } - public function testHandleImipCancelOrganiserInReplyTo(): void { - /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + public function testHandleImipCancelOrganizerInReplyTo(): void { + /** @var Manager&MockObject $manager */ $manager = $this->getMockBuilder(Manager::class) ->setConstructorArgs([ $this->coordinator, @@ -1556,7 +1558,64 @@ class ManagerTest extends TestCase { $calendar = $this->createMock(ITestCalendar::class); $calendarData = clone $this->vCalendar3a; $calendarData->add('METHOD', 'CANCEL'); + /* + * Piping the expected data through the parser on purpose due to line-ending issues. + * If you know a better way, let me know. :) + */ + $expectedCalendarData = Reader::read(file_get_contents(__DIR__ . '/../../data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics')); + $this->time->expects(self::once()) + ->method('getTime') + ->willReturn(1628374233); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with($principalUri) + ->willReturn([$calendar]); + $calendar->expects(self::once()) + ->method('search') + ->willReturn([['uri' => 'testname.ics']]); + $calendar->expects(self::once()) + ->method('handleIMipMessage') + ->with('testname.ics', $expectedCalendarData->serialize()); + // Act + $result = $manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); + // Assert + $this->assertTrue($result); + } + public function testHandleImipCancelRecurrenceId(): void { + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time, + $this->secureRandom, + $this->userManager, + $this->serverFactory, + ]) + ->onlyMethods([ + 'getCalendarsForPrincipal' + ]) + ->getMock(); + + $principalUri = 'principals/user/pierre'; + $sender = 'clint@stardew-tent-living.com'; + $recipient = 'pierre@general-store.com'; + $replyTo = 'linus@stardew-tent-living.com'; + $calendar = $this->createMock(ITestCalendar::class); + $calendarData = clone $this->vCalendar3a; + $calendarData->add('METHOD', 'CANCEL'); + /* + * The test is incomplete because we only check if copying the recurrence ID from the original event to the new event works, + * and we assume that the previous code ensures this is correct. The test data does not even include a recurrence rule. + */ + $calendarData->VEVENT->add('RECURRENCE-ID', '20240701'); + /* + * Piping the expected data through the parser on purpose due to line-ending issues. + * If you know a better way, let me know. :) + */ + $expectedCalendarData = Reader::read(file_get_contents(__DIR__ . '/../../data/ics/imip-handle-imip-cancel-recurrence-id.ics')); $this->time->expects(self::once()) ->method('getTime') ->willReturn(1628374233); @@ -1569,7 +1628,7 @@ class ManagerTest extends TestCase { ->willReturn([['uri' => 'testname.ics']]); $calendar->expects(self::once()) ->method('handleIMipMessage') - ->with('testname.ics', $calendarData->serialize()); + ->with('testname.ics', $expectedCalendarData->serialize()); // Act $result = $manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); // Assert @@ -1577,7 +1636,7 @@ class ManagerTest extends TestCase { } public function testHandleImipCancel(): void { - /** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */ + /** @var Manager&MockObject $manager */ $manager = $this->getMockBuilder(Manager::class) ->setConstructorArgs([ $this->coordinator, @@ -1599,7 +1658,11 @@ class ManagerTest extends TestCase { $calendar = $this->createMock(ITestCalendar::class); $calendarData = clone $this->vCalendar3a; $calendarData->add('METHOD', 'CANCEL'); - + /* + * Piping the expected data through the parser on purpose due to line-ending issues. + * If you know a better way, let me know. :) + */ + $expectedCalendarData = Reader::read(file_get_contents(__DIR__ . '/../../data/ics/imip-handle-imip-cancel.ics')); $this->time->expects(self::once()) ->method('getTime') ->willReturn(1628374233); @@ -1612,7 +1675,7 @@ class ManagerTest extends TestCase { ->willReturn([['uri' => 'testname.ics']]); $calendar->expects(self::once()) ->method('handleIMipMessage') - ->with('testname.ics', $calendarData->serialize()); + ->with('testname.ics', $expectedCalendarData->serialize()); // Act $result = $manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); // Assert |