on('propFind', [$this, 'propFindDefaultCalendarUrl'], 90); $server->on('afterWriteContent', [$this, 'dispatchSchedulingResponses']); $server->on('afterCreateFile', [$this, 'dispatchSchedulingResponses']); // We allow mutating the default calendar URL through the CustomPropertiesBackend // (oc_properties table) $server->protectedProperties = array_filter( $server->protectedProperties, static fn (string $property) => $property !== self::SCHEDULE_DEFAULT_CALENDAR_URL, ); } /** * 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 * * @param string $path */ public function setPathOfCalendarObjectChange(string $path): void { $this->pathOfCalendarObjectChange = $path; } /** * This method handler is invoked during fetching of properties. * * We use this event to add calendar-auto-schedule-specific properties. * * @param PropFind $propFind * @param INode $node * @return void */ public function propFind(PropFind $propFind, INode $node) { if ($node instanceof IPrincipal) { // overwrite Sabre/Dav's implementation $propFind->handle(self::CALENDAR_USER_TYPE, function () use ($node) { if ($node instanceof IProperties) { $props = $node->getProperties([self::CALENDAR_USER_TYPE]); if (isset($props[self::CALENDAR_USER_TYPE])) { return $props[self::CALENDAR_USER_TYPE]; } } return 'INDIVIDUAL'; }); } parent::propFind($propFind, $node); } /** * Returns a list of addresses that are associated with a principal. * * @param string $principal * @return array */ protected function getAddressesForPrincipal($principal) { $result = parent::getAddressesForPrincipal($principal); if ($result === null) { $result = []; } // iterate through items and html decode values foreach ($result as $key => $value) { $result[$key] = urldecode($value); } 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(); } try { if (!$this->scheduleReply($this->server->httpRequest)) { return; } /** @var Calendar $calendarNode */ $calendarNode = $this->server->tree->getNodeForPath($calendarPath); // extract addresses for owner $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner()); // determine if request is from a sharee if ($calendarNode->isShared()) { // extract addresses for sharee and add to address collection $addresses = array_merge( $addresses, $this->getAddressesForPrincipal($calendarNode->getPrincipalURI()) ); } // determine if we are updating a calendar event if (!$isNew) { // retrieve current calendar event node /** @var CalendarObject $currentNode */ $currentNode = $this->server->tree->getNodeForPath($request->getPath()); // convert calendar event string data to VCalendar object /** @var \Sabre\VObject\Component\VCalendar $currentObject */ $currentObject = Reader::read($currentNode->get()); } else { $currentObject = null; } // process request $this->processICalendarChange($currentObject, $vCal, $addresses, [], $modified); if ($currentObject) { // Destroy circular references so PHP will GC the object. $currentObject->destroy(); } } catch (SameOrganizerForAllComponentsException $e) { $this->handleSameOrganizerException($e, $vCal, $calendarPath); } } /** * @inheritDoc */ public function beforeUnbind($path): void { try { parent::beforeUnbind($path); } catch (SameOrganizerForAllComponentsException $e) { $node = $this->server->tree->getNodeForPath($path); if (!$node instanceof ICalendarObject || $node instanceof ISchedulingObject) { throw $e; } /** @var VCalendar $vCal */ $vCal = Reader::read($node->get()); $this->handleSameOrganizerException($e, $vCal, $path); } } /** * @inheritDoc */ public function scheduleLocalDelivery(ITip\Message $iTipMessage):void { /** @var VEvent|null $vevent */ $vevent = $iTipMessage->message->VEVENT ?? null; // Strip VALARMs from incoming VEVENT if ($vevent && isset($vevent->VALARM)) { $vevent->remove('VALARM'); } parent::scheduleLocalDelivery($iTipMessage); // We only care when the message was successfully delivered locally // Log all possible codes returned from the parent method that mean something went wrong // 3.7, 3.8, 5.0, 5.2 if ($iTipMessage->scheduleStatus !== '1.2;Message delivered locally') { $this->logger->debug('Message not delivered locally with status: ' . $iTipMessage->scheduleStatus); 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 principal // 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) { $this->logger->debug('Calendar user type is room or resource, not processing further'); return; } $attendee = $this->getCurrentAttendee($iTipMessage); if (!$attendee) { $this->logger->debug('No attendee set for scheduling message'); return; } // We only respond when a response was actually requested $rsvp = $this->getAttendeeRSVP($attendee); if (!$rsvp) { $this->logger->debug('No RSVP requested for attendee ' . $attendee->getValue()); return; } if (!$vevent) { $this->logger->debug('No VEVENT set to process on scheduling message'); return; } // We don't support autoresponses for recurrencing events for now if (isset($vevent->RRULE) || isset($vevent->RDATE)) { $this->logger->debug('VEVENT is a recurring event, autoresponding not supported'); 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 * * @param PropFind $propFind * @param INode $node * @return void */ public function propFindDefaultCalendarUrl(PropFind $propFind, INode $node) { if ($node instanceof IPrincipal) { $propFind->handle(self::SCHEDULE_DEFAULT_CALENDAR_URL, function () use ($node) { /** @var \OCA\DAV\CalDAV\Plugin $caldavPlugin */ $caldavPlugin = $this->server->getPlugin('caldav'); $principalUrl = $node->getPrincipalUrl(); $calendarHomePath = $caldavPlugin->getCalendarHomeForPrincipal($principalUrl); if (!$calendarHomePath) { return null; } $isResourceOrRoom = str_starts_with($principalUrl, 'principals/calendar-resources') || str_starts_with($principalUrl, 'principals/calendar-rooms'); if (str_starts_with($principalUrl, 'principals/users')) { [, $userId] = split($principalUrl); $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI); $displayName = CalDavBackend::PERSONAL_CALENDAR_NAME; } elseif ($isResourceOrRoom) { $uri = CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI; $displayName = CalDavBackend::RESOURCE_BOOKING_CALENDAR_NAME; } else { // How did we end up here? // TODO - throw exception or just ignore? return null; } /** @var CalendarHome $calendarHome */ $calendarHome = $this->server->tree->getNodeForPath($calendarHomePath); $currentCalendarDeleted = false; if (!$calendarHome->childExists($uri) || $currentCalendarDeleted = $this->isCalendarDeleted($calendarHome, $uri)) { // If the default calendar doesn't exist if ($isResourceOrRoom) { // Resources or rooms can't be in the trashbin, so we're fine $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName); } else { // And we're not handling scheduling on resource/room booking $userCalendars = []; /** * If the default calendar of the user isn't set and the * fallback doesn't match any of the user's calendar * try to find the first "personal" calendar we can write to * instead of creating a new one. * A appropriate personal calendar to receive invites: * - isn't a calendar subscription * - user can write to it (no virtual/3rd-party calendars) * - calendar isn't a share * - calendar supports VEVENTs */ foreach ($calendarHome->getChildren() as $node) { if (!($node instanceof Calendar)) { continue; } try { $this->defaultCalendarValidator->validateScheduleDefaultCalendar($node); } catch (DavException $e) { continue; } $userCalendars[] = $node; } if (count($userCalendars) > 0) { // Calendar backend returns calendar by calendarorder property $uri = $userCalendars[0]->getName(); } else { // Otherwise if we have really nothing, create a new calendar if ($currentCalendarDeleted) { // If the calendar exists but is deleted, we need to purge it first // This may cause some issues in a non synchronous database setup $calendar = $this->getCalendar($calendarHome, $uri); if ($calendar instanceof Calendar) { $calendar->disableTrashbin(); $calendar->delete(); } } $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName); } } } $result = $this->server->getPropertiesForPath($calendarHomePath . '/' . $uri, [], 1); if (empty($result)) { return null; } return new LocalHref($result[0]['href']); }); } } /** * Returns a list of addresses that are associated with a principal. * * @param string $principal * @return string|null */ 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(); /** @var Calendar $node */ 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 available 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; } private function getCalendar(CalendarHome $calendarHome, string $uri): INode { return $calendarHome->getChild($uri); } private function isCalendarDeleted(CalendarHome $calendarHome, string $uri): bool { $calendar = $this->getCalendar($calendarHome, $uri); return $calendar instanceof Calendar && $calendar->isDeleted(); } private function createCalendar(CalendarHome $calendarHome, string $principalUri, string $uri, string $displayName): void { $calendarHome->getCalDAVBackend()->createCalendar($principalUri, $uri, [ '{DAV:}displayname' => $displayName, ]); } /** * Try to handle the given exception gracefully or throw it if necessary. * * @throws SameOrganizerForAllComponentsException If the exception should not be ignored */ private function handleSameOrganizerException( SameOrganizerForAllComponentsException $e, VCalendar $vCal, string $calendarPath, ): void { // This is very hacky! However, we want to allow saving events with multiple // organizers. Those events are not RFC compliant, but sometimes imported from major // external calendar services (e.g. Google). If the current user is not an organizer of // the event we ignore the exception as no scheduling messages will be sent anyway. // It would be cleaner to patch Sabre to validate organizers *after* checking if // scheduling messages are necessary. Currently, organizers are validated first and // afterwards the broker checks if messages should be scheduled. So the code will throw // even if the organizers are not relevant. This is to ensure compliance with RFCs but // a bit too strict for real world usage. if (!isset($vCal->VEVENT)) { throw $e; } $calendarNode = $this->server->tree->getNodeForPath($calendarPath); if (!($calendarNode instanceof IACL)) { // Should always be an instance of IACL but just to be sure throw $e; } $addresses = $this->getAddressesForPrincipal($calendarNode->getOwner()); foreach ($vCal->VEVENT as $vevent) { if (in_array($vevent->ORGANIZER->getNormalizedValue(), $addresses, true)) { // User is an organizer => throw the exception throw $e; } } } }