diff options
Diffstat (limited to 'apps/dav/lib/CalDAV/Schedule/Plugin.php')
-rw-r--r-- | apps/dav/lib/CalDAV/Schedule/Plugin.php | 301 |
1 files changed, 246 insertions, 55 deletions
diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php index 96bacce4454..a001df8b2a8 100644 --- a/apps/dav/lib/CalDAV/Schedule/Plugin.php +++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php @@ -1,43 +1,30 @@ <?php + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * @copyright Copyright (c) 2016, Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Daniel Kesselberg <mail@danielkesselberg.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Schedule; use DateTimeZone; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CalDAV\CalendarHome; +use OCA\DAV\CalDAV\CalendarObject; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\TipBroker; use OCP\IConfig; +use Psr\Log\LoggerInterface; use Sabre\CalDAV\ICalendar; +use Sabre\CalDAV\ICalendarObject; +use Sabre\CalDAV\Schedule\ISchedulingObject; +use Sabre\DAV\Exception as DavException; 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\IACL; use Sabre\DAVACL\IPrincipal; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; @@ -47,18 +34,14 @@ use Sabre\VObject\Component\VEvent; use Sabre\VObject\DateTimeParser; use Sabre\VObject\FreeBusyGenerator; use Sabre\VObject\ITip; +use Sabre\VObject\ITip\SameOrganizerForAllComponentsException; use Sabre\VObject\Parameter; use Sabre\VObject\Property; use Sabre\VObject\Reader; -use function \Sabre\Uri\split; +use function Sabre\Uri\split; class Plugin extends \Sabre\CalDAV\Schedule\Plugin { - /** - * @var IConfig - */ - private $config; - /** @var ITip\Message[] */ private $schedulingResponses = []; @@ -71,8 +54,11 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { /** * @param IConfig $config */ - public function __construct(IConfig $config) { - $this->config = $config; + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + private DefaultCalendarValidator $defaultCalendarValidator, + ) { } /** @@ -86,6 +72,20 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $server->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(); } /** @@ -139,6 +139,11 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $result = []; } + // iterate through items and html decode values + foreach ($result as $key => $value) { + $result[$key] = urldecode($value); + } + return $result; } @@ -156,20 +161,91 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { $this->pathOfCalendarObjectChange = $request->getPath(); } - parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew); + try { + + // Do not generate iTip and iMip messages if scheduling is disabled for this message + if ($request->getHeader('x-nc-scheduling') === 'false') { + return; + } + + 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 { - parent::scheduleLocalDelivery($iTipMessage); + /** @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) { @@ -178,41 +254,38 @@ class Plugin extends \Sabre\CalDAV\Schedule\Plugin { // 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 + // 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 neither room nor 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 (!isset($iTipMessage->message)) { - return; - } - - $vcalendar = $iTipMessage->message; - if (!isset($vcalendar->VEVENT)) { + if (!$vevent) { + $this->logger->debug('No VEVENT set to process on scheduling message'); return; } - /** @var Component $vevent */ - $vevent = $vcalendar->VEVENT; - // 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; } @@ -299,12 +372,14 @@ EOF; return null; } - if (strpos($principalUrl, 'principals/users') === 0) { + $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 (strpos($principalUrl, 'principals/calendar-resources') === 0 || - strpos($principalUrl, 'principals/calendar-rooms') === 0) { + } elseif ($isResourceOrRoom) { $uri = CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI; $displayName = CalDavBackend::RESOURCE_BOOKING_CALENDAR_NAME; } else { @@ -315,10 +390,65 @@ EOF; /** @var CalendarHome $calendarHome */ $calendarHome = $this->server->tree->getNodeForPath($calendarHomePath); - if (!$calendarHome->childExists($uri)) { - $calendarHome->getCalDAVBackend()->createCalendar($principalUrl, $uri, [ - '{DAV:}displayname' => $displayName, - ]); + $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 in the trash bin, we try to rename its uri + // so that we can create the new one and still restore the previous one + // otherwise we just purge the calendar by removing it before recreating it + $calendar = $this->getCalendar($calendarHome, $uri); + if ($calendar instanceof Calendar) { + $backend = $calendarHome->getCalDAVBackend(); + if ($backend instanceof CalDavBackend) { + // If the CalDAV backend supports moving calendars + $this->moveCalendar($backend, $principalUrl, $uri, $uri . '-back-' . time()); + } else { + // Otherwise just purge the calendar + $calendar->disableTrashbin(); + $calendar->delete(); + } + } + } + $this->createCalendar($calendarHome, $principalUrl, $uri, $displayName); + } + } } $result = $this->server->getPropertiesForPath($calendarHomePath . '/' . $uri, [], 1); @@ -373,7 +503,7 @@ EOF; * @param Property|null $attendee * @return bool */ - private function getAttendeeRSVP(Property $attendee = null):bool { + private function getAttendeeRSVP(?Property $attendee = null):bool { if ($attendee !== null) { $rsvp = $attendee->offsetGet('RSVP'); if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { @@ -448,7 +578,9 @@ EOF; $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; } @@ -533,7 +665,7 @@ EOF; } // 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 + // starts or ends inside this time-range, so it's not available and we return false if (count($freeBusyProperties) > 1) { return false; } @@ -564,4 +696,63 @@ EOF; 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, + ]); + } + + private function moveCalendar(CalDavBackend $calDavBackend, string $principalUri, string $oldUri, string $newUri): void { + $calDavBackend->moveCalendar($oldUri, $principalUri, $principalUri, $newUri); + } + + /** + * 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; + } + } + } } |