Skip to content

Commit

Permalink
fix: override iTip Broker to fix several issues
Browse files Browse the repository at this point in the history
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
  • Loading branch information
SebastianKrupinski committed Nov 14, 2024
1 parent b2bc3bb commit 97c3938
Show file tree
Hide file tree
Showing 5 changed files with 374 additions and 0 deletions.
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
'OCA\\DAV\\CalDAV\\Security\\RateLimitingPlugin' => $baseDir . '/../lib/CalDAV/Security/RateLimitingPlugin.php',
'OCA\\DAV\\CalDAV\\Status\\StatusService' => $baseDir . '/../lib/CalDAV/Status/StatusService.php',
'OCA\\DAV\\CalDAV\\TimezoneService' => $baseDir . '/../lib/CalDAV/TimezoneService.php',
'OCA\\DAV\\CalDAV\\TipBroker' => $baseDir . '/../lib/CalDAV/TipBroker.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => $baseDir . '/../lib/CalDAV/Trashbin/Plugin.php',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Security\\RateLimitingPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/Security/RateLimitingPlugin.php',
'OCA\\DAV\\CalDAV\\Status\\StatusService' => __DIR__ . '/..' . '/../lib/CalDAV/Status/StatusService.php',
'OCA\\DAV\\CalDAV\\TimezoneService' => __DIR__ . '/..' . '/../lib/CalDAV/TimezoneService.php',
'OCA\\DAV\\CalDAV\\TipBroker' => __DIR__ . '/..' . '/../lib/CalDAV/TipBroker.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php',
'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/Plugin.php',
Expand Down
7 changes: 7 additions & 0 deletions apps/dav/lib/CalDAV/Schedule/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ public function initialize(Server $server) {
$server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']);
}

