123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682 |
- <?php
- /**
- * 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 OCP\IConfig;
- use Psr\Log\LoggerInterface;
- use Sabre\CalDAV\ICalendar;
- use Sabre\CalDAV\ICalendarObject;
- use Sabre\CalDAV\Schedule\ISchedulingObject;
- 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;
- use Sabre\VObject\Component;
- use Sabre\VObject\Component\VCalendar;
- 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;
- class Plugin extends \Sabre\CalDAV\Schedule\Plugin {
- /**
- * @var IConfig
- */
- private $config;
- /** @var ITip\Message[] */
- private $schedulingResponses = [];
- /** @var string|null */
- private $pathOfCalendarObjectChange = null;
- public const CALENDAR_USER_TYPE = '{' . self::NS_CALDAV . '}calendar-user-type';
- public const SCHEDULE_DEFAULT_CALENDAR_URL = '{' . Plugin::NS_CALDAV . '}schedule-default-calendar-URL';
- private LoggerInterface $logger;
- /**
- * @param IConfig $config
- */
- public function __construct(IConfig $config, LoggerInterface $logger) {
- $this->config = $config;
- $this->logger = $logger;
- }
- /**
- * Initializes the plugin
- *
- * @param Server $server
- * @return void
- */
- public function initialize(Server $server) {
- parent::initialize($server);
- $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,
- );
- }
- /**
- * 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 = [];
- }
- 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 {
- parent::calendarObjectChange($request, $response, $vCal, $calendarPath, $modified, $isNew);
- } 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 = <<<EOF
- BEGIN:VCALENDAR
- PRODID:-//Nextcloud/Nextcloud CalDAV Server//EN
- METHOD:REPLY
- VERSION:2.0
- BEGIN:VEVENT
- ATTENDEE;PARTSTAT=%s:%s
- ORGANIZER:%s
- UID:%s
- SEQUENCE:%s
- REQUEST-STATUS:2.0;Success
- %sEND:VEVENT
- END:VCALENDAR
- EOF;
- if ($this->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
- */
- foreach ($calendarHome->getChildren() as $node) {
- if ($node instanceof Calendar && !$node->isSubscription() && $node->canWrite() && !$node->isShared() && !$node->isDeleted()) {
- $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();
- 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;
- }
- }
- }
- }
|