feat: add iMip Request Handling

Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
pull/47826/head
SebastianKrupinski 2024-09-07 18:28:50 +07:00
parent 3bd8197458
commit 7ebeed45bd
10 changed files with 512 additions and 13 deletions

@ -9,9 +9,12 @@ declare(strict_types=1);
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<string, mixed> */
@ -83,10 +86,18 @@ class CachedSubscriptionImpl implements ICalendar {
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'];
}

@ -127,6 +127,13 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage {
return $result;
}
/**
* @since 31.0.0
*/
public function isWritable(): bool {
return $this->calendar->canWrite();
}
/**
* @since 26.0.0
*/
@ -134,6 +141,13 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage {
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 @@ class CalendarImpl implements ICreateFromString, IHandleImipMessage {
$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 {

@ -163,6 +163,8 @@ return array(
'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',

@ -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',

@ -12,6 +12,8 @@ use OC\AppFramework\Bootstrap\Coordinator;
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 @@ class Manager implements IManager {
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
*/

@ -59,7 +59,8 @@ interface ICalendar {
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;

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Calendar;
/**
* ICalendar Interface Extension
*
* @since 31.0.0
*/
interface ICalendarIsShared {
/**
* Indicates whether the calendar is shared with the current user
*
* @since 31.0.0
*/
public function isShared(): bool;
}

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\Calendar;
/**
* ICalendar Interface Extension
*
* @since 31.0.0
*/
interface ICalendarIsWritable {
/**
* Indicates whether the calendar can be modified
*
* @since 31.0.0
*/
public function isWritable(): bool;
}

@ -137,6 +137,13 @@ interface IManager {
*/
public function newQuery(string $principalUri) : ICalendarQuery;
/**
* Handle a iMip REQUEST message
*
* @since 31.0.0
*/
public function handleIMipRequest(string $principalUri, string $sender, string $recipient, string $calendarData): bool;
/**
* Handle a iMip REPLY message
*

@ -10,11 +10,14 @@ use OC\AppFramework\Bootstrap\Coordinator;
use OC\Calendar\Manager;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarIsShared;
use OCP\Calendar\ICalendarIsWritable;
use OCP\Calendar\ICreateFromString;
use OCP\Calendar\IHandleImipMessage;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Document;
use Sabre\VObject\Reader;
use Test\TestCase;
@ -22,25 +25,27 @@ use Test\TestCase;
/*
* This allows us to create Mock object supporting both interfaces
*/
interface ICreateFromStringAndHandleImipMessage extends ICreateFromString, IHandleImipMessage {
interface ITestCalendar extends ICreateFromString, IHandleImipMessage, ICalendarIsShared, ICalendarIsWritable {
}
class ManagerTest extends TestCase {
/** @var Coordinator|MockObject */
/** @var Coordinator&MockObject */
private $coordinator;
/** @var MockObject|ContainerInterface */
/** @var ContainerInterface&MockObject */
private $container;
/** @var MockObject|LoggerInterface */
/** @var LoggerInterface&MockObject */
private $logger;
/** @var Manager */
private $manager;
/** @var ITimeFactory|ITimeFactory&MockObject|MockObject */
/** @var ITimeFactory&MockObject */
private $time;
private VCalendar $vCalendar1a;
protected function setUp(): void {
parent::setUp();
@ -55,6 +60,23 @@ class ManagerTest extends TestCase {
$this->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 @@ class ManagerTest extends TestCase {
$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 @@ class ManagerTest extends TestCase {
'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 @@ class ManagerTest extends TestCase {
'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 @@ class ManagerTest extends TestCase {
$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 @@ class ManagerTest extends TestCase {
$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 @@ class ManagerTest extends TestCase {
$result = $manager->handleIMipCancel($principalUri, $sender, $replyTo, $recipient, $calendarData->serialize());
$this->assertTrue($result);
}
private function getVCalendarReply(): Document {
$data = <<<EOF
BEGIN:VCALENDAR