/**
* Returns an instance of the iTip\Broker.
*/
protected function createITipBroker(): TipBroker {

Check failure on line 103 in apps/dav/lib/CalDAV/Schedule/Plugin.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

MethodSignatureMismatch

apps/dav/lib/CalDAV/Schedule/Plugin.php:103:2: MethodSignatureMismatch: Method OCA\DAV\CalDAV\Schedule\Plugin::createITipBroker with return type 'OCA\DAV\CalDAV\Schedule\TipBroker' is different to return type 'Sabre\VObject\ITip\Broker' of inherited method Sabre\CalDAV\Schedule\Plugin::createITipBroker (see https://psalm.dev/042)

Check failure on line 103 in apps/dav/lib/CalDAV/Schedule/Plugin.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedClass

apps/dav/lib/CalDAV/Schedule/Plugin.php:103:41: UndefinedClass: Class, interface or enum named OCA\DAV\CalDAV\Schedule\TipBroker does not exist (see https://psalm.dev/019)

Check failure

Code scanning / Psalm

MethodSignatureMismatch Error

Method OCA\DAV\CalDAV\Schedule\Plugin::createITipBroker with return type 'OCA\DAV\CalDAV\Schedule\TipBroker' is different to return type 'Sabre\VObject\ITip\Broker' of inherited method Sabre\CalDAV\Schedule\Plugin::createITipBroker

Check failure

Code scanning / Psalm

UndefinedClass Error

Class, interface or enum named OCA\DAV\CalDAV\Schedule\TipBroker does not exist
return new TipBroker();

Check failure on line 104 in apps/dav/lib/CalDAV/Schedule/Plugin.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedClass

apps/dav/lib/CalDAV/Schedule/Plugin.php:104:14: UndefinedClass: Class, interface or enum named OCA\DAV\CalDAV\Schedule\TipBroker does not exist (see https://psalm.dev/019)

Check failure

Code scanning / Psalm

UndefinedClass Error

Class, interface or enum named OCA\DAV\CalDAV\Schedule\TipBroker does not exist
}

/**
* Allow manual setting of the object change URL
* to support public write
Expand Down
187 changes: 187 additions & 0 deletions apps/dav/lib/CalDAV/TipBroker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\CalDAV;

use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\ITip\Broker;
use Sabre\VObject\ITip\Message;

class TipBroker extends Broker {

public $significantChangeProperties = [
'DTSTART',
'DTEND',
'DURATION',
'DUE',
'RRULE',
'RDATE',
'EXDATE',
'STATUS',
'SUMMARY',
'DESCRIPTION',
'LOCATION',

];

/**
* This method is used in cases where an event got updated, and we
* potentially need to send emails to attendees to let them know of updates
* in the events.
*
* We will detect which attendees got added, which got removed and create
* specific messages for these situations.
*
* @return array
*/
protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) {
// Merging attendee lists.
$attendees = [];
foreach ($oldEventInfo['attendees'] as $attendee) {
$attendees[$attendee['href']] = [
'href' => $attendee['href'],
'oldInstances' => $attendee['instances'],
'newInstances' => [],
'name' => $attendee['name'],
'forceSend' => null,
];
}
foreach ($eventInfo['attendees'] as $attendee) {
if (isset($attendees[$attendee['href']])) {
$attendees[$attendee['href']]['name'] = $attendee['name'];
$attendees[$attendee['href']]['newInstances'] = $attendee['instances'];
$attendees[$attendee['href']]['forceSend'] = $attendee['forceSend'];
} else {
$attendees[$attendee['href']] = [
'href' => $attendee['href'],
'oldInstances' => [],
'newInstances' => $attendee['instances'],
'name' => $attendee['name'],
'forceSend' => $attendee['forceSend'],
];
}
}

$messages = [];

foreach ($attendees as $attendee) {
// An organizer can also be an attendee. We should not generate any
// messages for those.
if ($attendee['href'] === $eventInfo['organizer']) {
continue;
}

$message = new Message();
$message->uid = $eventInfo['uid'];
$message->component = 'VEVENT';
$message->sequence = $eventInfo['sequence'];
$message->sender = $eventInfo['organizer'];
$message->senderName = $eventInfo['organizerName'];
$message->recipient = $attendee['href'];
$message->recipientName = $attendee['name'];

// Creating the new iCalendar body.
$icalMsg = new VCalendar();

foreach ($calendar->select('VTIMEZONE') as $timezone) {
$icalMsg->add(clone $timezone);

Check notice

Code scanning / Psalm

MixedClone Note

Cannot clone mixed
}
// If there are no instances the attendee is a part of, it means
// the attendee was removed and we need to send them a CANCEL message.
// Also If the meeting STATUS property was changed to CANCELLED
// we need to send the attendee a CANCEL message.
if (!$attendee['newInstances'] || $eventInfo['status'] === 'CANCELLED') {

Check notice

Code scanning / Psalm

RiskyTruthyFalsyComparison Note

Operand of type array<never, never>|mixed contains type mixed, which can be falsy and truthy. This can cause possibly unexpected behavior. Use strict comparison instead.

$message->method = $icalMsg->METHOD = 'CANCEL';
$message->significantChange = true;
// clone base event
$event = clone $eventInfo['instances']['master'];

Check notice

Code scanning / Psalm

MixedClone Note

Cannot clone mixed
// alter some properties
unset($event->ATTENDEE);
$event->add('ATTENDEE', $attendee['href'], ['CN' => $attendee['name'],]);
$event->DTSTAMP = gmdate('Ymd\\THis\\Z');
$event->SEQUENCE = $message->sequence;
$icalMsg->add($event);

} else {
// The attendee gets the updated event body
$message->method = $icalMsg->METHOD = 'REQUEST';

// We need to find out that this change is significant. If it's
// not, systems may opt to not send messages.
//
// We do this based on the 'significantChangeHash' which is
// some value that changes if there's a certain set of
// properties changed in the event, or simply if there's a
// difference in instances that the attendee is invited to.

$oldAttendeeInstances = array_keys($attendee['oldInstances']);
$newAttendeeInstances = array_keys($attendee['newInstances']);

$message->significantChange =
$attendee['forceSend'] === 'REQUEST' ||
count($oldAttendeeInstances) !== count($newAttendeeInstances) ||
count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 ||
$oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash'];

foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) {
$currentEvent = clone $eventInfo['instances'][$instanceId];

Check notice

Code scanning / Psalm

MixedClone Note

Cannot clone mixed
if ($instanceId === 'master') {
// We need to find a list of events that the attendee
// is not a part of to add to the list of exceptions.
$exceptions = [];
foreach ($eventInfo['instances'] as $instanceId => $vevent) {
if (!isset($attendee['newInstances'][$instanceId])) {
$exceptions[] = $instanceId;
}
}

// If there were exceptions, we need to add it to an
// existing EXDATE property, if it exists.
if ($exceptions) {
if (isset($currentEvent->EXDATE)) {
$currentEvent->EXDATE->setParts(array_merge(
$currentEvent->EXDATE->getParts(),
$exceptions
));
} else {
$currentEvent->EXDATE = $exceptions;
}
}

// Cleaning up any scheduling information that
// shouldn't be sent along.
unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']);
unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']);

foreach ($currentEvent->ATTENDEE as $attendee) {
unset($attendee['SCHEDULE-FORCE-SEND']);
unset($attendee['SCHEDULE-STATUS']);

// We're adding PARTSTAT=NEEDS-ACTION to ensure that
// iOS shows an "Inbox Item"
if (!isset($attendee['PARTSTAT'])) {
$attendee['PARTSTAT'] = 'NEEDS-ACTION';
}
}
}

$currentEvent->DTSTAMP = gmdate('Ymd\\THis\\Z');
$icalMsg->add($currentEvent);
}
}

$message->message = $icalMsg;
$messages[] = $message;
}

return $messages;
}

}
178 changes: 178 additions & 0 deletions apps/dav/tests/unit/CalDAV/TipBrokerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV;

use OCA\DAV\CalDAV\TipBroker;
use Sabre\VObject\Component\VCalendar;
use Test\TestCase;

