aboutsummaryrefslogtreecommitdiffstats
path: root/lib/private/Calendar/Manager.php
diff options
context:
space:
mode:
Diffstat (limited to 'lib/private/Calendar/Manager.php')
-rw-r--r--lib/private/Calendar/Manager.php568
1 files changed, 527 insertions, 41 deletions
diff --git a/lib/private/Calendar/Manager.php b/lib/private/Calendar/Manager.php
index f8401259eb4..7da1379809d 100644
--- a/lib/private/Calendar/Manager.php
+++ b/lib/private/Calendar/Manager.php
@@ -1,42 +1,66 @@
<?php
+
+declare(strict_types=1);
+
/**
- * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com>
- *
- * @author Christoph Wurst <christoph@winzerhof-wurst.at>
- * @author Georg Ehrke <oc.list@georgehrke.com>
- *
- * @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: 2017 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
-
namespace OC\Calendar;
+use DateTimeInterface;
+use OC\AppFramework\Bootstrap\Coordinator;
+use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin;
+use OCA\DAV\ServerFactory;
+use OCP\AppFramework\Utility\ITimeFactory;
+use OCP\Calendar\Exceptions\CalendarException;
use OCP\Calendar\ICalendar;
+use OCP\Calendar\ICalendarEventBuilder;
+use OCP\Calendar\ICalendarIsShared;
+use OCP\Calendar\ICalendarIsWritable;
+use OCP\Calendar\ICalendarProvider;
+use OCP\Calendar\ICalendarQuery;
+use OCP\Calendar\ICreateFromString;
+use OCP\Calendar\IHandleImipMessage;
+use OCP\Calendar\IManager;
+use OCP\IUser;
+use OCP\IUserManager;
+use OCP\Security\ISecureRandom;
+use Psr\Container\ContainerInterface;
+use Psr\Log\LoggerInterface;
+use Sabre\HTTP\Request;
+use Sabre\HTTP\Response;
+use Sabre\VObject\Component\VCalendar;
+use Sabre\VObject\Component\VEvent;
+use Sabre\VObject\Component\VFreeBusy;
+use Sabre\VObject\ParseException;
+use Sabre\VObject\Property\VCard\DateTime;
+use Sabre\VObject\Reader;
+use Throwable;
+use function array_map;
+use function array_merge;
-class Manager implements \OCP\Calendar\IManager {
-
+class Manager implements IManager {
/**
* @var ICalendar[] holds all registered calendars
*/
- private $calendars = [];
+ private array $calendars = [];
/**
* @var \Closure[] to call to load/register calendar providers
*/
- private $calendarLoaders = [];
+ private array $calendarLoaders = [];
+
+ public function __construct(
+ private Coordinator $coordinator,
+ private ContainerInterface $container,
+ private LoggerInterface $logger,
+ private ITimeFactory $timeFactory,
+ private ISecureRandom $random,
+ private IUserManager $userManager,
+ private ServerFactory $serverFactory,
+ ) {
+ }
/**
* This function is used to search and find objects within the user's calendars.
@@ -45,13 +69,19 @@ class Manager implements \OCP\Calendar\IManager {
* @param string $pattern which should match within the $searchProperties
* @param array $searchProperties defines the properties within the query pattern should match
* @param array $options - optional parameters:
- * ['timerange' => ['start' => new DateTime(...), 'end' => new DateTime(...)]]
+ * ['timerange' => ['start' => new DateTime(...), 'end' => new DateTime(...)]]
* @param integer|null $limit - limit number of search results
* @param integer|null $offset - offset for paging of search results
* @return array an array of events/journals/todos which are arrays of arrays of key-value-pairs
* @since 13.0.0
*/
- public function search($pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null) {
+ public function search(
+ $pattern,
+ array $searchProperties = [],
+ array $options = [],
+ $limit = null,
+ $offset = null,
+ ): array {
$this->loadCalendars();
$result = [];
foreach ($this->calendars as $calendar) {
@@ -71,29 +101,25 @@ class Manager implements \OCP\Calendar\IManager {
* @return bool true if enabled, false if not
* @since 13.0.0
*/
- public function isEnabled() {
+ public function isEnabled(): bool {
return !empty($this->calendars) || !empty($this->calendarLoaders);
}
/**
* Registers a calendar
*
- * @param ICalendar $calendar
- * @return void
* @since 13.0.0
*/
- public function registerCalendar(ICalendar $calendar) {
+ public function registerCalendar(ICalendar $calendar): void {
$this->calendars[$calendar->getKey()] = $calendar;
}
/**
* Unregisters a calendar
*
- * @param ICalendar $calendar
- * @return void
* @since 13.0.0
*/
- public function unregisterCalendar(ICalendar $calendar) {
+ public function unregisterCalendar(ICalendar $calendar): void {
unset($this->calendars[$calendar->getKey()]);
}
@@ -101,19 +127,18 @@ class Manager implements \OCP\Calendar\IManager {
* In order to improve lazy loading a closure can be registered which will be called in case
* calendars are actually requested
*
- * @param \Closure $callable
- * @return void
* @since 13.0.0
*/
- public function register(\Closure $callable) {
+ public function register(\Closure $callable): void {
$this->calendarLoaders[] = $callable;
}
/**
* @return ICalendar[]
+ *
* @since 13.0.0
*/
- public function getCalendars() {
+ public function getCalendars(): array {
$this->loadCalendars();
return array_values($this->calendars);
@@ -121,10 +146,10 @@ class Manager implements \OCP\Calendar\IManager {
/**
* removes all registered calendar instances
- * @return void
+ *
* @since 13.0.0
*/
- public function clear() {
+ public function clear(): void {
$this->calendars = [];
$this->calendarLoaders = [];
}
@@ -132,10 +157,471 @@ class Manager implements \OCP\Calendar\IManager {
/**
* loads all calendars
*/
- private function loadCalendars() {
+ private function loadCalendars(): void {
foreach ($this->calendarLoaders as $callable) {
$callable($this);
}
$this->calendarLoaders = [];
}
+
+ /**
+ * @return ICreateFromString[]
+ */
+ public function getCalendarsForPrincipal(string $principalUri, array $calendarUris = []): array {
+ $context = $this->coordinator->getRegistrationContext();
+ if ($context === null) {
+ return [];
+ }
+
+ return array_merge(
+ ...array_map(function ($registration) use ($principalUri, $calendarUris) {
+ try {
+ /** @var ICalendarProvider $provider */
+ $provider = $this->container->get($registration->getService());
+ } catch (Throwable $e) {
+ $this->logger->error('Could not load calendar provider ' . $registration->getService() . ': ' . $e->getMessage(), [
+ 'exception' => $e,
+ ]);
+ return [];
+ }
+
+ return $provider->getCalendars($principalUri, $calendarUris);
+ }, $context->getCalendarProviders())
+ );
+ }
+
+ public function searchForPrincipal(ICalendarQuery $query): array {
+ /** @var CalendarQuery $query */
+ $calendars = $this->getCalendarsForPrincipal(
+ $query->getPrincipalUri(),
+ $query->getCalendarUris(),
+ );
+
+ $results = [];
+ foreach ($calendars as $calendar) {
+ $r = $calendar->search(
+ $query->getSearchPattern() ?? '',
+ $query->getSearchProperties(),
+ $query->getOptions(),
+ $query->getLimit(),
+ $query->getOffset()
+ );
+
+ foreach ($r as $o) {
+ $o['calendar-key'] = $calendar->getKey();
+ $o['calendar-uri'] = $calendar->getUri();
+ $results[] = $o;
+ }
+ }
+ return $results;
+ }
+
+ public function newQuery(string $principalUri): ICalendarQuery {
+ return new CalendarQuery($principalUri);
+ }
+
+ /**
+ * @since 31.0.0
+ * @throws \OCP\DB\Exception
+ */
+ public function handleIMipRequest(
+ string $principalUri,
+ string $sender,
+ string $recipient,
+ string $calendarData,
+ ): bool {
+
+ $userCalendars = $this->getCalendarsForPrincipal($principalUri);
+ if (empty($userCalendars)) {
+ $this->logger->warning('iMip message could not be processed because user has no calendars');
+ return false;
+ }
+
+ try {
+ /** @var VCalendar $vObject|null */
+ $calendarObject = Reader::read($calendarData);
+ } catch (ParseException $e) {
+ $this->logger->error('iMip message could not be processed because an error occurred while parsing the iMip message', ['exception' => $e]);
+ return false;
+ }
+
+ if (!isset($calendarObject->METHOD) || $calendarObject->METHOD->getValue() !== 'REQUEST') {
+ $this->logger->warning('iMip message contains an incorrect or invalid method');
+ return false;
+ }
+
+ if (!isset($calendarObject->VEVENT)) {
+ $this->logger->warning('iMip message contains no event');
+ return false;
+ }
+
+ /** @var VEvent|null $vEvent */
+ $eventObject = $calendarObject->VEVENT;
+
+ if (!isset($eventObject->UID)) {
+ $this->logger->warning('iMip message event dose not contains a UID');
+ return false;
+ }
+
+ if (!isset($eventObject->ORGANIZER)) {
+ $this->logger->warning('iMip message event dose not contains an organizer');
+ return false;
+ }
+
+ if (!isset($eventObject->ATTENDEE)) {
+ $this->logger->warning('iMip message event dose not contains any attendees');
+ return false;
+ }
+
+ foreach ($eventObject->ATTENDEE as $entry) {
+ $address = trim(str_replace('mailto:', '', $entry->getValue()));
+ if ($address === $recipient) {
+ $attendee = $address;
+ break;
+ }
+ }
+ if (!isset($attendee)) {
+ $this->logger->warning('iMip message event does not contain a attendee that matches the recipient');
+ return false;
+ }
+
+ foreach ($userCalendars as $calendar) {
+
+ if (!$calendar instanceof ICalendarIsWritable && !$calendar instanceof ICalendarIsShared) {
+ continue;
+ }
+
+ if ($calendar->isDeleted() || !$calendar->isWritable() || $calendar->isShared()) {
+ continue;
+ }
+
+ if (!empty($calendar->search($recipient, ['ATTENDEE'], ['uid' => $eventObject->UID->getValue()]))) {
+ try {
+ if ($calendar instanceof IHandleImipMessage) {
+ $calendar->handleIMipMessage('', $calendarData);
+ }
+ return true;
+ } catch (CalendarException $e) {
+ $this->logger->error('An error occurred while processing the iMip message event', ['exception' => $e]);
+ return false;
+ }
+ }
+ }
+
+ $this->logger->warning('iMip message event could not be processed because no corresponding event was found in any calendar');
+ return false;
+ }
+
+ /**
+ * @throws \OCP\DB\Exception
+ */
+ public function handleIMipReply(
+ string $principalUri,
+ string $sender,
+ string $recipient,
+ string $calendarData,
+ ): bool {
+
+ $calendars = $this->getCalendarsForPrincipal($principalUri);
+ if (empty($calendars)) {
+ $this->logger->warning('iMip message could not be processed because user has no calendars');
+ return false;
+ }
+
+ try {
+ /** @var VCalendar $vObject|null */
+ $vObject = Reader::read($calendarData);
+ } catch (ParseException $e) {
+ $this->logger->error('iMip message could not be processed because an error occurred while parsing the iMip message', ['exception' => $e]);
+ return false;
+ }
+
+ if ($vObject === null) {
+ $this->logger->warning('iMip message contains an invalid calendar object');
+ return false;
+ }
+
+ if (!isset($vObject->METHOD) || $vObject->METHOD->getValue() !== 'REPLY') {
+ $this->logger->warning('iMip message contains an incorrect or invalid method');
+ return false;
+ }
+
+ if (!isset($vObject->VEVENT)) {
+ $this->logger->warning('iMip message contains no event');
+ return false;
+ }
+
+ /** @var VEvent|null $vEvent */
+ $vEvent = $vObject->VEVENT;
+
+ if (!isset($vEvent->UID)) {
+ $this->logger->warning('iMip message event dose not contains a UID');
+ return false;
+ }
+
+ if (!isset($vEvent->ORGANIZER)) {
+ $this->logger->warning('iMip message event dose not contains an organizer');
+ return false;
+ }
+
+ if (!isset($vEvent->ATTENDEE)) {
+ $this->logger->warning('iMip message event dose not contains any attendees');
+ return false;
+ }
+
+ // check if mail recipient and organizer are one and the same
+ $organizer = substr($vEvent->{'ORGANIZER'}->getValue(), 7);
+
+ if (strcasecmp($recipient, $organizer) !== 0) {
+ $this->logger->warning('iMip message event could not be processed because recipient and ORGANIZER must be identical');
+ return false;
+ }
+
+ //check if the event is in the future
+ /** @var DateTime $eventTime */
+ $eventTime = $vEvent->{'DTSTART'};
+ if ($eventTime->getDateTime()->getTimeStamp() < $this->timeFactory->getTime()) { // this might cause issues with recurrences
+ $this->logger->warning('iMip message event could not be processed because the event is in the past');
+ return false;
+ }
+
+ $found = null;
+ // if the attendee has been found in at least one calendar event with the UID of the iMIP event
+ // we process it.
+ // Benefit: no attendee lost
+ // Drawback: attendees that have been deleted will still be able to update their partstat
+ foreach ($calendars as $calendar) {
+ // We should not search in writable calendars
+ if ($calendar instanceof IHandleImipMessage) {
+ $o = $calendar->search($sender, ['ATTENDEE'], ['uid' => $vEvent->{'UID'}->getValue()]);
+ if (!empty($o)) {
+ $found = $calendar;
+ $name = $o[0]['uri'];
+ break;
+ }
+ }
+ }
+
+ if (empty($found)) {
+ $this->logger->warning('iMip message event could not be processed because no corresponding event was found in any calendar', [
+ 'principalUri' => $principalUri,
+ 'eventUid' => $vEvent->{'UID'}->getValue(),
+ ]);
+ return false;
+ }
+
+ try {
+ $found->handleIMipMessage($name, $calendarData); // sabre will handle the scheduling behind the scenes
+ } catch (CalendarException $e) {
+ $this->logger->error('An error occurred while processing the iMip message event', ['exception' => $e]);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * @since 25.0.0
+ * @throws \OCP\DB\Exception
+ */
+ public function handleIMipCancel(
+ string $principalUri,
+ string $sender,
+ ?string $replyTo,
+ string $recipient,
+ string $calendarData,
+ ): bool {
+
+ $calendars = $this->getCalendarsForPrincipal($principalUri);
+ if (empty($calendars)) {
+ $this->logger->warning('iMip message could not be processed because user has no calendars');
+ return false;
+ }
+
+ try {
+ /** @var VCalendar $vObject|null */
+ $vObject = Reader::read($calendarData);
+ } catch (ParseException $e) {
+ $this->logger->error('iMip message could not be processed because an error occurred while parsing the iMip message', ['exception' => $e]);
+ return false;
+ }
+
+ if ($vObject === null) {
+ $this->logger->warning('iMip message contains an invalid calendar object');
+ return false;
+ }
+
+ if (!isset($vObject->METHOD) || $vObject->METHOD->getValue() !== 'CANCEL') {
+ $this->logger->warning('iMip message contains an incorrect or invalid method');
+ return false;
+ }
+
+ if (!isset($vObject->VEVENT)) {
+ $this->logger->warning('iMip message contains no event');
+ return false;
+ }
+
+ /** @var VEvent|null $vEvent */
+ $vEvent = $vObject->{'VEVENT'};
+
+ if (!isset($vEvent->UID)) {
+ $this->logger->warning('iMip message event dose not contains a UID');
+ return false;
+ }
+
+ if (!isset($vEvent->ORGANIZER)) {
+ $this->logger->warning('iMip message event dose not contains an organizer');
+ return false;
+ }
+
+ if (!isset($vEvent->ATTENDEE)) {
+ $this->logger->warning('iMip message event dose not contains any attendees');
+ return false;
+ }
+
+ $attendee = substr($vEvent->{'ATTENDEE'}->getValue(), 7);
+ if (strcasecmp($recipient, $attendee) !== 0) {
+ $this->logger->warning('iMip message event could not be processed because recipient must be an ATTENDEE of this event');
+ return false;
+ }
+
+ // Thirdly, we need to compare the email address the CANCEL is coming from (in Mail)
+ // or the Reply- To Address submitted with the CANCEL email
+ // to the email address in the ORGANIZER.
+ // We don't want to accept a CANCEL request from just anyone
+ $organizer = substr($vEvent->{'ORGANIZER'}->getValue(), 7);
+ $isNotOrganizer = ($replyTo !== null) ? (strcasecmp($sender, $organizer) !== 0 && strcasecmp($replyTo, $organizer) !== 0) : (strcasecmp($sender, $organizer) !== 0);
+ if ($isNotOrganizer) {
+ $this->logger->warning('iMip message event could not be processed because sender must be the ORGANIZER of this event');
+ return false;
+ }
+
+ //check if the event is in the future
+ /** @var DateTime $eventTime */
+ $eventTime = $vEvent->{'DTSTART'};
+ if ($eventTime->getDateTime()->getTimeStamp() < $this->timeFactory->getTime()) { // this might cause issues with recurrences
+ $this->logger->warning('iMip message event could not be processed because the event is in the past');
+ return false;
+ }
+
+ $found = null;
+ // if the attendee has been found in at least one calendar event with the UID of the iMIP event
+ // we process it.
+ // Benefit: no attendee lost
+ // Drawback: attendees that have been deleted will still be able to update their partstat
+ foreach ($calendars as $calendar) {
+ // We should not search in writable calendars
+ if ($calendar instanceof IHandleImipMessage) {
+ $o = $calendar->search($recipient, ['ATTENDEE'], ['uid' => $vEvent->{'UID'}->getValue()]);
+ if (!empty($o)) {
+ $found = $calendar;
+ $name = $o[0]['uri'];
+ break;
+ }
+ }
+ }
+
+ if (empty($found)) {
+ $this->logger->warning('iMip message event could not be processed because no corresponding event was found in any calendar', [
+ 'principalUri' => $principalUri,
+ 'eventUid' => $vEvent->{'UID'}->getValue(),
+ ]);
+ return false;
+ }
+
+ try {
+ $found->handleIMipMessage($name, $calendarData); // sabre will handle the scheduling behind the scenes
+ return true;
+ } catch (CalendarException $e) {
+ $this->logger->error('An error occurred while processing the iMip message event', ['exception' => $e]);
+ return false;
+ }
+ }
+
+ public function createEventBuilder(): ICalendarEventBuilder {
+ $uid = $this->random->generate(32, ISecureRandom::CHAR_ALPHANUMERIC);
+ return new CalendarEventBuilder($uid, $this->timeFactory);
+ }
+
+ public function checkAvailability(
+ DateTimeInterface $start,
+ DateTimeInterface $end,
+ IUser $organizer,
+ array $attendees,
+ ): array {
+ $organizerMailto = 'mailto:' . $organizer->getEMailAddress();
+ $request = new VCalendar();
+ $request->METHOD = 'REQUEST';
+ $request->add('VFREEBUSY', [
+ 'DTSTART' => $start,
+ 'DTEND' => $end,
+ 'ORGANIZER' => $organizerMailto,
+ 'ATTENDEE' => $organizerMailto,
+ ]);
+
+ $mailtoLen = strlen('mailto:');
+ foreach ($attendees as $attendee) {
+ if (str_starts_with($attendee, 'mailto:')) {
+ $attendee = substr($attendee, $mailtoLen);
+ }
+
+ $attendeeUsers = $this->userManager->getByEmail($attendee);
+ if ($attendeeUsers === []) {
+ continue;
+ }
+
+ $request->VFREEBUSY->add('ATTENDEE', "mailto:$attendee");
+ }
+
+ $organizerUid = $organizer->getUID();
+ $server = $this->serverFactory->createAttendeeAvailabilityServer();
+ /** @var CustomPrincipalPlugin $plugin */
+ $plugin = $server->getPlugin('auth');
+ $plugin->setCurrentPrincipal("principals/users/$organizerUid");
+
+ $request = new Request(
+ 'POST',
+ "/calendars/$organizerUid/outbox/",
+ [
+ 'Content-Type' => 'text/calendar',
+ 'Depth' => 0,
+ ],
+ $request->serialize(),
+ );
+ $response = new Response();
+ $server->invokeMethod($request, $response, false);
+
+ $xmlService = new \Sabre\Xml\Service();
+ $xmlService->elementMap = [
+ '{urn:ietf:params:xml:ns:caldav}response' => 'Sabre\Xml\Deserializer\keyValue',
+ '{urn:ietf:params:xml:ns:caldav}recipient' => 'Sabre\Xml\Deserializer\keyValue',
+ ];
+ $parsedResponse = $xmlService->parse($response->getBodyAsString());
+
+ $result = [];
+ foreach ($parsedResponse as $freeBusyResponse) {
+ $freeBusyResponse = $freeBusyResponse['value'];
+ if ($freeBusyResponse['{urn:ietf:params:xml:ns:caldav}request-status'] !== '2.0;Success') {
+ continue;
+ }
+
+ $freeBusyResponseData = \Sabre\VObject\Reader::read(
+ $freeBusyResponse['{urn:ietf:params:xml:ns:caldav}calendar-data']
+ );
+
+ $attendee = substr(
+ $freeBusyResponse['{urn:ietf:params:xml:ns:caldav}recipient']['{DAV:}href'],
+ $mailtoLen,
+ );
+
+ $vFreeBusy = $freeBusyResponseData->VFREEBUSY;
+ if (!($vFreeBusy instanceof VFreeBusy)) {
+ continue;
+ }
+
+ // TODO: actually check values of FREEBUSY properties to find a free slot
+ $result[] = new AvailabilityResult($attendee, $vFreeBusy->isFree($start, $end));
+ }
+
+ return $result;
+ }
}