diff options
Diffstat (limited to 'lib/private/Calendar')
-rw-r--r-- | lib/private/Calendar/AvailabilityResult.php | 28 | ||||
-rw-r--r-- | lib/private/Calendar/CalendarEventBuilder.php | 147 | ||||
-rw-r--r-- | lib/private/Calendar/CalendarQuery.php | 21 | ||||
-rw-r--r-- | lib/private/Calendar/Manager.php | 345 | ||||
-rw-r--r-- | lib/private/Calendar/Resource/Manager.php | 29 | ||||
-rw-r--r-- | lib/private/Calendar/ResourcesRoomsUpdater.php | 413 | ||||
-rw-r--r-- | lib/private/Calendar/Room/Manager.php | 29 |
7 files changed, 891 insertions, 121 deletions
diff --git a/lib/private/Calendar/AvailabilityResult.php b/lib/private/Calendar/AvailabilityResult.php new file mode 100644 index 00000000000..8031758f64e --- /dev/null +++ b/lib/private/Calendar/AvailabilityResult.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Calendar; + +use OCP\Calendar\IAvailabilityResult; + +class AvailabilityResult implements IAvailabilityResult { + public function __construct( + private readonly string $attendee, + private readonly bool $available, + ) { + } + + public function getAttendeeEmail(): string { + return $this->attendee; + } + + public function isAvailable(): bool { + return $this->available; + } +} diff --git a/lib/private/Calendar/CalendarEventBuilder.php b/lib/private/Calendar/CalendarEventBuilder.php new file mode 100644 index 00000000000..1aa11c2436d --- /dev/null +++ b/lib/private/Calendar/CalendarEventBuilder.php @@ -0,0 +1,147 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Calendar; + +use DateTimeInterface; +use InvalidArgumentException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\CalendarEventStatus; +use OCP\Calendar\ICalendarEventBuilder; +use OCP\Calendar\ICreateFromString; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; + +class CalendarEventBuilder implements ICalendarEventBuilder { + private ?DateTimeInterface $startDate = null; + private ?DateTimeInterface $endDate = null; + private ?string $summary = null; + private ?string $description = null; + private ?string $location = null; + private ?CalendarEventStatus $status = null; + private ?array $organizer = null; + private array $attendees = []; + + public function __construct( + private readonly string $uid, + private readonly ITimeFactory $timeFactory, + ) { + } + + public function setStartDate(DateTimeInterface $start): ICalendarEventBuilder { + $this->startDate = $start; + return $this; + } + + public function setEndDate(DateTimeInterface $end): ICalendarEventBuilder { + $this->endDate = $end; + return $this; + } + + public function setSummary(string $summary): ICalendarEventBuilder { + $this->summary = $summary; + return $this; + } + + public function setDescription(string $description): ICalendarEventBuilder { + $this->description = $description; + return $this; + } + + public function setLocation(string $location): ICalendarEventBuilder { + $this->location = $location; + return $this; + } + + public function setStatus(CalendarEventStatus $status): static { + $this->status = $status; + return $this; + } + + public function setOrganizer(string $email, ?string $commonName = null): ICalendarEventBuilder { + $this->organizer = [$email, $commonName]; + return $this; + } + + public function addAttendee(string $email, ?string $commonName = null): ICalendarEventBuilder { + $this->attendees[] = [$email, $commonName]; + return $this; + } + + public function toIcs(): string { + if ($this->startDate === null) { + throw new InvalidArgumentException('Event is missing a start date'); + } + + if ($this->endDate === null) { + throw new InvalidArgumentException('Event is missing an end date'); + } + + if ($this->summary === null) { + throw new InvalidArgumentException('Event is missing a summary'); + } + + if ($this->organizer === null && $this->attendees !== []) { + throw new InvalidArgumentException('Event has attendees but is missing an organizer'); + } + + $vcalendar = new VCalendar(); + $props = [ + 'UID' => $this->uid, + 'DTSTAMP' => $this->timeFactory->now(), + 'SUMMARY' => $this->summary, + 'DTSTART' => $this->startDate, + 'DTEND' => $this->endDate, + 'STATUS' => $this->status->value, + ]; + if ($this->description !== null) { + $props['DESCRIPTION'] = $this->description; + } + if ($this->location !== null) { + $props['LOCATION'] = $this->location; + } + /** @var VEvent $vevent */ + $vevent = $vcalendar->add('VEVENT', $props); + if ($this->organizer !== null) { + self::addAttendeeToVEvent($vevent, 'ORGANIZER', $this->organizer); + } + foreach ($this->attendees as $attendee) { + self::addAttendeeToVEvent($vevent, 'ATTENDEE', $attendee); + } + return $vcalendar->serialize(); + } + + public function createInCalendar(ICreateFromString $calendar): string { + $fileName = $this->uid . '.ics'; + $calendar->createFromString($fileName, $this->toIcs()); + return $fileName; + } + + /** + * @param array{0: string, 1: ?string} $tuple A tuple of [$email, $commonName] where $commonName may be null. + */ + private static function addAttendeeToVEvent(VEvent $vevent, string $name, array $tuple): void { + [$email, $cn] = $tuple; + if (!str_starts_with($email, 'mailto:')) { + $email = "mailto:$email"; + } + $params = []; + if ($cn !== null) { + $params['CN'] = $cn; + if ($name === 'ORGANIZER') { + $params['ROLE'] = 'CHAIR'; + $params['PARTSTAT'] = 'ACCEPTED'; + } else { + $params['ROLE'] = 'REQ-PARTICIPANT'; + $params['PARTSTAT'] = 'NEEDS-ACTION'; + } + } + $vevent->add($name, $email, $params); + } +} diff --git a/lib/private/Calendar/CalendarQuery.php b/lib/private/Calendar/CalendarQuery.php index 3d37d9dc467..4eb4a4cd636 100644 --- a/lib/private/Calendar/CalendarQuery.php +++ b/lib/private/Calendar/CalendarQuery.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Anna Larch <anna.larch@gmx.net> - * - * @author Anna Larch <anna.larch@gmx.net> - * - * @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: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Calendar; diff --git a/lib/private/Calendar/Manager.php b/lib/private/Calendar/Manager.php index dc4801c69ce..7da1379809d 100644 --- a/lib/private/Calendar/Manager.php +++ b/lib/private/Calendar/Manager.php @@ -3,43 +3,37 @@ 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> - * @author Anna Larch <anna.larch@gmx.net> - * - * @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; @@ -62,6 +56,9 @@ class Manager implements IManager { private ContainerInterface $container, private LoggerInterface $logger, private ITimeFactory $timeFactory, + private ISecureRandom $random, + private IUserManager $userManager, + private ServerFactory $serverFactory, ) { } @@ -72,7 +69,7 @@ class Manager implements 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 @@ -212,6 +209,7 @@ class Manager implements IManager { foreach ($r as $o) { $o['calendar-key'] = $calendar->getKey(); + $o['calendar-uri'] = $calendar->getUri(); $results[] = $o; } } @@ -223,6 +221,98 @@ class Manager implements IManager { } /** + * @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( @@ -231,23 +321,51 @@ class Manager implements IManager { string $recipient, string $calendarData, ): bool { - /** @var VCalendar $vObject|null */ - $vObject = Reader::read($calendarData); + + $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'}; + $vEvent = $vObject->VEVENT; + + if (!isset($vEvent->UID)) { + $this->logger->warning('iMip message event dose not contains a UID'); + return false; + } - if ($vEvent === null) { + if (!isset($vEvent->ORGANIZER)) { + $this->logger->warning('iMip message event dose not contains an organizer'); return false; } - // First, we check if the correct method is passed to us - if (strcasecmp('REPLY', $vObject->{'METHOD'}->getValue()) !== 0) { - $this->logger->warning('Wrong method provided for processing'); + if (!isset($vEvent->ATTENDEE)) { + $this->logger->warning('iMip message event dose not contains any attendees'); return false; } @@ -255,7 +373,7 @@ class Manager implements IManager { $organizer = substr($vEvent->{'ORGANIZER'}->getValue(), 7); if (strcasecmp($recipient, $organizer) !== 0) { - $this->logger->warning('Recipient and ORGANIZER must be identical'); + $this->logger->warning('iMip message event could not be processed because recipient and ORGANIZER must be identical'); return false; } @@ -263,13 +381,7 @@ class Manager implements IManager { /** @var DateTime $eventTime */ $eventTime = $vEvent->{'DTSTART'}; if ($eventTime->getDateTime()->getTimeStamp() < $this->timeFactory->getTime()) { // this might cause issues with recurrences - $this->logger->warning('Only events in the future are processed'); - return false; - } - - $calendars = $this->getCalendarsForPrincipal($principalUri); - if (empty($calendars)) { - $this->logger->warning('Could not find any calendars for principal ' . $principalUri); + $this->logger->warning('iMip message event could not be processed because the event is in the past'); return false; } @@ -291,14 +403,17 @@ class Manager implements IManager { } if (empty($found)) { - $this->logger->info('Event not found in any calendar for principal ' . $principalUri . 'and UID' . $vEvent->{'UID'}->getValue()); + $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('Could not update calendar for iMIP processing', ['exception' => $e]); + $this->logger->error('An error occurred while processing the iMip message event', ['exception' => $e]); return false; } return true; @@ -315,29 +430,57 @@ class Manager implements IManager { string $recipient, string $calendarData, ): bool { - /** @var VCalendar $vObject|null */ - $vObject = Reader::read($calendarData); + + $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 ($vEvent === null) { + if (!isset($vEvent->UID)) { + $this->logger->warning('iMip message event dose not contains a UID'); return false; } - // First, we check if the correct method is passed to us - if (strcasecmp('CANCEL', $vObject->{'METHOD'}->getValue()) !== 0) { - $this->logger->warning('Wrong method provided for processing'); + 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('Recipient must be an ATTENDEE of this event'); + $this->logger->warning('iMip message event could not be processed because recipient must be an ATTENDEE of this event'); return false; } @@ -348,7 +491,7 @@ class Manager implements IManager { $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('Sender must be the ORGANIZER of this event'); + $this->logger->warning('iMip message event could not be processed because sender must be the ORGANIZER of this event'); return false; } @@ -356,14 +499,7 @@ class Manager implements IManager { /** @var DateTime $eventTime */ $eventTime = $vEvent->{'DTSTART'}; if ($eventTime->getDateTime()->getTimeStamp() < $this->timeFactory->getTime()) { // this might cause issues with recurrences - $this->logger->warning('Only events in the future are processed'); - return false; - } - - // Check if we have a calendar to work with - $calendars = $this->getCalendarsForPrincipal($principalUri); - if (empty($calendars)) { - $this->logger->warning('Could not find any calendars for principal ' . $principalUri); + $this->logger->warning('iMip message event could not be processed because the event is in the past'); return false; } @@ -385,18 +521,107 @@ class Manager implements IManager { } if (empty($found)) { - $this->logger->info('Event not found in any calendar for principal ' . $principalUri . 'and UID' . $vEvent->{'UID'}->getValue()); - // this is a safe operation - // we can ignore events that have been cancelled but were not in the calendar anyway - return true; + $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('Could not update calendar for iMIP processing', ['exception' => $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; + } } diff --git a/lib/private/Calendar/Resource/Manager.php b/lib/private/Calendar/Resource/Manager.php index 88e733f3f24..db04e6a648a 100644 --- a/lib/private/Calendar/Resource/Manager.php +++ b/lib/private/Calendar/Resource/Manager.php @@ -3,31 +3,13 @@ declare(strict_types=1); /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Calendar\Resource; use OC\AppFramework\Bootstrap\Coordinator; +use OC\Calendar\ResourcesRoomsUpdater; use OCP\Calendar\Resource\IBackend; use OCP\Calendar\Resource\IManager; use OCP\IServerContainer; @@ -47,6 +29,7 @@ class Manager implements IManager { public function __construct( private Coordinator $bootstrapCoordinator, private IServerContainer $server, + private ResourcesRoomsUpdater $updater, ) { } @@ -127,4 +110,8 @@ class Manager implements IManager { $this->backends = []; $this->initializedBackends = []; } + + public function update(): void { + $this->updater->updateResources(); + } } diff --git a/lib/private/Calendar/ResourcesRoomsUpdater.php b/lib/private/Calendar/ResourcesRoomsUpdater.php new file mode 100644 index 00000000000..eacdaf0aeb4 --- /dev/null +++ b/lib/private/Calendar/ResourcesRoomsUpdater.php @@ -0,0 +1,413 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Calendar; + +use OCA\DAV\CalDAV\CalDavBackend; +use OCP\Calendar\BackendTemporarilyUnavailableException; +use OCP\Calendar\IMetadataProvider; +use OCP\Calendar\Resource\IBackend as IResourceBackend; +use OCP\Calendar\Resource\IManager as IResourceManager; +use OCP\Calendar\Resource\IResource; +use OCP\Calendar\Room\IManager as IRoomManager; +use OCP\Calendar\Room\IRoom; +use OCP\IDBConnection; +use Psr\Container\ContainerInterface; + +class ResourcesRoomsUpdater { + public function __construct( + private ContainerInterface $container, + private IDBConnection $dbConnection, + private CalDavBackend $calDavBackend, + ) { + } + + /** + * Update resource cache from backends + */ + public function updateResources(): void { + $this->updateFromBackend( + $this->container->get(IResourceManager::class), + 'calendar_resources', + 'calendar_resources_md', + 'resource_id', + 'principals/calendar-resources' + ); + } + + /** + * Update room cache from backends + */ + public function updateRooms(): void { + $this->updateFromBackend( + $this->container->get(IRoomManager::class), + 'calendar_rooms', + 'calendar_rooms_md', + 'room_id', + 'principals/calendar-rooms' + ); + } + + /** + * Update cache from one specific backend manager, either ResourceManager or RoomManager + * + * @param IResourceManager|IRoomManager $backendManager + */ + private function updateFromBackend($backendManager, + string $dbTable, + string $dbTableMetadata, + string $foreignKey, + string $principalPrefix): void { + $backends = $backendManager->getBackends(); + + foreach ($backends as $backend) { + $backendId = $backend->getBackendIdentifier(); + + try { + if ($backend instanceof IResourceBackend) { + $list = $backend->listAllResources(); + } else { + $list = $backend->listAllRooms(); + } + } catch (BackendTemporarilyUnavailableException $ex) { + continue; + } + + $cachedList = $this->getAllCachedByBackend($dbTable, $backendId); + $newIds = array_diff($list, $cachedList); + $deletedIds = array_diff($cachedList, $list); + $editedIds = array_intersect($list, $cachedList); + + foreach ($newIds as $newId) { + try { + if ($backend instanceof IResourceBackend) { + $resource = $backend->getResource($newId); + } else { + $resource = $backend->getRoom($newId); + } + + $metadata = []; + if ($resource instanceof IMetadataProvider) { + $metadata = $this->getAllMetadataOfBackend($resource); + } + } catch (BackendTemporarilyUnavailableException $ex) { + continue; + } + + $id = $this->addToCache($dbTable, $backendId, $resource); + $this->addMetadataToCache($dbTableMetadata, $foreignKey, $id, $metadata); + // we don't create the calendar here, it is created lazily + // when an event is actually scheduled with this resource / room + } + + foreach ($deletedIds as $deletedId) { + $id = $this->getIdForBackendAndResource($dbTable, $backendId, $deletedId); + $this->deleteFromCache($dbTable, $id); + $this->deleteMetadataFromCache($dbTableMetadata, $foreignKey, $id); + + $principalName = implode('-', [$backendId, $deletedId]); + $this->deleteCalendarDataForResource($principalPrefix, $principalName); + } + + foreach ($editedIds as $editedId) { + $id = $this->getIdForBackendAndResource($dbTable, $backendId, $editedId); + + try { + if ($backend instanceof IResourceBackend) { + $resource = $backend->getResource($editedId); + } else { + $resource = $backend->getRoom($editedId); + } + + $metadata = []; + if ($resource instanceof IMetadataProvider) { + $metadata = $this->getAllMetadataOfBackend($resource); + } + } catch (BackendTemporarilyUnavailableException $ex) { + continue; + } + + $this->updateCache($dbTable, $id, $resource); + + if ($resource instanceof IMetadataProvider) { + $cachedMetadata = $this->getAllMetadataOfCache($dbTableMetadata, $foreignKey, $id); + $this->updateMetadataCache($dbTableMetadata, $foreignKey, $id, $metadata, $cachedMetadata); + } + } + } + } + + /** + * add entry to cache that exists remotely but not yet in cache + * + * @param string $table + * @param string $backendId + * @param IResource|IRoom $remote + * + * @return int Insert id + */ + private function addToCache(string $table, + string $backendId, + $remote): int { + $query = $this->dbConnection->getQueryBuilder(); + $query->insert($table) + ->values([ + 'backend_id' => $query->createNamedParameter($backendId), + 'resource_id' => $query->createNamedParameter($remote->getId()), + 'email' => $query->createNamedParameter($remote->getEMail()), + 'displayname' => $query->createNamedParameter($remote->getDisplayName()), + 'group_restrictions' => $query->createNamedParameter( + $this->serializeGroupRestrictions( + $remote->getGroupRestrictions() + )) + ]) + ->executeStatement(); + return $query->getLastInsertId(); + } + + /** + * @param string $table + * @param string $foreignKey + * @param int $foreignId + * @param array $metadata + */ + private function addMetadataToCache(string $table, + string $foreignKey, + int $foreignId, + array $metadata): void { + foreach ($metadata as $key => $value) { + $query = $this->dbConnection->getQueryBuilder(); + $query->insert($table) + ->values([ + $foreignKey => $query->createNamedParameter($foreignId), + 'key' => $query->createNamedParameter($key), + 'value' => $query->createNamedParameter($value), + ]) + ->executeStatement(); + } + } + + /** + * delete entry from cache that does not exist anymore remotely + * + * @param string $table + * @param int $id + */ + private function deleteFromCache(string $table, + int $id): void { + $query = $this->dbConnection->getQueryBuilder(); + $query->delete($table) + ->where($query->expr()->eq('id', $query->createNamedParameter($id))) + ->executeStatement(); + } + + /** + * @param string $table + * @param string $foreignKey + * @param int $id + */ + private function deleteMetadataFromCache(string $table, + string $foreignKey, + int $id): void { + $query = $this->dbConnection->getQueryBuilder(); + $query->delete($table) + ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))) + ->executeStatement(); + } + + /** + * update an existing entry in cache + * + * @param string $table + * @param int $id + * @param IResource|IRoom $remote + */ + private function updateCache(string $table, + int $id, + $remote): void { + $query = $this->dbConnection->getQueryBuilder(); + $query->update($table) + ->set('email', $query->createNamedParameter($remote->getEMail())) + ->set('displayname', $query->createNamedParameter($remote->getDisplayName())) + ->set('group_restrictions', $query->createNamedParameter( + $this->serializeGroupRestrictions( + $remote->getGroupRestrictions() + ))) + ->where($query->expr()->eq('id', $query->createNamedParameter($id))) + ->executeStatement(); + } + + /** + * @param string $dbTable + * @param string $foreignKey + * @param int $id + * @param array $metadata + * @param array $cachedMetadata + */ + private function updateMetadataCache(string $dbTable, + string $foreignKey, + int $id, + array $metadata, + array $cachedMetadata): void { + $newMetadata = array_diff_key($metadata, $cachedMetadata); + $deletedMetadata = array_diff_key($cachedMetadata, $metadata); + + foreach ($newMetadata as $key => $value) { + $query = $this->dbConnection->getQueryBuilder(); + $query->insert($dbTable) + ->values([ + $foreignKey => $query->createNamedParameter($id), + 'key' => $query->createNamedParameter($key), + 'value' => $query->createNamedParameter($value), + ]) + ->executeStatement(); + } + + foreach ($deletedMetadata as $key => $value) { + $query = $this->dbConnection->getQueryBuilder(); + $query->delete($dbTable) + ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))) + ->andWhere($query->expr()->eq('key', $query->createNamedParameter($key))) + ->executeStatement(); + } + + $existingKeys = array_keys(array_intersect_key($metadata, $cachedMetadata)); + foreach ($existingKeys as $existingKey) { + if ($metadata[$existingKey] !== $cachedMetadata[$existingKey]) { + $query = $this->dbConnection->getQueryBuilder(); + $query->update($dbTable) + ->set('value', $query->createNamedParameter($metadata[$existingKey])) + ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))) + ->andWhere($query->expr()->eq('key', $query->createNamedParameter($existingKey))) + ->executeStatement(); + } + } + } + + /** + * serialize array of group restrictions to store them in database + * + * @param array $groups + * + * @return string + */ + private function serializeGroupRestrictions(array $groups): string { + return \json_encode($groups, JSON_THROW_ON_ERROR); + } + + /** + * Gets all metadata of a backend + * + * @param IResource|IRoom $resource + * + * @return array + */ + private function getAllMetadataOfBackend($resource): array { + if (!($resource instanceof IMetadataProvider)) { + return []; + } + + $keys = $resource->getAllAvailableMetadataKeys(); + $metadata = []; + foreach ($keys as $key) { + $metadata[$key] = $resource->getMetadataForKey($key); + } + + return $metadata; + } + + /** + * @param string $table + * @param string $foreignKey + * @param int $id + * + * @return array + */ + private function getAllMetadataOfCache(string $table, + string $foreignKey, + int $id): array { + $query = $this->dbConnection->getQueryBuilder(); + $query->select(['key', 'value']) + ->from($table) + ->where($query->expr()->eq($foreignKey, $query->createNamedParameter($id))); + $result = $query->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + $metadata = []; + foreach ($rows as $row) { + $metadata[$row['key']] = $row['value']; + } + + return $metadata; + } + + /** + * Gets all cached rooms / resources by backend + * + * @param $tableName + * @param $backendId + * + * @return array + */ + private function getAllCachedByBackend(string $tableName, + string $backendId): array { + $query = $this->dbConnection->getQueryBuilder(); + $query->select('resource_id') + ->from($tableName) + ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId))); + $result = $query->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + return array_map(function ($row): string { + return $row['resource_id']; + }, $rows); + } + + /** + * @param $principalPrefix + * @param $principalUri + */ + private function deleteCalendarDataForResource(string $principalPrefix, + string $principalUri): void { + $calendar = $this->calDavBackend->getCalendarByUri( + implode('/', [$principalPrefix, $principalUri]), + CalDavBackend::RESOURCE_BOOKING_CALENDAR_URI); + + if ($calendar !== null) { + $this->calDavBackend->deleteCalendar( + $calendar['id'], + true // Because this wasn't deleted by a user + ); + } + } + + /** + * @param $table + * @param $backendId + * @param $resourceId + * + * @return int + */ + private function getIdForBackendAndResource(string $table, + string $backendId, + string $resourceId): int { + $query = $this->dbConnection->getQueryBuilder(); + $query->select('id') + ->from($table) + ->where($query->expr()->eq('backend_id', $query->createNamedParameter($backendId))) + ->andWhere($query->expr()->eq('resource_id', $query->createNamedParameter($resourceId))); + $result = $query->executeQuery(); + + $id = (int)$result->fetchOne(); + $result->closeCursor(); + return $id; + } +} diff --git a/lib/private/Calendar/Room/Manager.php b/lib/private/Calendar/Room/Manager.php index d7ecdfd8b36..65897010f2a 100644 --- a/lib/private/Calendar/Room/Manager.php +++ b/lib/private/Calendar/Room/Manager.php @@ -3,31 +3,13 @@ declare(strict_types=1); /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OC\Calendar\Room; use OC\AppFramework\Bootstrap\Coordinator; +use OC\Calendar\ResourcesRoomsUpdater; use OCP\Calendar\Room\IBackend; use OCP\Calendar\Room\IManager; use OCP\IServerContainer; @@ -47,6 +29,7 @@ class Manager implements IManager { public function __construct( private Coordinator $bootstrapCoordinator, private IServerContainer $server, + private ResourcesRoomsUpdater $updater, ) { } @@ -134,4 +117,8 @@ class Manager implements IManager { $this->backends = []; $this->initializedBackends = []; } + + public function update(): void { + $this->updater->updateRooms(); + } } |