From 7ebeed45bda44dabff20fba0e46b68eec627c3e0 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Sat, 7 Sep 2024 18:28:50 -0400 Subject: [PATCH] feat: add iMip Request Handling Signed-off-by: SebastianKrupinski --- .../dav/lib/CalDAV/CachedSubscriptionImpl.php | 13 +- apps/dav/lib/CalDAV/CalendarImpl.php | 19 +- lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + lib/private/Calendar/Manager.php | 83 +++++ lib/public/Calendar/ICalendar.php | 3 +- lib/public/Calendar/ICalendarIsShared.php | 25 ++ lib/public/Calendar/ICalendarIsWritable.php | 25 ++ lib/public/Calendar/IManager.php | 7 + tests/lib/Calendar/ManagerTest.php | 346 +++++++++++++++++- 10 files changed, 512 insertions(+), 13 deletions(-) create mode 100644 lib/public/Calendar/ICalendarIsShared.php create mode 100644 lib/public/Calendar/ICalendarIsWritable.php diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php index 00fa90f5d20e1..4d25f5bb50190 100644 --- a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php +++ b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php @@ -9,9 +9,12 @@ namespace OCA\DAV\CalDAV; use OCP\Calendar\ICalendar; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; use OCP\Constants; -class CachedSubscriptionImpl implements ICalendar { +class CachedSubscriptionImpl implements ICalendar, ICalendarIsShared, ICalendarIsWritable { + public function __construct( private CachedSubscription $calendar, /** @var array */ @@ -83,10 +86,18 @@ public function getPermissions(): int { return $result; } + public function isWritable(): bool { + return false; + } + public function isDeleted(): bool { return false; } + public function isShared(): bool { + return true; + } + public function getSource(): string { return $this->calendarInfo['source']; } diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index 85ca7f78ca4e3..919b08eefce2b 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -127,6 +127,13 @@ public function getPermissions(): int { return $result; } + /** + * @since 31.0.0 + */ + public function isWritable(): bool { + return $this->calendar->canWrite(); + } + /** * @since 26.0.0 */ @@ -134,6 +141,13 @@ public function isDeleted(): bool { return $this->calendar->isDeleted(); } + /** + * @since 31.0.0 + */ + public function isShared(): bool { + return $this->calendar->isShared(); + } + /** * Create a new calendar event for this calendar * by way of an ICS string @@ -215,7 +229,10 @@ public function handleIMipMessage(string $name, string $calendarData): void { $attendee = $vEvent->{'ATTENDEE'}->getValue(); $iTipMessage->method = $vObject->{'METHOD'}->getValue(); - if ($iTipMessage->method === 'REPLY') { + if ($iTipMessage->method === 'REQUEST') { + $iTipMessage->sender = $organizer; + $iTipMessage->recipient = $attendee; + } elseif ($iTipMessage->method === 'REPLY') { if ($server->isExternalAttendee($vEvent->{'ATTENDEE'}->getValue())) { $iTipMessage->recipient = $organizer; } else { diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 7265edfe95f98..352c3c11a9ed0 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -163,6 +163,8 @@ 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => $baseDir . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\Exceptions\\CalendarException' => $baseDir . '/lib/public/Calendar/Exceptions/CalendarException.php', 'OCP\\Calendar\\ICalendar' => $baseDir . '/lib/public/Calendar/ICalendar.php', + 'OCP\\Calendar\\ICalendarIsShared' => $baseDir . '/lib/public/Calendar/ICalendarIsShared.php', + 'OCP\\Calendar\\ICalendarIsWritable' => $baseDir . '/lib/public/Calendar/ICalendarIsWritable.php', 'OCP\\Calendar\\ICalendarProvider' => $baseDir . '/lib/public/Calendar/ICalendarProvider.php', 'OCP\\Calendar\\ICalendarQuery' => $baseDir . '/lib/public/Calendar/ICalendarQuery.php', 'OCP\\Calendar\\ICreateFromString' => $baseDir . '/lib/public/Calendar/ICreateFromString.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 4bac34425ac16..890c778da1d4d 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -196,6 +196,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Calendar\\BackendTemporarilyUnavailableException' => __DIR__ . '/../../..' . '/lib/public/Calendar/BackendTemporarilyUnavailableException.php', 'OCP\\Calendar\\Exceptions\\CalendarException' => __DIR__ . '/../../..' . '/lib/public/Calendar/Exceptions/CalendarException.php', 'OCP\\Calendar\\ICalendar' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendar.php', + 'OCP\\Calendar\\ICalendarIsShared' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsShared.php', + 'OCP\\Calendar\\ICalendarIsWritable' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarIsWritable.php', 'OCP\\Calendar\\ICalendarProvider' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarProvider.php', 'OCP\\Calendar\\ICalendarQuery' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICalendarQuery.php', 'OCP\\Calendar\\ICreateFromString' => __DIR__ . '/../../..' . '/lib/public/Calendar/ICreateFromString.php', diff --git a/lib/private/Calendar/Manager.php b/lib/private/Calendar/Manager.php index fa324273f5cb8..ba2124a5c239a 100644 --- a/lib/private/Calendar/Manager.php +++ b/lib/private/Calendar/Manager.php @@ -12,6 +12,8 @@ use OCP\AppFramework\Utility\ITimeFactory; use OCP\Calendar\Exceptions\CalendarException; use OCP\Calendar\ICalendar; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICalendarProvider; use OCP\Calendar\ICalendarQuery; use OCP\Calendar\ICreateFromString; @@ -204,6 +206,87 @@ public function newQuery(string $principalUri): ICalendarQuery { return new CalendarQuery($principalUri); } + /** + * @since 31.0.0 + * @throws \OCP\DB\Exception + */ + public function handleIMipRequest( + string $principalUri, + string $sender, + string $recipient, + string $calendarData, + ): bool { + + $userCalendars = $this->getCalendarsForPrincipal($principalUri); + if (empty($userCalendars)) { + $this->logger->warning('iMip message could not be processed because user has no calendars'); + return false; + } + + /** @var VCalendar $vObject|null */ + $calendarObject = Reader::read($calendarData); + + if (!isset($calendarObject->METHOD) || $calendarObject->METHOD->getValue() !== 'REQUEST') { + $this->logger->warning('iMip message contains an incorrect or invalid method'); + return false; + } + + if (!isset($calendarObject->VEVENT)) { + $this->logger->warning('iMip message contains no event'); + return false; + } + + $eventObject = $calendarObject->VEVENT; + + if (!isset($eventObject->UID)) { + $this->logger->warning('iMip message event dose not contains a UID'); + return false; + } + + if (!isset($eventObject->ATTENDEE)) { + $this->logger->warning('iMip message event dose not contains any attendees'); + return false; + } + + foreach ($eventObject->ATTENDEE as $entry) { + $address = trim(str_replace('mailto:', '', $entry->getValue())); + if ($address === $recipient) { + $attendee = $address; + break; + } + } + if (!isset($attendee)) { + $this->logger->warning('iMip message event does not contain a attendee that matches the recipient'); + return false; + } + + foreach ($userCalendars as $calendar) { + + if (!$calendar instanceof ICalendarIsWritable && !$calendar instanceof ICalendarIsShared) { + continue; + } + + if ($calendar->isDeleted() || !$calendar->isWritable() || $calendar->isShared()) { + continue; + } + + if (!empty($calendar->search($recipient, ['ATTENDEE'], ['uid' => $eventObject->UID->getValue()]))) { + try { + if ($calendar instanceof IHandleImipMessage) { + $calendar->handleIMipMessage('', $calendarData); + } + return true; + } catch (CalendarException $e) { + $this->logger->error('An error occurred while processing the iMip message event', ['exception' => $e]); + return false; + } + } + } + + $this->logger->warning('iMip message event could not be processed because the no corresponding event was found in any calendar'); + return false; + } + /** * @throws \OCP\DB\Exception */ diff --git a/lib/public/Calendar/ICalendar.php b/lib/public/Calendar/ICalendar.php index 2f74d32911946..f29d6f3017636 100644 --- a/lib/public/Calendar/ICalendar.php +++ b/lib/public/Calendar/ICalendar.php @@ -59,7 +59,8 @@ public function search(string $pattern, array $searchProperties = [], array $opt public function getPermissions(): int; /** - * Whether the calendar is deleted + * Indicates whether the calendar is in the trash bin + * * @since 26.0.0 */ public function isDeleted(): bool; diff --git a/lib/public/Calendar/ICalendarIsShared.php b/lib/public/Calendar/ICalendarIsShared.php new file mode 100644 index 0000000000000..8121c826f4ef2 --- /dev/null +++ b/lib/public/Calendar/ICalendarIsShared.php @@ -0,0 +1,25 @@ +logger, $this->time, ); + + // construct calendar with a 1 hour event and same start/end time zones + $this->vCalendar1a = new VCalendar(); + /** @var VEvent $vEvent */ + $vEvent = $this->vCalendar1a->add('VEVENT', []); + $vEvent->UID->setValue('96a0e6b1-d886-4a55-a60d-152b31401dcc'); + $vEvent->add('DTSTART', '20240701T080000', ['TZID' => 'America/Toronto']); + $vEvent->add('DTEND', '20240701T090000', ['TZID' => 'America/Toronto']); + $vEvent->add('SUMMARY', 'Test Event'); + $vEvent->add('ORGANIZER', 'mailto:organizer@testing.com', ['CN' => 'Organizer']); + $vEvent->add('ATTENDEE', 'mailto:attendee1@testing.com', [ + 'CN' => 'Attendee One', + 'CUTYPE' => 'INDIVIDUAL', + 'PARTSTAT' => 'NEEDS-ACTION', + 'ROLE' => 'REQ-PARTICIPANT', + 'RSVP' => 'TRUE' + ]); } /** @@ -230,6 +252,310 @@ public function testIfEnabledIfSo(): void { $this->assertTrue($isEnabled); } + public function testHandleImipRequestWithNoCalendars(): void { + // construct calendar manager returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message could not be processed because user has no calendars'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'REQUEST'); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequestWithNoMethod(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message contains an incorrect or invalid method'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequestWithInvalidMethod(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message contains an incorrect or invalid method'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'CANCEL'); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequestWithNoEvent(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message contains no event'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'REQUEST'); + $calendar->remove('VEVENT'); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequestWithNoUid(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message event dose not contains a UID'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'REQUEST'); + $calendar->VEVENT->remove('UID'); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequestWithNoAttendee(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message event dose not contains any attendees'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'REQUEST'); + $calendar->VEVENT->remove('ATTENDEE'); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequestWithInvalidAttendee(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message event does not contain a attendee that matches the recipient'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee2@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'REQUEST'); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequestWithNoMatch(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + $userCalendar->expects(self::once()) + ->method('isDeleted') + ->willReturn(false); + $userCalendar->expects(self::once()) + ->method('isWritable') + ->willReturn(true); + $userCalendar->expects(self::once()) + ->method('isShared') + ->willReturn(false); + $userCalendar->expects(self::once()) + ->method('search') + ->willReturn([]); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct logger returns + $this->logger->expects(self::once())->method('warning') + ->with('iMip message event could not be processed because the no corresponding event was found in any calendar'); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'REQUEST'); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertFalse($result); + } + + public function testHandleImipRequest(): void { + // construct mock user calendar + $userCalendar = $this->createMock(ITestCalendar::class); + $userCalendar->expects(self::once()) + ->method('isDeleted') + ->willReturn(false); + $userCalendar->expects(self::once()) + ->method('isWritable') + ->willReturn(true); + $userCalendar->expects(self::once()) + ->method('isShared') + ->willReturn(false); + $userCalendar->expects(self::once()) + ->method('search') + ->willReturn([['uri' => 'principals/user/attendee1/personal']]); + // construct mock calendar manager and returns + /** @var Manager&MockObject $manager */ + $manager = $this->getMockBuilder(Manager::class) + ->setConstructorArgs([ + $this->coordinator, + $this->container, + $this->logger, + $this->time + ]) + ->onlyMethods(['getCalendarsForPrincipal']) + ->getMock(); + $manager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->willReturn([$userCalendar]); + // construct parameters + $principalUri = 'principals/user/attendee1'; + $sender = 'organizer@testing.com'; + $recipient = 'attendee1@testing.com'; + $calendar = $this->vCalendar1a; + $calendar->add('METHOD', 'REQUEST'); + // construct user calendar returns + $userCalendar->expects(self::once()) + ->method('handleIMipMessage') + ->with('', $calendar->serialize()); + // test method + $result = $manager->handleIMipRequest($principalUri, $sender, $recipient, $calendar->serialize()); + $this->assertTrue($result); + } + public function testHandleImipReplyWrongMethod(): void { $principalUri = 'principals/user/linus'; $sender = 'pierre@general-store.com'; @@ -323,7 +649,7 @@ public function testHandleImipReplyEventNotFound(): void { 'getCalendarsForPrincipal' ]) ->getMock(); - $calendar = $this->createMock(ICreateFromStringAndHandleImipMessage::class); + $calendar = $this->createMock(ITestCalendar::class); $principalUri = 'principals/user/linus'; $sender = 'pierre@general-store.com'; $recipient = 'linus@stardew-tent-living.com'; @@ -360,7 +686,7 @@ public function testHandleImipReply(): void { 'getCalendarsForPrincipal' ]) ->getMock(); - $calendar = $this->createMock(ICreateFromStringAndHandleImipMessage::class); + $calendar = $this->createMock(ITestCalendar::class); $principalUri = 'principals/user/linus'; $sender = 'pierre@general-store.com'; $recipient = 'linus@stardew-tent-living.com'; @@ -484,7 +810,7 @@ public function testHandleImipCancelOrganiserInReplyTo(): void { $sender = 'clint@stardew-blacksmiths.com'; $recipient = 'pierre@general-store.com'; $replyTo = 'linus@stardew-tent-living.com'; - $calendar = $this->createMock(ICreateFromStringAndHandleImipMessage::class); + $calendar = $this->createMock(ITestCalendar::class); $calendarData = $this->getVCalendarCancel(); $this->time->expects(self::once()) @@ -521,7 +847,7 @@ public function testHandleImipCancel(): void { $sender = 'linus@stardew-tent-living.com'; $recipient = 'pierre@general-store.com'; $replyTo = null; - $calendar = $this->createMock(ICreateFromStringAndHandleImipMessage::class); + $calendar = $this->createMock(ITestCalendar::class); $calendarData = $this->getVCalendarCancel(); $this->time->expects(self::once()) @@ -540,7 +866,7 @@ public function testHandleImipCancel(): void { $result = $manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize()); $this->assertTrue($result); } - + private function getVCalendarReply(): Document { $data = <<