aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDaniel Kesselberg <mail@danielkesselberg.de>2025-02-06 22:24:08 +0100
committerDaniel Kesselberg <mail@danielkesselberg.de>2025-05-22 18:19:36 +0200
commitb86d600d029be113cd30696d849b48dde71c117e (patch)
treee279ae54578908d12844aa097e33d920f56ba31c
parentfb4a06fef86a8a5d06075aad82dd4b3bfafe4d9a (diff)
downloadnextcloud-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>
-rw-r--r--lib/private/Calendar/Manager.php72
-rw-r--r--tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics15
-rw-r--r--tests/data/ics/imip-handle-imip-cancel-organizer-in-reply-to.ics.license2
-rw-r--r--tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics16
-rw-r--r--tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics.license2
-rw-r--r--tests/data/ics/imip-handle-imip-cancel.ics15
-rw-r--r--tests/data/ics/imip-handle-imip-cancel.ics.license2
-rw-r--r--tests/lib/Calendar/ManagerTest.php77
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