]> source.dussan.org Git - nextcloud-server.git/commitdiff
fix: override iTip Broker to fix several issues backport/48583/stable28 49269/head
authorSebastianKrupinski <krupinskis05@gmail.com>
Sun, 6 Oct 2024 22:39:36 +0000 (18:39 -0400)
committerSebastianKrupinski <krupinskis05@gmail.com>
Mon, 18 Nov 2024 00:24:39 +0000 (19:24 -0500)
Signed-off-by: SebastianKrupinski <krupinskis05@gmail.com>
apps/dav/composer/composer/autoload_classmap.php
apps/dav/composer/composer/autoload_static.php
apps/dav/lib/CalDAV/Schedule/Plugin.php
apps/dav/lib/CalDAV/TipBroker.php [new file with mode: 0644]
apps/dav/tests/unit/CalDAV/TipBrokerTest.php [new file with mode: 0644]

index 1e0bac392819a713c91eabc029b18f9b2d1c12b7..bb4e6daf5183b4ecb324001b6873a34a4f5f8154 100644 (file)
@@ -105,6 +105,7 @@ return array(
     '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',
index 4e931c10af78298be6801028b6ed0a9fdab5492e..af38a1bc08073e77f1bda62a3bc6e679de811cf1 100644 (file)
@@ -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',
index c6d08b0f3b77aa2de77325b46196395b1216dd00..26ea9f2531bd913161025899f583362cb3bfe50a 100644 (file)
@@ -33,6 +33,7 @@ use DateTimeZone;
 use OCA\DAV\CalDAV\CalDavBackend;
 use OCA\DAV\CalDAV\Calendar;
 use OCA\DAV\CalDAV\CalendarHome;
+use OCA\DAV\CalDAV\TipBroker;
 use OCP\IConfig;
 use Psr\Log\LoggerInterface;
 use Sabre\CalDAV\ICalendar;
@@ -97,6 +98,13 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
                $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']);
        }
 
+       /**
+        * Returns an instance of the iTip\Broker.
+        */
+       protected function createITipBroker(): TipBroker {
+               return new TipBroker();
+       }
+
        /**
         * Allow manual setting of the object change URL
         * to support public write
diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php
new file mode 100644 (file)
index 0000000..43eff12
--- /dev/null
@@ -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);
+                       }
+                       // 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') {
+                               
+                               $message->method = $icalMsg->METHOD = 'CANCEL';
+                               $message->significantChange = true;
+                               // clone base event
+                               $event = clone $eventInfo['instances']['master'];
+                               // 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];
+                                       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;
+       }
+
+}
diff --git a/apps/dav/tests/unit/CalDAV/TipBrokerTest.php b/apps/dav/tests/unit/CalDAV/TipBrokerTest.php
new file mode 100644 (file)
index 0000000..3a8e240
--- /dev/null
@@ -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);
+
+       }
+
+}