Skip to content
Closed
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
71 changes: 56 additions & 15 deletions lib/private/Calendar/Manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
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 Throwable;
Expand Down Expand Up @@ -236,7 +237,7 @@ public function handleIMipRequest(
$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);
Expand All @@ -255,7 +256,7 @@ public function handleIMipRequest(
return false;
}

/** @var VEvent|null $vEvent */
/** @var VEvent $eventObject */
$eventObject = $calendarObject->VEVENT;

if (!isset($eventObject->UID)) {
Expand All @@ -273,14 +274,7 @@ public function handleIMipRequest(
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;
}
Expand Down Expand Up @@ -457,8 +451,8 @@ public function handleIMipCancel(
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');
Expand All @@ -475,8 +469,7 @@ public function handleIMipCancel(
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;
}
Expand Down Expand Up @@ -522,15 +515,52 @@ public function handleIMipCancel(
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]);
return false;
}
}

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);
Expand Down Expand Up @@ -618,4 +648,15 @@ public function checkAvailability(

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;
}
}
Original file line number Diff line number Diff line change
@@ -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:[email protected]
DTSTAMP:20210820T080000Z
ORGANIZER;CN=admin:mailto:[email protected]
SEQUENCE:3
UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
16 changes: 16 additions & 0 deletions tests/data/ics/imip-handle-imip-cancel-recurrence-id.ics
Original file line number Diff line number Diff line change
@@ -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:[email protected]
DTSTAMP:20210820T080000Z
ORGANIZER;CN=admin:mailto:[email protected]
SEQUENCE:3
UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff
RECURRENCE-ID:20240701
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
15 changes: 15 additions & 0 deletions tests/data/ics/imip-handle-imip-cancel.ics
Original file line number Diff line number Diff line change
@@ -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:[email protected]
DTSTAMP:20210820T080000Z
ORGANIZER;CN=admin:mailto:[email protected]
SEQUENCE:3
UID:dcc733bf-b2b2-41f2-a8cf-550ae4b67aff
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR

2 changes: 2 additions & 0 deletions tests/data/ics/imip-handle-imip-cancel.ics.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: AGPL-3.0-or-later
77 changes: 70 additions & 7 deletions tests/lib/Calendar/ManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Reader;
use Test\TestCase;

/*
Expand Down Expand Up @@ -124,8 +125,9 @@ protected function setUp(): void {
// 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');
Expand Down Expand Up @@ -1532,8 +1534,8 @@ public function testHandleImipCancelEventNotFound(): void {
$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,
Expand All @@ -1556,7 +1558,64 @@ public function testHandleImipCancelOrganiserInReplyTo(): void {
$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 = '[email protected]';
$recipient = '[email protected]';
$replyTo = '[email protected]';
$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);
Expand All @@ -1569,15 +1628,15 @@ public function testHandleImipCancelOrganiserInReplyTo(): void {
->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
$this->assertTrue($result);
}

public function testHandleImipCancel(): void {
/** @var Manager | \PHPUnit\Framework\MockObject\MockObject $manager */
/** @var Manager&MockObject $manager */
$manager = $this->getMockBuilder(Manager::class)
->setConstructorArgs([
$this->coordinator,
Expand All @@ -1599,7 +1658,11 @@ public function testHandleImipCancel(): void {
$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);
Expand All @@ -1612,7 +1675,7 @@ public function testHandleImipCancel(): void {
->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
Expand Down
Loading