diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index afdd473aee..24fe625e00 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -24,17 +24,36 @@ */ namespace OCA\DAV\CalDAV\Schedule; +use DateTimeZone; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalendarHome; +use Sabre\CalDAV\ICalendar; use Sabre\DAV\INode; use Sabre\DAV\IProperties; use Sabre\DAV\PropFind; use Sabre\DAV\Server; use Sabre\DAV\Xml\Property\LocalHref; use Sabre\DAVACL\IPrincipal; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; +use Sabre\VObject\Component; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\DateTimeParser; +use Sabre\VObject\ITip; +use Sabre\VObject\Parameter; +use Sabre\VObject\Property; +use Sabre\VObject\Reader; +use Sabre\VObject\FreeBusyGenerator; class Plugin extends \Sabre\CalDAV\Schedule\Plugin { + /** @var ITip\Message[] */ + private $schedulingResponses = []; + + /** @var string|null */ + private $pathOfCalendarObjectChange = null; + /** * Initializes the plugin * @@ -44,6 +63,8 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { function initialize(Server $server) { parent::initialize($server); $server->on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90); + $server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']); + $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']); } /** @@ -56,8 +77,6 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { * @return void */ function propFind(PropFind $propFind, INode $node) { - parent::propFind($propFind, $node); - if ($node instanceof IPrincipal) { // overwrite Sabre/Dav's implementation $propFind->handle('{' . self::NS_CALDAV . '}calendar-user-type', function () use ($node) { @@ -73,6 +92,8 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { return 'INDIVIDUAL'; }); } + + parent::propFind($propFind, $node); } /** @@ -91,6 +112,144 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { return $result; } + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @param VCalendar $vCal + * @param mixed $calendarPath + * @param mixed $modified + * @param mixed $isNew + */ + public function calendarObjectChange(RequestInterface $request, ResponseInterface $response, VCalendar $vCal, $calendarPath, &$modified, $isNew) { + // Save the first path we get as a calendar-object-change request + if (!$this->pathOfCalendarObjectChange) { + $this->pathOfCalendarObjectChange = $request->getPath(); + } + + parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew); + } + + /** + * @inheritDoc + */ + public function scheduleLocalDelivery(ITip\Message $iTipMessage):void { + parent::scheduleLocalDelivery($iTipMessage); + + // We only care when the message was successfully delivered locally + if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') { + return; + } + + // We only care about request. reply and cancel are properly handled + // by parent::scheduleLocalDelivery already + if (strcasecmp($iTipMessage->method, 'REQUEST') !== 0) { + return; + } + + // If parent::scheduleLocalDelivery set scheduleStatus to 1.2, + // it means that it was successfully delivered locally. + // Meaning that the ACL plugin is loaded and that a principial + // exists for the given recipient id, no need to double check + /** @var \Sabre\DAVACL\Plugin $aclPlugin */ + $aclPlugin = $this->server->getPlugin('acl'); + $principalUri = $aclPlugin->getPrincipalByUri($iTipMessage->recipient); + $calendarUserType = $this->getCalendarUserTypeForPrincipal($principalUri); + if (strcasecmp($calendarUserType, 'ROOM') !== 0 && strcasecmp($calendarUserType, 'RESOURCE') !== 0) { + return; + } + + $attendee = $this->getCurrentAttendee($iTipMessage); + if (!$attendee) { + return; + } + + // We only respond when a response was actually requested + $rsvp = $this->getAttendeeRSVP($attendee); + if (!$rsvp) { + return; + } + + if (!isset($iTipMessage->message)) { + return; + } + + $vcalendar = $iTipMessage->message; + if (!isset($vcalendar->VEVENT)) { + return; + } + + /** @var Component $vevent */ + $vevent = $vcalendar->VEVENT; + + // We don't support autoresponses for recurrencing events for now + if (isset($vevent->RRULE) || isset($vevent->RDATE)) { + return; + } + + $dtstart = $vevent->DTSTART; + $dtend = $this->getDTEndFromVEvent($vevent); + $uid = $vevent->UID->getValue(); + $sequence = isset($vevent->SEQUENCE) ? $vevent->SEQUENCE->getValue() : 0; + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? $vevent->{'RECURRENCE-ID'}->serialize() : ''; + + $message = <<isAvailableAtTime($attendee->getValue(), $dtstart->getDateTime(), $dtend->getDateTime(), $uid)) { + $partStat = 'ACCEPTED'; + } else { + $partStat = 'DECLINED'; + } + + $vObject = Reader::read(vsprintf($message, [ + $partStat, + $iTipMessage->recipient, + $iTipMessage->sender, + $uid, + $sequence, + $recurrenceId + ])); + + $responseITipMessage = new ITip\Message(); + $responseITipMessage->uid = $uid; + $responseITipMessage->component = 'VEVENT'; + $responseITipMessage->method = 'REPLY'; + $responseITipMessage->sequence = $sequence; + $responseITipMessage->sender = $iTipMessage->recipient; + $responseITipMessage->recipient = $iTipMessage->sender; + $responseITipMessage->message = $vObject; + + // We can't dispatch them now already, because the organizers calendar-object + // was not yet created. Hence Sabre/DAV won't find a calendar-object, when we + // send our reply. + $this->schedulingResponses[] = $responseITipMessage; + } + + /** + * @param string $uri + */ + public function dispatchSchedulingResponses(string $uri):void { + if ($uri !== $this->pathOfCalendarObjectChange) { + return; + } + + foreach ($this->schedulingResponses as $schedulingResponse) { + $this->scheduleLocalDelivery($schedulingResponse); + } + } + /** * Always use the personal calendar as target for scheduled events * @@ -140,4 +299,238 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { }); } } + + /** + * Returns a list of addresses that are associated with a principal. + * + * @param string $principal + * @return string? + */ + protected function getCalendarUserTypeForPrincipal($principal):?string { + $calendarUserType = '{' . self::NS_CALDAV . '}calendar-user-type'; + $properties = $this->server->getProperties( + $principal, + [$calendarUserType] + ); + + // If we can't find this information, we'll stop processing + if (!isset($properties[$calendarUserType])) { + return null; + } + + return $properties[$calendarUserType]; + } + + /** + * @param ITip\Message $iTipMessage + * @return null|Property + */ + private function getCurrentAttendee(ITip\Message $iTipMessage):?Property { + /** @var VEvent $vevent */ + $vevent = $iTipMessage->message->VEVENT; + $attendees = $vevent->select('ATTENDEE'); + foreach ($attendees as $attendee) { + /** @var Property $attendee */ + if (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { + return $attendee; + } + } + return null; + } + + /** + * @param Property|null $attendee + * @return bool + */ + private function getAttendeeRSVP(Property $attendee = null):bool { + if ($attendee !== null) { + $rsvp = $attendee->offsetGet('RSVP'); + if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { + return true; + } + } + // RFC 5545 3.2.17: default RSVP is false + return false; + } + + /** + * @param VEvent $vevent + * @return Property\ICalendar\DateTime + */ + private function getDTEndFromVEvent(VEvent $vevent):Property\ICalendar\DateTime { + if (isset($vevent->DTEND)) { + return $vevent->DTEND; + } + + if (isset($vevent->DURATION)) { + $isFloating = $vevent->DTSTART->isFloating(); + /** @var Property\ICalendar\DateTime $end */ + $end = clone $vevent->DTSTART; + $endDateTime = $end->getDateTime(); + $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); + $end->setDateTime($endDateTime, $isFloating); + return $end; + } + + if (!$vevent->DTSTART->hasTime()) { + $isFloating = $vevent->DTSTART->isFloating(); + /** @var Property\ICalendar\DateTime $end */ + $end = clone $vevent->DTSTART; + $endDateTime = $end->getDateTime(); + $endDateTime = $endDateTime->modify('+1 day'); + $end->setDateTime($endDateTime, $isFloating); + return $end; + } + + return clone $vevent->DTSTART; + } + + /** + * @param string $email + * @param \DateTimeInterface $start + * @param \DateTimeInterface $end + * @param string $ignoreUID + * @return bool + */ + private function isAvailableAtTime(string $email, \DateTimeInterface $start, \DateTimeInterface $end, string $ignoreUID):bool { + // This method is heavily inspired by Sabre\CalDAV\Schedule\Plugin::scheduleLocalDelivery + // and Sabre\CalDAV\Schedule\Plugin::getFreeBusyForEmail + + $aclPlugin = $this->server->getPlugin('acl'); + $this->server->removeListener('propFind', [$aclPlugin, 'propFind']); + + $result = $aclPlugin->principalSearch( + ['{http://sabredav.org/ns}email-address' => $this->stripOffMailTo($email)], + [ + '{DAV:}principal-URL', + '{' . self::NS_CALDAV . '}calendar-home-set', + '{' . self::NS_CALDAV . '}schedule-inbox-URL', + '{http://sabredav.org/ns}email-address', + + ] + ); + $this->server->on('propFind', [$aclPlugin, 'propFind'], 20); + + + // Grabbing the calendar list + $objects = []; + $calendarTimeZone = new DateTimeZone('UTC'); + + $homePath = $result[0][200]['{' . self::NS_CALDAV . '}calendar-home-set']->getHref(); + foreach ($this->server->tree->getNodeForPath($homePath)->getChildren() as $node) { + if (!$node instanceof ICalendar) { + continue; + } + + // Getting the list of object uris within the time-range + $urls = $node->calendarQuery([ + 'name' => 'VCALENDAR', + 'comp-filters' => [ + [ + 'name' => 'VEVENT', + 'is-not-defined' => false, + 'time-range' => [ + 'start' => $start, + 'end' => $end, + ], + 'comp-filters' => [], + 'prop-filters' => [], + ], + [ + 'name' => 'VEVENT', + 'is-not-defined' => false, + 'time-range' => null, + 'comp-filters' => [], + 'prop-filters' => [ + [ + 'name' => 'UID', + 'is-not-defined' => false, + 'time-range' => null, + 'text-match' => [ + 'value' => $ignoreUID, + 'negate-condition' => true, + 'collation' => 'i;octet', + ], + 'param-filters' => [], + ], + ] + ], + ], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => null, + ]); + + foreach ($urls as $url) { + $objects[] = $node->getChild($url)->get(); + } + } + + $inboxProps = $this->server->getProperties( + $result[0][200]['{' . self::NS_CALDAV . '}schedule-inbox-URL']->getHref(), + ['{' . self::NS_CALDAV . '}calendar-availability'] + ); + + $vcalendar = new VCalendar(); + $vcalendar->METHOD = 'REPLY'; + + $generator = new FreeBusyGenerator(); + $generator->setObjects($objects); + $generator->setTimeRange($start, $end); + $generator->setBaseObject($vcalendar); + $generator->setTimeZone($calendarTimeZone); + + if (isset($inboxProps['{' . self::NS_CALDAV . '}calendar-availability'])) { + $generator->setVAvailability( + Reader::read( + $inboxProps['{' . self::NS_CALDAV . '}calendar-availability'] + ) + ); + } + + $result = $generator->getResult(); + if (!isset($result->VFREEBUSY)) { + return false; + } + + /** @var Component $freeBusyComponent */ + $freeBusyComponent = $result->VFREEBUSY; + $freeBusyProperties = $freeBusyComponent->select('FREEBUSY'); + // If there is no Free-busy property at all, the time-range is empty and available + if (count($freeBusyProperties) === 0) { + return true; + } + + // If more than one Free-Busy property was returned, it means that an event + // starts or ends inside this time-range, so it's not availabe and we return false + if (count($freeBusyProperties) > 1) { + return false; + } + + /** @var Property $freeBusyProperty */ + $freeBusyProperty = $freeBusyProperties[0]; + if (!$freeBusyProperty->offsetExists('FBTYPE')) { + // If there is no FBTYPE, it means it's busy + return false; + } + + $fbTypeParameter = $freeBusyProperty->offsetGet('FBTYPE'); + if (!($fbTypeParameter instanceof Parameter)) { + return false; + } + + return (strcasecmp($fbTypeParameter->getValue(), 'FREE') === 0); + } + + /** + * @param string $email + * @return string + */ + private function stripOffMailTo(string $email): string { + if (stripos($email, 'mailto:') === 0) { + return substr($email, 7); + } + + return $email; + } } diff --git a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php index 243077063a..ff6c0837c7 100644 --- a/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php +++ b/apps/dav/tests/unit/CalDAV/Schedule/PluginTest.php @@ -26,6 +26,8 @@ namespace OCA\DAV\Tests\unit\CalDAV\Schedule; use OCA\DAV\CalDAV\Schedule\Plugin; use Sabre\DAV\Server; use Sabre\DAV\Xml\Property\Href; +use Sabre\VObject\Parameter; +use Sabre\VObject\Property\ICalendar\CalAddress; use Test\TestCase; class PluginTest extends TestCase { @@ -82,4 +84,43 @@ class PluginTest extends TestCase { $result = $this->invokePrivate($this->plugin, 'getAddressesForPrincipal', ['MyPrincipal']); $this->assertSame([], $result); } + + public function testStripOffMailTo() { + $this->assertEquals('test@example.com', $this->invokePrivate($this->plugin, 'stripOffMailTo', ['test@example.com'])); + $this->assertEquals('test@example.com', $this->invokePrivate($this->plugin, 'stripOffMailTo', ['mailto:test@example.com'])); + } + + public function testGetAttendeeRSVP() { + $property1 = $this->createMock(CalAddress::class); + $parameter1 = $this->createMock(Parameter::class); + $property1->expects($this->once()) + ->method('offsetGet') + ->with('RSVP') + ->willReturn($parameter1); + $parameter1->expects($this->once()) + ->method('getValue') + ->with() + ->willReturn('TRUE'); + + $property2 = $this->createMock(CalAddress::class); + $parameter2 = $this->createMock(Parameter::class); + $property2->expects($this->once()) + ->method('offsetGet') + ->with('RSVP') + ->willReturn($parameter2); + $parameter2->expects($this->once()) + ->method('getValue') + ->with() + ->willReturn('FALSE'); + + $property3 = $this->createMock(CalAddress::class); + $property3->expects($this->once()) + ->method('offsetGet') + ->with('RSVP') + ->willReturn(null); + + $this->assertTrue($this->invokePrivate($this->plugin, 'getAttendeeRSVP', [$property1])); + $this->assertFalse($this->invokePrivate($this->plugin, 'getAttendeeRSVP', [$property2])); + $this->assertFalse($this->invokePrivate($this->plugin, 'getAttendeeRSVP', [$property3])); + } }