class TipBrokerTest extends TestCase {

private TipBroker $broker;
private VCalendar $vCalendar1a;

protected function setUp(): void {
parent::setUp();

$this->broker = new TipBroker();
// 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->add('UID', '96a0e6b1-d886-4a55-a60d-152b31401dcc');
$vEvent->add('DTSTAMP', '20240701T000000Z');
$vEvent->add('CREATED', '20240701T000000Z');
$vEvent->add('LAST-MODIFIED', '20240701T000000Z');
$vEvent->add('SEQUENCE', '1');
$vEvent->add('STATUS', 'CONFIRMED');
$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'
]);
}

public function testParseEventForOrganizerOnCreate(): void {

// construct calendar and generate event info for newly created event with one attendee
$calendar = clone $this->vCalendar1a;
$previousEventInfo = [
'organizer' => null,
'significantChangeHash' => '',
'attendees' => [],
];
$currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
// test iTip generation
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]);
$this->assertCount(1, $messages);
$this->assertEquals('REQUEST', $messages[0]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
$this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);

}

public function testParseEventForOrganizerOnModify(): void {

// construct calendar and generate event info for modified event with one attendee
$calendar = clone $this->vCalendar1a;
$previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
$calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
$calendar->VEVENT->SEQUENCE->setValue(2);
$calendar->VEVENT->SUMMARY->setValue('Test Event Modified');
$currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
// test iTip generation
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]);
$this->assertCount(1, $messages);
$this->assertEquals('REQUEST', $messages[0]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
$this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);

}

public function testParseEventForOrganizerOnDelete(): void {

// construct calendar and generate event info for modified event with one attendee
$calendar = clone $this->vCalendar1a;
$previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
$currentEventInfo = $previousEventInfo;
$currentEventInfo['attendees'] = [];
++$currentEventInfo['sequence'];
// test iTip generation
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]);
$this->assertCount(1, $messages);
$this->assertEquals('CANCEL', $messages[0]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
$this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);

}

public function testParseEventForOrganizerOnStatusCancelled(): void {

// construct calendar and generate event info for modified event with one attendee
$calendar = clone $this->vCalendar1a;
$previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
$calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
$calendar->VEVENT->SEQUENCE->setValue(2);
$calendar->VEVENT->STATUS->setValue('CANCELLED');
$currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
// test iTip generation
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]);
$this->assertCount(1, $messages);
$this->assertEquals('CANCEL', $messages[0]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
$this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);

}

public function testParseEventForOrganizerOnAddAttendee(): void {

// construct calendar and generate event info for modified event with two attendees
$calendar = clone $this->vCalendar1a;
$previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
$calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
$calendar->VEVENT->SEQUENCE->setValue(2);
$calendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [
'CN' => 'Attendee Two',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT',
'RSVP' => 'TRUE'
]);
$currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
// test iTip generation
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]);
$this->assertCount(2, $messages);
$this->assertEquals('REQUEST', $messages[0]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
$this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);
$this->assertEquals('REQUEST', $messages[1]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender);
$this->assertEquals($calendar->VEVENT->ATTENDEE[1]->getValue(), $messages[1]->recipient);

}

public function testParseEventForOrganizerOnRemoveAttendee(): void {

// construct calendar and generate event info for modified event with two attendees
$calendar = clone $this->vCalendar1a;
$calendar->VEVENT->add('ATTENDEE', 'mailto:attendee2@testing.com', [
'CN' => 'Attendee Two',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT',
'RSVP' => 'TRUE'
]);
$previousEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
$calendar->VEVENT->{'LAST-MODIFIED'}->setValue('20240701T020000Z');
$calendar->VEVENT->SEQUENCE->setValue(2);
$calendar->VEVENT->remove('ATTENDEE');
$calendar->VEVENT->add('ATTENDEE', 'mailto:attendee1@testing.com', [
'CN' => 'Attendee One',
'CUTYPE' => 'INDIVIDUAL',
'PARTSTAT' => 'NEEDS-ACTION',
'ROLE' => 'REQ-PARTICIPANT',
'RSVP' => 'TRUE'
]);
$currentEventInfo = $this->invokePrivate($this->broker, 'parseEventInfo', [$calendar]);
// test iTip generation
$messages = $this->invokePrivate($this->broker, 'parseEventForOrganizer', [$calendar, $currentEventInfo, $previousEventInfo]);
$this->assertCount(2, $messages);
$this->assertEquals('REQUEST', $messages[0]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[0]->sender);
$this->assertEquals($calendar->VEVENT->ATTENDEE[0]->getValue(), $messages[0]->recipient);
$this->assertEquals('CANCEL', $messages[1]->method);
$this->assertEquals($calendar->VEVENT->ORGANIZER->getValue(), $messages[1]->sender);
$this->assertEquals('mailto:attendee2@testing.com', $messages[1]->recipient);

}

}

0 comments on commit 97c3938

Please sign in to comment.