diff options
Diffstat (limited to 'apps/dav/lib/CalDAV')
98 files changed, 8058 insertions, 4778 deletions
diff --git a/apps/dav/lib/CalDAV/Activity/Backend.php b/apps/dav/lib/CalDAV/Activity/Backend.php index 84ba50b8c37..f0c49e6e28c 100644 --- a/apps/dav/lib/CalDAV/Activity/Backend.php +++ b/apps/dav/lib/CalDAV/Activity/Backend.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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\Activity; @@ -34,6 +15,7 @@ use OCP\App\IAppManager; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; +use OCP\IUserManager; use OCP\IUserSession; use Sabre\VObject\Reader; @@ -44,23 +26,13 @@ use Sabre\VObject\Reader; */ class Backend { - /** @var IActivityManager */ - protected $activityManager; - - /** @var IGroupManager */ - protected $groupManager; - - /** @var IUserSession */ - protected $userSession; - - /** @var IAppManager */ - protected $appManager; - - public function __construct(IActivityManager $activityManager, IGroupManager $groupManager, IUserSession $userSession, IAppManager $appManager) { - $this->activityManager = $activityManager; - $this->groupManager = $groupManager; - $this->userSession = $userSession; - $this->appManager = $appManager; + public function __construct( + protected IActivityManager $activityManager, + protected IGroupManager $groupManager, + protected IUserSession $userSession, + protected IAppManager $appManager, + protected IUserManager $userManager, + ) { } /** @@ -119,7 +91,7 @@ class Backend { * @param array $calendarData * @param bool $publishStatus */ - public function onCalendarPublication(array $calendarData, $publishStatus) { + public function onCalendarPublication(array $calendarData, bool $publishStatus): void { $this->triggerCalendarActivity($publishStatus ? Calendar::SUBJECT_PUBLISH : Calendar::SUBJECT_UNPUBLISH, $calendarData); } @@ -148,7 +120,7 @@ class Backend { $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('calendar', (int) $calendarData['id']) + ->setObject('calendar', (int)$calendarData['id']) ->setType('calendar') ->setAuthor($currentUser); @@ -165,13 +137,18 @@ class Backend { } foreach ($users as $user) { + if ($action === Calendar::SUBJECT_DELETE && !$this->userManager->userExists($user)) { + // Avoid creating calendar_delete activities for deleted users + continue; + } + $event->setAffectedUser($user) ->setSubject( $user === $currentUser ? $action . '_self' : $action, [ 'actor' => $currentUser, 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -202,7 +179,7 @@ class Backend { $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('calendar', (int) $calendarData['id']) + ->setObject('calendar', (int)$calendarData['id']) ->setType('calendar') ->setAuthor($currentUser); @@ -227,7 +204,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -256,7 +233,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -298,7 +275,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -325,7 +302,7 @@ class Backend { $parameters = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -403,7 +380,7 @@ class Backend { [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $properties['id'], + 'id' => (int)$properties['id'], 'uri' => $properties['uri'], 'name' => $properties['{DAV:}displayname'], ], @@ -438,17 +415,22 @@ class Backend { $classification = $objectData['classification'] ?? CalDavBackend::CLASSIFICATION_PUBLIC; $object = $this->getObjectNameAndType($objectData); + + if (!$object) { + return; + } + $action = $action . '_' . $object['type']; - if ($object['type'] === 'todo' && strpos($action, Event::SUBJECT_OBJECT_UPDATE) === 0 && $object['status'] === 'COMPLETED') { + if ($object['type'] === 'todo' && str_starts_with($action, Event::SUBJECT_OBJECT_UPDATE) && $object['status'] === 'COMPLETED') { $action .= '_completed'; - } elseif ($object['type'] === 'todo' && strpos($action, Event::SUBJECT_OBJECT_UPDATE) === 0 && $object['status'] === 'NEEDS-ACTION') { + } elseif ($object['type'] === 'todo' && str_starts_with($action, Event::SUBJECT_OBJECT_UPDATE) && $object['status'] === 'NEEDS-ACTION') { $action .= '_needs_action'; } $event = $this->activityManager->generateEvent(); $event->setApp('dav') - ->setObject('calendar', (int) $calendarData['id']) + ->setObject('calendar', (int)$calendarData['id']) ->setType($object['type'] === 'event' ? 'calendar_event' : 'calendar_todo') ->setAuthor($currentUser); @@ -465,7 +447,7 @@ class Backend { $params = [ 'actor' => $event->getAuthor(), 'calendar' => [ - 'id' => (int) $calendarData['id'], + 'id' => (int)$calendarData['id'], 'uri' => $calendarData['uri'], 'name' => $calendarData['{DAV:}displayname'], ], @@ -476,7 +458,7 @@ class Backend { ], ]; - if ($object['type'] === 'event' && strpos($action, Event::SUBJECT_OBJECT_DELETE) === false && $this->appManager->isEnabledForUser('calendar')) { + if ($object['type'] === 'event' && !str_contains($action, Event::SUBJECT_OBJECT_DELETE) && $this->appManager->isEnabledForUser('calendar')) { $params['object']['link']['object_uri'] = $objectData['uri']; $params['object']['link']['calendar_uri'] = $calendarData['uri']; $params['object']['link']['owner'] = $owner; @@ -494,8 +476,103 @@ class Backend { } /** + * Creates activities when a calendar object was moved + */ + public function onMovedCalendarObject(array $sourceCalendarData, array $targetCalendarData, array $sourceShares, array $targetShares, array $objectData): void { + if (!isset($targetCalendarData['principaluri'])) { + return; + } + + $sourcePrincipal = explode('/', $sourceCalendarData['principaluri']); + $sourceOwner = array_pop($sourcePrincipal); + + $targetPrincipal = explode('/', $targetCalendarData['principaluri']); + $targetOwner = array_pop($targetPrincipal); + + if ($sourceOwner !== $targetOwner) { + $this->onTouchCalendarObject( + Event::SUBJECT_OBJECT_DELETE, + $sourceCalendarData, + $sourceShares, + $objectData + ); + $this->onTouchCalendarObject( + Event::SUBJECT_OBJECT_ADD, + $targetCalendarData, + $targetShares, + $objectData + ); + return; + } + + $currentUser = $this->userSession->getUser(); + if ($currentUser instanceof IUser) { + $currentUser = $currentUser->getUID(); + } else { + $currentUser = $targetOwner; + } + + $classification = $objectData['classification'] ?? CalDavBackend::CLASSIFICATION_PUBLIC; + $object = $this->getObjectNameAndType($objectData); + + if (!$object) { + return; + } + + $event = $this->activityManager->generateEvent(); + $event->setApp('dav') + ->setObject('calendar', (int)$targetCalendarData['id']) + ->setType($object['type'] === 'event' ? 'calendar_event' : 'calendar_todo') + ->setAuthor($currentUser); + + $users = $this->getUsersForShares(array_intersect($sourceShares, $targetShares)); + $users[] = $targetOwner; + + // Users for share can return the owner itself if the calendar is published + foreach (array_unique($users) as $user) { + if ($classification === CalDavBackend::CLASSIFICATION_PRIVATE && $user !== $targetOwner) { + // Private events are only shown to the owner + continue; + } + + $params = [ + 'actor' => $event->getAuthor(), + 'sourceCalendar' => [ + 'id' => (int)$sourceCalendarData['id'], + 'uri' => $sourceCalendarData['uri'], + 'name' => $sourceCalendarData['{DAV:}displayname'], + ], + 'targetCalendar' => [ + 'id' => (int)$targetCalendarData['id'], + 'uri' => $targetCalendarData['uri'], + 'name' => $targetCalendarData['{DAV:}displayname'], + ], + 'object' => [ + 'id' => $object['id'], + 'name' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $targetOwner ? 'Busy' : $object['name'], + 'classified' => $classification === CalDavBackend::CLASSIFICATION_CONFIDENTIAL && $user !== $targetOwner, + ], + ]; + + if ($object['type'] === 'event' && $this->appManager->isEnabledForUser('calendar')) { + $params['object']['link']['object_uri'] = $objectData['uri']; + $params['object']['link']['calendar_uri'] = $targetCalendarData['uri']; + $params['object']['link']['owner'] = $targetOwner; + } + + $event->setAffectedUser($user) + ->setSubject( + $user === $currentUser ? Event::SUBJECT_OBJECT_MOVE . '_' . $object['type'] . '_self' : Event::SUBJECT_OBJECT_MOVE . '_' . $object['type'], + $params + ); + + $this->activityManager->publish($event); + } + } + + /** * @param array $objectData - * @return string[]|bool + * @return string[]|false */ protected function getObjectNameAndType(array $objectData) { $vObject = Reader::read($objectData['calendardata']); @@ -513,9 +590,9 @@ class Backend { } if ($componentType === 'VEVENT') { - return ['id' => (string) $component->UID, 'name' => (string) $component->SUMMARY, 'type' => 'event']; + return ['id' => (string)$component->UID, 'name' => (string)$component->SUMMARY, 'type' => 'event']; } - return ['id' => (string) $component->UID, 'name' => (string) $component->SUMMARY, 'type' => 'todo', 'status' => (string) $component->STATUS]; + return ['id' => (string)$component->UID, 'name' => (string)$component->SUMMARY, 'type' => 'todo', 'status' => (string)$component->STATUS]; } /** diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php index 06258e3cf74..78579ee84b7 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Calendar.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Filter; @@ -29,15 +12,10 @@ use OCP\IURLGenerator; class Calendar implements IFilter { - /** @var IL10N */ - protected $l; - - /** @var IURLGenerator */ - protected $url; - - public function __construct(IL10N $l, IURLGenerator $url) { - $this->l = $l; - $this->url = $url; + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { } /** @@ -58,8 +36,8 @@ class Calendar implements IFilter { /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php index f727c10befe..b001f90c28d 100644 --- a/apps/dav/lib/CalDAV/Activity/Filter/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Filter/Todo.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.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: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Filter; @@ -28,15 +12,10 @@ use OCP\IURLGenerator; class Todo implements IFilter { - /** @var IL10N */ - protected $l; - - /** @var IURLGenerator */ - protected $url; - - public function __construct(IL10N $l, IURLGenerator $url) { - $this->l = $l; - $this->url = $url; + public function __construct( + protected IL10N $l, + protected IURLGenerator $url, + ) { } /** @@ -52,13 +31,13 @@ class Todo implements IFilter { * @since 11.0.0 */ public function getName() { - return $this->l->t('Todos'); + return $this->l->t('Tasks'); } /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Base.php b/apps/dav/lib/CalDAV/Activity/Provider/Base.php index 7f70980a72b..558abe0ca1a 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Base.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Base.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @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\Activity\Provider; @@ -30,51 +13,26 @@ use OCP\IGroup; use OCP\IGroupManager; use OCP\IL10N; use OCP\IURLGenerator; -use OCP\IUser; use OCP\IUserManager; abstract class Base implements IProvider { - - /** @var IUserManager */ - protected $userManager; - - /** @var string[] */ - protected $userDisplayNames = []; - - /** @var IGroupManager */ - protected $groupManager; - /** @var string[] */ protected $groupDisplayNames = []; - /** @var IURLGenerator */ - protected $url; - /** * @param IUserManager $userManager * @param IGroupManager $groupManager - * @param IURLGenerator $urlGenerator + * @param IURLGenerator $url */ - public function __construct(IUserManager $userManager, IGroupManager $groupManager, IURLGenerator $urlGenerator) { - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->url = $urlGenerator; + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + protected IURLGenerator $url, + ) { } - /** - * @param IEvent $event - * @param string $subject - * @param array $parameters - */ - protected function setSubjects(IEvent $event, $subject, array $parameters) { - $placeholders = $replacements = []; - foreach ($parameters as $placeholder => $parameter) { - $placeholders[] = '{' . $placeholder . '}'; - $replacements[] = $parameter['name']; - } - - $event->setParsedSubject(str_replace($placeholders, $replacements, $subject)) - ->setRichSubject($subject, $parameters); + protected function setSubjects(IEvent $event, string $subject, array $parameters): void { + $event->setRichSubject($subject, $parameters); } /** @@ -83,18 +41,18 @@ abstract class Base implements IProvider { * @return array */ protected function generateCalendarParameter($data, IL10N $l) { - if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI && - $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { + if ($data['uri'] === CalDavBackend::PERSONAL_CALENDAR_URI + && $data['name'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { return [ 'type' => 'calendar', - 'id' => $data['id'], + 'id' => (string)$data['id'], 'name' => $l->t('Personal'), ]; } return [ 'type' => 'calendar', - 'id' => $data['id'], + 'id' => (string)$data['id'], 'name' => $data['name'], ]; } @@ -107,40 +65,20 @@ abstract class Base implements IProvider { protected function generateLegacyCalendarParameter($id, $name) { return [ 'type' => 'calendar', - 'id' => $id, + 'id' => (string)$id, 'name' => $name, ]; } - /** - * @param string $uid - * @return array - */ - protected function generateUserParameter($uid) { - if (!isset($this->userDisplayNames[$uid])) { - $this->userDisplayNames[$uid] = $this->getUserDisplayName($uid); - } - + protected function generateUserParameter(string $uid): array { return [ 'type' => 'user', 'id' => $uid, - 'name' => $this->userDisplayNames[$uid], + 'name' => $this->userManager->getDisplayName($uid) ?? $uid, ]; } /** - * @param string $uid - * @return string - */ - protected function getUserDisplayName($uid) { - $user = $this->userManager->get($uid); - if ($user instanceof IUser) { - return $user->getDisplayName(); - } - return $uid; - } - - /** * @param string $gid * @return array */ diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php index daab7806e46..8c93ddae431 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Calendar.php @@ -1,31 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @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\Activity\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -48,18 +29,9 @@ class Calendar extends Base { public const SUBJECT_UNSHARE_USER = 'calendar_user_unshare'; public const SUBJECT_UNSHARE_GROUP = 'calendar_group_unshare'; - /** @var IFactory */ - protected $languageFactory; - /** @var IL10N */ protected $l; - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - /** * @param IFactory $languageFactory * @param IURLGenerator $url @@ -68,11 +40,15 @@ class Calendar extends Base { * @param IGroupManager $groupManager * @param IEventMerger $eventMerger */ - public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IGroupManager $groupManager, IEventMerger $eventMerger) { + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + ) { parent::__construct($userManager, $groupManager, $url); - $this->languageFactory = $languageFactory; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; } /** @@ -80,12 +56,12 @@ class Calendar extends Base { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('dav', $language); @@ -143,7 +119,7 @@ class Calendar extends Base { } elseif ($event->getSubject() === self::SUBJECT_UNSHARE_GROUP . '_by') { $subject = $this->l->t('{actor} unshared calendar {calendar} from group {group}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event); diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Event.php b/apps/dav/lib/CalDAV/Activity/Provider/Event.php index 96366f54942..87551d7840b 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Event.php @@ -1,32 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @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\Activity\Provider; -use OC_App; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; use OCP\Activity\IEventMerger; use OCP\Activity\IManager; @@ -40,25 +20,14 @@ use OCP\L10N\IFactory; class Event extends Base { public const SUBJECT_OBJECT_ADD = 'object_add'; public const SUBJECT_OBJECT_UPDATE = 'object_update'; + public const SUBJECT_OBJECT_MOVE = 'object_move'; public const SUBJECT_OBJECT_MOVE_TO_TRASH = 'object_move_to_trash'; public const SUBJECT_OBJECT_RESTORE = 'object_restore'; public const SUBJECT_OBJECT_DELETE = 'object_delete'; - /** @var IFactory */ - protected $languageFactory; - /** @var IL10N */ protected $l; - /** @var IManager */ - protected $activityManager; - - /** @var IEventMerger */ - protected $eventMerger; - - /** @var IAppManager */ - protected $appManager; - /** * @param IFactory $languageFactory * @param IURLGenerator $url @@ -68,19 +37,23 @@ class Event extends Base { * @param IEventMerger $eventMerger * @param IAppManager $appManager */ - public function __construct(IFactory $languageFactory, IURLGenerator $url, IManager $activityManager, IUserManager $userManager, IGroupManager $groupManager, IEventMerger $eventMerger, IAppManager $appManager) { + public function __construct( + protected IFactory $languageFactory, + IURLGenerator $url, + protected IManager $activityManager, + IUserManager $userManager, + IGroupManager $groupManager, + protected IEventMerger $eventMerger, + protected IAppManager $appManager, + ) { parent::__construct($userManager, $groupManager, $url); - $this->languageFactory = $languageFactory; - $this->activityManager = $activityManager; - $this->eventMerger = $eventMerger; - $this->appManager = $appManager; } /** * @param array $eventData * @return array */ - protected function generateObjectParameter(array $eventData) { + protected function generateObjectParameter(array $eventData, string $affectedUser): array { if (!isset($eventData['id']) || !isset($eventData['name'])) { throw new \InvalidArgumentException(); } @@ -88,23 +61,27 @@ class Event extends Base { $params = [ 'type' => 'calendar-event', 'id' => $eventData['id'], - 'name' => $eventData['name'], - + 'name' => trim($eventData['name']) !== '' ? $eventData['name'] : $this->l->t('Untitled event'), ]; + if (isset($eventData['link']) && is_array($eventData['link']) && $this->appManager->isEnabledForUser('calendar')) { try { // The calendar app needs to be manually loaded for the routes to be loaded - OC_App::loadApp('calendar'); + $this->appManager->loadApp('calendar'); $linkData = $eventData['link']; - $objectId = base64_encode('/remote.php/dav/calendars/' . $linkData['owner'] . '/' . $linkData['calendar_uri'] . '/' . $linkData['object_uri']); - $link = [ - 'view' => 'dayGridMonth', - 'timeRange' => 'now', - 'mode' => 'sidebar', + $calendarUri = $this->urlencodeLowerHex($linkData['calendar_uri']); + if ($affectedUser === $linkData['owner']) { + $objectId = base64_encode($this->url->getWebroot() . '/remote.php/dav/calendars/' . $linkData['owner'] . '/' . $calendarUri . '/' . $linkData['object_uri']); + } else { + // Can't use the "real" owner and calendar names here because we create a custom + // calendar for incoming shares with the name "<calendar>_shared_by_<sharer>". + // Hack: Fix the link by generating it for the incoming shared calendar instead, + // as seen from the affected user. + $objectId = base64_encode($this->url->getWebroot() . '/remote.php/dav/calendars/' . $affectedUser . '/' . $calendarUri . '_shared_by_' . $linkData['owner'] . '/' . $linkData['object_uri']); + } + $params['link'] = $this->url->linkToRouteAbsolute('calendar.view.indexdirect.edit', [ 'objectId' => $objectId, - 'recurrenceId' => 'next' - ]; - $params['link'] = $this->url->linkToRouteAbsolute('calendar.view.indexview.timerange.edit', $link); + ]); } catch (\Exception $error) { // Do nothing } @@ -117,12 +94,12 @@ class Event extends Base { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar_event') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('dav', $language); @@ -145,6 +122,10 @@ class Event extends Base { $subject = $this->l->t('{actor} updated event {event} in calendar {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_event_self') { $subject = $this->l->t('You updated event {event} in calendar {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_event') { + $subject = $this->l->t('{actor} moved event {event} from calendar {sourceCalendar} to calendar {targetCalendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_event_self') { + $subject = $this->l->t('You moved event {event} from calendar {sourceCalendar} to calendar {targetCalendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event') { $subject = $this->l->t('{actor} deleted event {event} from calendar {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE_TO_TRASH . '_event_self') { @@ -154,7 +135,7 @@ class Event extends Base { } elseif ($event->getSubject() === self::SUBJECT_OBJECT_RESTORE . '_event_self') { $subject = $this->l->t('You restored event {event} of calendar {calendar}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event); @@ -184,7 +165,7 @@ class Event extends Base { return [ 'actor' => $this->generateUserParameter($parameters['actor']), 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'event' => $this->generateClassifiedObjectParameter($parameters['object']), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_event_self': case self::SUBJECT_OBJECT_DELETE . '_event_self': @@ -193,7 +174,25 @@ class Event extends Base { case self::SUBJECT_OBJECT_RESTORE . '_event_self': return [ 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'event' => $this->generateClassifiedObjectParameter($parameters['object']), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + } + } + + if (isset($parameters['sourceCalendar']) && isset($parameters['targetCalendar'])) { + switch ($subject) { + case self::SUBJECT_OBJECT_MOVE . '_event': + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + case self::SUBJECT_OBJECT_MOVE . '_event_self': + return [ + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'event' => $this->generateClassifiedObjectParameter($parameters['object'], $event->getAffectedUser()), ]; } } @@ -210,25 +209,37 @@ class Event extends Base { return [ 'actor' => $this->generateUserParameter($parameters[0]), 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'event' => $this->generateObjectParameter($parameters[2]), + 'event' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_event_self': case self::SUBJECT_OBJECT_DELETE . '_event_self': case self::SUBJECT_OBJECT_UPDATE . '_event_self': return [ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'event' => $this->generateObjectParameter($parameters[2]), + 'event' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; } throw new \InvalidArgumentException(); } - private function generateClassifiedObjectParameter(array $eventData) { - $parameter = $this->generateObjectParameter($eventData); + private function generateClassifiedObjectParameter(array $eventData, string $affectedUser): array { + $parameter = $this->generateObjectParameter($eventData, $affectedUser); if (!empty($eventData['classified'])) { $parameter['name'] = $this->l->t('Busy'); } return $parameter; } + + /** + * Return urlencoded string but with lower cased hex sequences. + * The remaining casing will be untouched. + */ + private function urlencodeLowerHex(string $raw): string { + return preg_replace_callback( + '/%[0-9A-F]{2}/', + static fn (array $matches) => strtolower($matches[0]), + urlencode($raw), + ); + } } diff --git a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php index a3ab81e38ae..fc0625ec970 100644 --- a/apps/dav/lib/CalDAV/Activity/Provider/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Provider/Todo.php @@ -1,29 +1,12 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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\Activity\Provider; +use OCP\Activity\Exceptions\UnknownActivityException; use OCP\Activity\IEvent; class Todo extends Event { @@ -33,12 +16,12 @@ class Todo extends Event { * @param IEvent $event * @param IEvent|null $previousEvent * @return IEvent - * @throws \InvalidArgumentException + * @throws UnknownActivityException * @since 11.0.0 */ - public function parse($language, IEvent $event, IEvent $previousEvent = null) { + public function parse($language, IEvent $event, ?IEvent $previousEvent = null) { if ($event->getApp() !== 'dav' || $event->getType() !== 'calendar_todo') { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $this->l = $this->languageFactory->get('dav', $language); @@ -50,27 +33,31 @@ class Todo extends Event { } if ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo') { - $subject = $this->l->t('{actor} created todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} created to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_ADD . '_todo_self') { - $subject = $this->l->t('You created todo {todo} in list {calendar}'); + $subject = $this->l->t('You created to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo') { - $subject = $this->l->t('{actor} deleted todo {todo} from list {calendar}'); + $subject = $this->l->t('{actor} deleted to-do {todo} from list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_DELETE . '_todo_self') { - $subject = $this->l->t('You deleted todo {todo} from list {calendar}'); + $subject = $this->l->t('You deleted to-do {todo} from list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo') { - $subject = $this->l->t('{actor} updated todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} updated to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_self') { - $subject = $this->l->t('You updated todo {todo} in list {calendar}'); + $subject = $this->l->t('You updated to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed') { - $subject = $this->l->t('{actor} solved todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} solved to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_completed_self') { - $subject = $this->l->t('You solved todo {todo} in list {calendar}'); + $subject = $this->l->t('You solved to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action') { - $subject = $this->l->t('{actor} reopened todo {todo} in list {calendar}'); + $subject = $this->l->t('{actor} reopened to-do {todo} in list {calendar}'); } elseif ($event->getSubject() === self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self') { - $subject = $this->l->t('You reopened todo {todo} in list {calendar}'); + $subject = $this->l->t('You reopened to-do {todo} in list {calendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_todo') { + $subject = $this->l->t('{actor} moved to-do {todo} from list {sourceCalendar} to list {targetCalendar}'); + } elseif ($event->getSubject() === self::SUBJECT_OBJECT_MOVE . '_todo_self') { + $subject = $this->l->t('You moved to-do {todo} from list {sourceCalendar} to list {targetCalendar}'); } else { - throw new \InvalidArgumentException(); + throw new UnknownActivityException(); } $parsedParameters = $this->getParameters($event); @@ -100,7 +87,7 @@ class Todo extends Event { return [ 'actor' => $this->generateUserParameter($parameters['actor']), 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'todo' => $this->generateObjectParameter($parameters['object']), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_todo_self': case self::SUBJECT_OBJECT_DELETE . '_todo_self': @@ -109,7 +96,25 @@ class Todo extends Event { case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self': return [ 'calendar' => $this->generateCalendarParameter($parameters['calendar'], $this->l), - 'todo' => $this->generateObjectParameter($parameters['object']), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + } + } + + if (isset($parameters['sourceCalendar']) && isset($parameters['targetCalendar'])) { + switch ($subject) { + case self::SUBJECT_OBJECT_MOVE . '_todo': + return [ + 'actor' => $this->generateUserParameter($parameters['actor']), + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), + ]; + case self::SUBJECT_OBJECT_MOVE . '_todo_self': + return [ + 'sourceCalendar' => $this->generateCalendarParameter($parameters['sourceCalendar'], $this->l), + 'targetCalendar' => $this->generateCalendarParameter($parameters['targetCalendar'], $this->l), + 'todo' => $this->generateObjectParameter($parameters['object'], $event->getAffectedUser()), ]; } } @@ -128,7 +133,7 @@ class Todo extends Event { return [ 'actor' => $this->generateUserParameter($parameters[0]), 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'todo' => $this->generateObjectParameter($parameters[2]), + 'todo' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; case self::SUBJECT_OBJECT_ADD . '_todo_self': case self::SUBJECT_OBJECT_DELETE . '_todo_self': @@ -137,7 +142,7 @@ class Todo extends Event { case self::SUBJECT_OBJECT_UPDATE . '_todo_needs_action_self': return [ 'calendar' => $this->generateLegacyCalendarParameter($event->getObjectId(), $parameters[1]), - 'todo' => $this->generateObjectParameter($parameters[2]), + 'todo' => $this->generateObjectParameter($parameters[2], $event->getAffectedUser()), ]; } diff --git a/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php b/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php index 20325a253f4..7ab7f16dbbb 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/CalDAVSetting.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020 Robin Appelman <robin@icewind.nl> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Activity\Setting; @@ -30,14 +12,12 @@ use OCP\Activity\ActivitySettings; use OCP\IL10N; abstract class CalDAVSetting extends ActivitySettings { - /** @var IL10N */ - protected $l; - /** * @param IL10N $l */ - public function __construct(IL10N $l) { - $this->l = $l; + public function __construct( + protected IL10N $l, + ) { } public function getGroupIdentifier() { diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php index 4a226fca439..0ad86a919bc 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Calendar.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @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\Activity\Setting; @@ -42,8 +25,8 @@ class Calendar extends CalDAVSetting { /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Event.php b/apps/dav/lib/CalDAV/Activity/Setting/Event.php index 0239296a403..ea9476d6f08 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Event.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Event.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @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\Activity\Setting; @@ -42,8 +25,8 @@ class Event extends CalDAVSetting { /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php index 7d27b30c4af..ed8377b0ffa 100644 --- a/apps/dav/lib/CalDAV/Activity/Setting/Todo.php +++ b/apps/dav/lib/CalDAV/Activity/Setting/Todo.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.nl> - * - * @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\Activity\Setting; @@ -38,13 +21,13 @@ class Todo extends CalDAVSetting { * @since 11.0.0 */ public function getName() { - return $this->l->t('A calendar <strong>todo</strong> was modified'); + return $this->l->t('A calendar <strong>to-do</strong> was modified'); } /** * @return int whether the filter should be rather on the top or bottom of - * the admin section. The filters are arranged in ascending order of the - * priority values. It is required to return a value between 0 and 100. + * the admin section. The filters are arranged in ascending order of the + * priority values. It is required to return a value between 0 and 100. * @since 11.0.0 */ public function getPriority() { diff --git a/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php b/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php new file mode 100644 index 00000000000..87d26324c32 --- /dev/null +++ b/apps/dav/lib/CalDAV/AppCalendar/AppCalendar.php @@ -0,0 +1,194 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\AppCalendar; + +use OCA\DAV\CalDAV\Integration\ExternalCalendar; +use OCA\DAV\CalDAV\Plugin; +use OCP\Calendar\ICalendar; +use OCP\Calendar\ICreateFromString; +use OCP\Constants; +use Sabre\CalDAV\CalendarQueryValidator; +use Sabre\CalDAV\ICalendarObject; +use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\PropPatch; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Reader; + +class AppCalendar extends ExternalCalendar { + protected ICalendar $calendar; + + public function __construct( + string $appId, + ICalendar $calendar, + protected string $principal, + ) { + parent::__construct($appId, $calendar->getUri()); + $this->calendar = $calendar; + } + + /** + * Return permissions supported by the backend calendar + * @return int Permissions based on \OCP\Constants + */ + public function getPermissions(): int { + // Make sure to only promote write support if the backend implement the correct interface + if ($this->calendar instanceof ICreateFromString) { + return $this->calendar->getPermissions(); + } + return Constants::PERMISSION_READ; + } + + public function getOwner(): ?string { + return $this->principal; + } + + public function getGroup(): ?string { + return null; + } + + public function getACL(): array { + $acl = [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ] + ]; + if ($this->getPermissions() & Constants::PERMISSION_CREATE) { + $acl[] = [ + 'privilege' => '{DAV:}write', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + } + return $acl; + } + + public function setACL(array $acl): void { + throw new Forbidden('Setting ACL is not supported on this node'); + } + + public function getSupportedPrivilegeSet(): ?array { + // Use the default one + return null; + } + + public function getLastModified(): ?int { + // unknown + return null; + } + + public function delete(): void { + // No method for deleting a calendar in OCP\Calendar\ICalendar + throw new Forbidden('Deleting an entry is not implemented'); + } + + public function createFile($name, $data = null) { + if ($this->calendar instanceof ICreateFromString) { + if (is_resource($data)) { + $data = stream_get_contents($data) ?: null; + } + $this->calendar->createFromString($name, is_null($data) ? '' : $data); + return null; + } else { + throw new Forbidden('Creating a new entry is not allowed'); + } + } + + public function getProperties($properties) { + return [ + '{DAV:}displayname' => $this->calendar->getDisplayName() ?: $this->calendar->getKey(), + '{http://apple.com/ns/ical/}calendar-color' => $this->calendar->getDisplayColor() ?: '#0082c9', + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VEVENT', 'VJOURNAL', 'VTODO']), + ]; + } + + public function calendarQuery(array $filters) { + $result = []; + $objects = $this->getChildren(); + + foreach ($objects as $object) { + if ($this->validateFilterForObject($object, $filters)) { + $result[] = $object->getName(); + } + } + + return $result; + } + + protected function validateFilterForObject(ICalendarObject $object, array $filters): bool { + /** @var \Sabre\VObject\Component\VCalendar */ + $vObject = Reader::read($object->get()); + + $validator = new CalendarQueryValidator(); + $result = $validator->validate($vObject, $filters); + + // Destroy circular references so PHP will GC the object. + $vObject->destroy(); + + return $result; + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $error) { + return false; + } + } + + public function getChild($name) { + // Try to get calendar by filename + $children = $this->calendar->search($name, ['X-FILENAME']); + if (count($children) === 0) { + // If nothing found try to get by UID from filename + $pos = strrpos($name, '.ics'); + $children = $this->calendar->search(substr($name, 0, $pos ?: null), ['UID']); + } + + if (count($children) > 0) { + return new CalendarObject($this, $this->calendar, new VCalendar($children)); + } + + throw new NotFound('Node not found'); + } + + /** + * @return ICalendarObject[] + */ + public function getChildren(): array { + $objects = $this->calendar->search(''); + // We need to group by UID (actually by filename but we do not have that information) + $result = []; + foreach ($objects as $object) { + $uid = (string)$object['UID'] ?: uniqid(); + if (!isset($result[$uid])) { + $result[$uid] = []; + } + $result[$uid][] = $object; + } + + return array_map(function (array $children) { + return new CalendarObject($this, $this->calendar, new VCalendar($children)); + }, $result); + } + + public function propPatch(PropPatch $propPatch): void { + // no setDisplayColor or setDisplayName in \OCP\Calendar\ICalendar + } +} diff --git a/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php new file mode 100644 index 00000000000..72f2ed2c163 --- /dev/null +++ b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\AppCalendar; + +use OCA\DAV\CalDAV\CachedSubscriptionImpl; +use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Integration\ExternalCalendar; +use OCA\DAV\CalDAV\Integration\ICalendarProvider; +use OCP\Calendar\IManager; +use Psr\Log\LoggerInterface; + +/* Plugin for wrapping application generated calendars registered in nextcloud core (OCP\Calendar\ICalendarProvider) */ +class AppCalendarPlugin implements ICalendarProvider { + public function __construct( + protected IManager $manager, + protected LoggerInterface $logger, + ) { + } + + public function getAppID(): string { + return 'dav-wrapper'; + } + + public function fetchAllForCalendarHome(string $principalUri): array { + return array_map(function ($calendar) use (&$principalUri) { + return new AppCalendar($this->getAppID(), $calendar, $principalUri); + }, $this->getWrappedCalendars($principalUri)); + } + + public function hasCalendarInCalendarHome(string $principalUri, string $calendarUri): bool { + return count($this->getWrappedCalendars($principalUri, [ $calendarUri ])) > 0; + } + + public function getCalendarInCalendarHome(string $principalUri, string $calendarUri): ?ExternalCalendar { + $calendars = $this->getWrappedCalendars($principalUri, [ $calendarUri ]); + if (count($calendars) > 0) { + return new AppCalendar($this->getAppID(), $calendars[0], $principalUri); + } + + return null; + } + + protected function getWrappedCalendars(string $principalUri, array $calendarUris = []): array { + return array_values( + array_filter($this->manager->getCalendarsForPrincipal($principalUri, $calendarUris), function ($c) { + // We must not provide a wrapper for DAV calendars + return ! (($c instanceof CalendarImpl) || ($c instanceof CachedSubscriptionImpl)); + }) + ); + } +} diff --git a/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php b/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php new file mode 100644 index 00000000000..3c62a26df54 --- /dev/null +++ b/apps/dav/lib/CalDAV/AppCalendar/CalendarObject.php @@ -0,0 +1,134 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\AppCalendar; + +use OCP\Calendar\ICalendar; +use OCP\Calendar\ICreateFromString; +use OCP\Constants; +use Sabre\CalDAV\ICalendarObject; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAVACL\IACL; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Property\ICalendar\DateTime; + +class CalendarObject implements ICalendarObject, IACL { + public function __construct( + private AppCalendar $calendar, + private ICalendar|ICreateFromString $backend, + private VCalendar $vobject, + ) { + } + + public function getOwner() { + return $this->calendar->getOwner(); + } + + public function getGroup() { + return $this->calendar->getGroup(); + } + + public function getACL(): array { + $acl = [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ] + ]; + if ($this->calendar->getPermissions() & Constants::PERMISSION_UPDATE) { + $acl[] = [ + 'privilege' => '{DAV:}write-content', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + } + return $acl; + } + + public function setACL(array $acl): void { + throw new Forbidden('Setting ACL is not supported on this node'); + } + + public function getSupportedPrivilegeSet(): ?array { + return null; + } + + public function put($data): void { + if ($this->backend instanceof ICreateFromString && $this->calendar->getPermissions() & Constants::PERMISSION_UPDATE) { + if (is_resource($data)) { + $data = stream_get_contents($data) ?: ''; + } + $this->backend->createFromString($this->getName(), $data); + } else { + throw new Forbidden('This calendar-object is read-only'); + } + } + + public function get(): string { + return $this->vobject->serialize(); + } + + public function getContentType(): string { + return 'text/calendar; charset=utf-8'; + } + + public function getETag(): ?string { + return null; + } + + public function getSize() { + return mb_strlen($this->vobject->serialize()); + } + + public function delete(): void { + if ($this->backend instanceof ICreateFromString && $this->calendar->getPermissions() & Constants::PERMISSION_DELETE) { + /** @var \Sabre\VObject\Component[] */ + $components = $this->vobject->getBaseComponents(); + foreach ($components as $key => $component) { + $components[$key]->STATUS = 'CANCELLED'; + $components[$key]->SEQUENCE = isset($component->SEQUENCE) ? ((int)$component->SEQUENCE->getValue()) + 1 : 1; + if ($component->name === 'VEVENT') { + $components[$key]->METHOD = 'CANCEL'; + } + } + $this->backend->createFromString($this->getName(), (new VCalendar($components))->serialize()); + } else { + throw new Forbidden('This calendar-object is read-only'); + } + } + + public function getName(): string { + // Every object is required to have an UID + $base = $this->vobject->getBaseComponent(); + // This should never happen except the app provides invalid calendars (VEvent, VTodo... all require UID to be present) + if ($base === null) { + throw new NotFound('Invalid node'); + } + if (isset($base->{'X-FILENAME'})) { + return (string)$base->{'X-FILENAME'}; + } + return (string)$base->UID . '.ics'; + } + + public function setName($name): void { + throw new Forbidden('This calendar-object is read-only'); + } + + public function getLastModified(): ?int { + $base = $this->vobject->getBaseComponent(); + if ($base !== null && $this->vobject->getBaseComponent()->{'LAST-MODIFIED'}) { + /** @var DateTime */ + $lastModified = $this->vobject->getBaseComponent()->{'LAST-MODIFIED'}; + return $lastModified->getDateTime()->getTimestamp(); + } + return null; + } +} diff --git a/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php b/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php index 89e50c7da6b..71b9acb939b 100644 --- a/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php +++ b/apps/dav/lib/CalDAV/Auth/CustomPrincipalPlugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * CalDAV App - * - * @copyright 2021 Anna Larch <anna.larch@gmx.net> - * - * @author Anna Larch <anna.larch@gmx.net> - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library 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 library. 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 OCA\DAV\CalDAV\Auth; diff --git a/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php index 96669558818..ed89638451e 100644 --- a/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php +++ b/apps/dav/lib/CalDAV/Auth/PublicPrincipalPlugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * CalDAV App - * - * @copyright 2021 Anna Larch <anna.larch@gmx.net> - * - * @author Anna Larch <anna.larch@gmx.net> - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE - * License as published by the Free Software Foundation; either - * version 3 of the License, or any later version. - * - * This library 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 library. 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 OCA\DAV\CalDAV\Auth; diff --git a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php index b736d9432bd..681709cdb6f 100644 --- a/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php +++ b/apps/dav/lib/CalDAV/BirthdayCalendar/EnablePlugin.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\BirthdayCalendar; use OCA\DAV\CalDAV\BirthdayService; use OCA\DAV\CalDAV\CalendarHome; +use OCP\AppFramework\Http; use OCP\IConfig; +use OCP\IUser; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; @@ -42,16 +26,6 @@ class EnablePlugin extends ServerPlugin { public const NS_Nextcloud = 'http://nextcloud.com/ns'; /** - * @var IConfig - */ - protected $config; - - /** - * @var BirthdayService - */ - protected $birthdayService; - - /** * @var Server */ protected $server; @@ -61,10 +35,13 @@ class EnablePlugin extends ServerPlugin { * * @param IConfig $config * @param BirthdayService $birthdayService + * @param IUser $user */ - public function __construct(IConfig $config, BirthdayService $birthdayService) { - $this->config = $config; - $this->birthdayService = $birthdayService; + public function __construct( + protected IConfig $config, + protected BirthdayService $birthdayService, + private IUser $user, + ) { } /** @@ -117,23 +94,26 @@ class EnablePlugin extends ServerPlugin { */ public function httpPost(RequestInterface $request, ResponseInterface $response) { $node = $this->server->tree->getNodeForPath($this->server->getRequestUri()); - if (!($node instanceof CalendarHome)) { + if (!$node instanceof CalendarHome) { return; } $requestBody = $request->getBodyAsString(); $this->server->xml->parse($requestBody, $request->getUrl(), $documentType); - if ($documentType !== '{'.self::NS_Nextcloud.'}enable-birthday-calendar') { + if ($documentType !== '{' . self::NS_Nextcloud . '}enable-birthday-calendar') { return; } - $principalUri = $node->getOwner(); - $userId = substr($principalUri, 17); + $owner = substr($node->getOwner(), 17); + if ($owner !== $this->user->getUID()) { + $this->server->httpResponse->setStatus(Http::STATUS_FORBIDDEN); + return false; + } - $this->config->setUserValue($userId, 'dav', 'generateBirthdayCalendar', 'yes'); - $this->birthdayService->syncUser($userId); + $this->config->setUserValue($this->user->getUID(), 'dav', 'generateBirthdayCalendar', 'yes'); + $this->birthdayService->syncUser($this->user->getUID()); - $this->server->httpResponse->setStatus(204); + $this->server->httpResponse->setStatus(Http::STATUS_NO_CONTENT); return false; } diff --git a/apps/dav/lib/CalDAV/BirthdayService.php b/apps/dav/lib/CalDAV/BirthdayService.php index bdcf0796283..680b228766f 100644 --- a/apps/dav/lib/CalDAV/BirthdayService.php +++ b/apps/dav/lib/CalDAV/BirthdayService.php @@ -3,32 +3,9 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Achim Königs <garfonso@tratschtante.de> - * @author Christian Weiske <cweiske@cweiske.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Sven Strickroth <email@cs-ware.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Valdnet <47037905+Valdnet@users.noreply.github.com> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -53,63 +30,33 @@ use Sabre\VObject\Reader; */ class BirthdayService { public const BIRTHDAY_CALENDAR_URI = 'contact_birthdays'; - - /** @var GroupPrincipalBackend */ - private $principalBackend; - - /** @var CalDavBackend */ - private $calDavBackEnd; - - /** @var CardDavBackend */ - private $cardDavBackEnd; - - /** @var IConfig */ - private $config; - - /** @var IDBConnection */ - private $dbConnection; - - /** @var IL10N */ - private $l10n; + public const EXCLUDE_FROM_BIRTHDAY_CALENDAR_PROPERTY_NAME = 'X-NC-EXCLUDE-FROM-BIRTHDAY-CALENDAR'; /** * BirthdayService constructor. - * - * @param CalDavBackend $calDavBackEnd - * @param CardDavBackend $cardDavBackEnd - * @param GroupPrincipalBackend $principalBackend - * @param IConfig $config - * @param IDBConnection $dbConnection - * @param IL10N $l10n */ - public function __construct(CalDavBackend $calDavBackEnd, - CardDavBackend $cardDavBackEnd, - GroupPrincipalBackend $principalBackend, - IConfig $config, - IDBConnection $dbConnection, - IL10N $l10n) { - $this->calDavBackEnd = $calDavBackEnd; - $this->cardDavBackEnd = $cardDavBackEnd; - $this->principalBackend = $principalBackend; - $this->config = $config; - $this->dbConnection = $dbConnection; - $this->l10n = $l10n; + public function __construct( + private CalDavBackend $calDavBackEnd, + private CardDavBackend $cardDavBackEnd, + private GroupPrincipalBackend $principalBackend, + private IConfig $config, + private IDBConnection $dbConnection, + private IL10N $l10n, + ) { } - /** - * @param int $addressBookId - * @param string $cardUri - * @param string $cardData - */ public function onCardChanged(int $addressBookId, - string $cardUri, - string $cardData) { + string $cardUri, + string $cardData): void { if (!$this->isGloballyEnabled()) { return; } $targetPrincipals = $this->getAllAffectedPrincipals($addressBookId); $book = $this->cardDavBackEnd->getAddressBookById($addressBookId); + if ($book === null) { + return; + } $targetPrincipals[] = $book['principaluri']; $datesToSync = [ ['postfix' => '', 'field' => 'BDAY'], @@ -122,19 +69,20 @@ class BirthdayService { continue; } + $reminderOffset = $this->getReminderOffsetForUser($principalUri); + $calendar = $this->ensureCalendarExists($principalUri); + if ($calendar === null) { + return; + } foreach ($datesToSync as $type) { - $this->updateCalendar($cardUri, $cardData, $book, (int) $calendar['id'], $type); + $this->updateCalendar($cardUri, $cardData, $book, (int)$calendar['id'], $type, $reminderOffset); } } } - /** - * @param int $addressBookId - * @param string $cardUri - */ public function onCardDeleted(int $addressBookId, - string $cardUri) { + string $cardUri): void { if (!$this->isGloballyEnabled()) { return; } @@ -149,18 +97,16 @@ class BirthdayService { $calendar = $this->ensureCalendarExists($principalUri); foreach (['', '-death', '-anniversary'] as $tag) { - $objectUri = $book['uri'] . '-' . $cardUri . $tag .'.ics'; + $objectUri = $book['uri'] . '-' . $cardUri . $tag . '.ics'; $this->calDavBackEnd->deleteCalendarObject($calendar['id'], $objectUri, CalDavBackend::CALENDAR_TYPE_CALENDAR, true); } } } /** - * @param string $principal - * @return array|null * @throws \Sabre\DAV\Exception\BadRequest */ - public function ensureCalendarExists(string $principal):?array { + public function ensureCalendarExists(string $principal): ?array { $calendar = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI); if (!is_null($calendar)) { return $calendar; @@ -178,12 +124,14 @@ class BirthdayService { * @param $cardData * @param $dateField * @param $postfix + * @param $reminderOffset * @return VCalendar|null * @throws InvalidDataException */ public function buildDateFromContact(string $cardData, - string $dateField, - string $postfix):?VCalendar { + string $dateField, + string $postfix, + ?string $reminderOffset):?VCalendar { if (empty($cardData)) { return null; } @@ -199,6 +147,10 @@ class BirthdayService { return null; } + if (isset($doc->{self::EXCLUDE_FROM_BIRTHDAY_CALENDAR_PROPERTY_NAME})) { + return null; + } + if (!isset($doc->{$dateField})) { return null; } @@ -220,33 +172,26 @@ class BirthdayService { } catch (InvalidDataException $e) { return null; } + if ($dateParts['year'] !== null) { + $parameters = $birthday->parameters(); + $omitYear = (isset($parameters['X-APPLE-OMIT-YEAR']) + && $parameters['X-APPLE-OMIT-YEAR'] === $dateParts['year']); + // 'X-APPLE-OMIT-YEAR' is not always present, at least iOS 12.4 uses the hard coded date of 1604 (the start of the gregorian calendar) when the year is unknown + if ($omitYear || (int)$dateParts['year'] === 1604) { + $dateParts['year'] = null; + } + } - $unknownYear = false; $originalYear = null; - if (!$dateParts['year']) { - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; + if ($dateParts['year'] !== null) { + $originalYear = (int)$dateParts['year']; + } - $unknownYear = true; - } else { - $parameters = $birthday->parameters(); - if (isset($parameters['X-APPLE-OMIT-YEAR'])) { - $omitYear = $parameters['X-APPLE-OMIT-YEAR']; - if ($dateParts['year'] === $omitYear) { - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; - $unknownYear = true; - } - } else { - $originalYear = (int)$dateParts['year']; - // 'X-APPLE-OMIT-YEAR' is not always present, at least iOS 12.4 uses the hard coded date of 1604 (the start of the gregorian calendar) when the year is unknown - if ($originalYear == 1604) { - $originalYear = null; - $unknownYear = true; - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; - } - if ($originalYear < 1970) { - $birthday = '1970-' . $dateParts['month'] . '-' . $dateParts['date']; - } - } + $leapDay = ((int)$dateParts['month'] === 2 + && (int)$dateParts['date'] === 29); + if ($dateParts['year'] === null || $originalYear < 1970) { + $birthday = ($leapDay ? '1972-' : '1970-') + . $dateParts['month'] . '-' . $dateParts['date']; } try { @@ -281,18 +226,25 @@ class BirthdayService { $vEvent->DTEND['VALUE'] = 'DATE'; $vEvent->{'UID'} = $doc->UID . $postfix; $vEvent->{'RRULE'} = 'FREQ=YEARLY'; + if ($leapDay) { + /* Sabre\VObject supports BYMONTHDAY only if BYMONTH + * is also set */ + $vEvent->{'RRULE'} = 'FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=-1'; + } $vEvent->{'SUMMARY'} = $summary; $vEvent->{'TRANSP'} = 'TRANSPARENT'; $vEvent->{'X-NEXTCLOUD-BC-FIELD-TYPE'} = $dateField; - $vEvent->{'X-NEXTCLOUD-BC-UNKNOWN-YEAR'} = $unknownYear ? '1' : '0'; + $vEvent->{'X-NEXTCLOUD-BC-UNKNOWN-YEAR'} = $dateParts['year'] === null ? '1' : '0'; if ($originalYear !== null) { - $vEvent->{'X-NEXTCLOUD-BC-YEAR'} = (string) $originalYear; + $vEvent->{'X-NEXTCLOUD-BC-YEAR'} = (string)$originalYear; + } + if ($reminderOffset) { + $alarm = $vCal->createComponent('VALARM'); + $alarm->add($vCal->createProperty('TRIGGER', $reminderOffset, ['VALUE' => 'DURATION'])); + $alarm->add($vCal->createProperty('ACTION', 'DISPLAY')); + $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'})); + $vEvent->add($alarm); } - $alarm = $vCal->createComponent('VALARM'); - $alarm->add($vCal->createProperty('TRIGGER', '-PT0M', ['VALUE' => 'DURATION'])); - $alarm->add($vCal->createProperty('ACTION', 'DISPLAY')); - $alarm->add($vCal->createProperty('DESCRIPTION', $vEvent->{'SUMMARY'})); - $vEvent->add($alarm); $vCal->add($vEvent); return $vCal; } @@ -301,8 +253,11 @@ class BirthdayService { * @param string $user */ public function resetForUser(string $user):void { - $principal = 'principals/users/'.$user; + $principal = 'principals/users/' . $user; $calendar = $this->calDavBackEnd->getCalendarByUri($principal, self::BIRTHDAY_CALENDAR_URI); + if (!$calendar) { + return; // The user's birthday calendar doesn't exist, no need to purge it + } $calendarObjects = $this->calDavBackEnd->getCalendarObjects($calendar['id'], CalDavBackend::CALENDAR_TYPE_CALENDAR); foreach ($calendarObjects as $calendarObject) { @@ -315,13 +270,13 @@ class BirthdayService { * @throws \Sabre\DAV\Exception\BadRequest */ public function syncUser(string $user):void { - $principal = 'principals/users/'.$user; + $principal = 'principals/users/' . $user; $this->ensureCalendarExists($principal); $books = $this->cardDavBackEnd->getAddressBooksForUser($principal); foreach ($books as $book) { $cards = $this->cardDavBackEnd->getCards($book['id']); foreach ($cards as $card) { - $this->onCardChanged((int) $book['id'], $card['uri'], $card['carddata']); + $this->onCardChanged((int)$book['id'], $card['uri'], $card['carddata']); } } } @@ -332,7 +287,7 @@ class BirthdayService { * @return bool */ public function birthdayEvenChanged(string $existingCalendarData, - VCalendar $newCalendarData):bool { + VCalendar $newCalendarData):bool { try { $existingBirthday = Reader::read($existingCalendarData); } catch (Exception $ex) { @@ -340,8 +295,8 @@ class BirthdayService { } return ( - $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() || - $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() + $newCalendarData->VEVENT->DTSTART->getValue() !== $existingBirthday->VEVENT->DTSTART->getValue() + || $newCalendarData->VEVENT->SUMMARY->getValue() !== $existingBirthday->VEVENT->SUMMARY->getValue() ); } @@ -371,16 +326,18 @@ class BirthdayService { * @param array $book * @param int $calendarId * @param array $type + * @param string $reminderOffset * @throws InvalidDataException * @throws \Sabre\DAV\Exception\BadRequest */ private function updateCalendar(string $cardUri, - string $cardData, - array $book, - int $calendarId, - array $type):void { + string $cardData, + array $book, + int $calendarId, + array $type, + ?string $reminderOffset):void { $objectUri = $book['uri'] . '-' . $cardUri . $type['postfix'] . '.ics'; - $calendarData = $this->buildDateFromContact($cardData, $type['field'], $type['postfix']); + $calendarData = $this->buildDateFromContact($cardData, $type['field'], $type['postfix'], $reminderOffset); $existing = $this->calDavBackEnd->getCalendarObject($calendarId, $objectUri); if ($calendarData === null) { if ($existing !== null) { @@ -421,14 +378,27 @@ class BirthdayService { } /** + * Extracts the userId part of a principal + * + * @param string $userPrincipal + * @return string|null + */ + private function principalToUserId(string $userPrincipal):?string { + if (str_starts_with($userPrincipal, 'principals/users/')) { + return substr($userPrincipal, 17); + } + return null; + } + + /** * Checks if the user opted-out of birthday calendars * * @param string $userPrincipal The user principal to check for * @return bool */ private function isUserEnabled(string $userPrincipal):bool { - if (strpos($userPrincipal, 'principals/users/') === 0) { - $userId = substr($userPrincipal, 17); + $userId = $this->principalToUserId($userPrincipal); + if ($userId !== null) { $isEnabled = $this->config->getUserValue($userId, 'dav', 'generateBirthdayCalendar', 'yes'); return $isEnabled === 'yes'; } @@ -438,6 +408,23 @@ class BirthdayService { } /** + * Get the reminder offset value for a user. This is a duration string (e.g. + * PT9H) or null if no reminder is wanted. + * + * @param string $userPrincipal + * @return string|null + */ + private function getReminderOffsetForUser(string $userPrincipal):?string { + $userId = $this->principalToUserId($userPrincipal); + if ($userId !== null) { + return $this->config->getUserValue($userId, 'dav', 'birthdayCalendarReminderOffset', 'PT9H') ?: null; + } + + // not sure how we got here, just be on the safe side and return the default value + return 'PT9H'; + } + + /** * Formats title of Birthday event * * @param string $field Field name like BDAY, ANNIVERSARY, ... @@ -447,9 +434,9 @@ class BirthdayService { * @return string The formatted title */ private function formatTitle(string $field, - string $name, - int $year = null, - bool $supports4Byte = true):string { + string $name, + ?int $year = null, + bool $supports4Byte = true):string { if ($supports4Byte) { switch ($field) { case 'BDAY': diff --git a/apps/dav/lib/CalDAV/CachedSubscription.php b/apps/dav/lib/CalDAV/CachedSubscription.php index 18e61450ee9..75ee5cb440f 100644 --- a/apps/dav/lib/CalDAV/CachedSubscription.php +++ b/apps/dav/lib/CalDAV/CachedSubscription.php @@ -3,41 +3,22 @@ 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 Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV; use OCA\DAV\Exception\UnsupportedLimitOnInitialSyncException; -use Sabre\CalDAV\Backend\BackendInterface; use Sabre\DAV\Exception\MethodNotAllowed; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; use Sabre\DAV\PropPatch; /** * Class CachedSubscription * * @package OCA\DAV\CalDAV - * @property BackendInterface|CalDavBackend $caldavBackend + * @property CalDavBackend $caldavBackend */ class CachedSubscription extends \Sabre\CalDAV\Calendar { @@ -51,7 +32,7 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { /** * @return array */ - public function getACL():array { + public function getACL() { return [ [ 'privilege' => '{DAV:}read', @@ -73,13 +54,18 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { 'principal' => '{DAV:}authenticated', 'protected' => true, ], + [ + 'privilege' => '{DAV:}write-properties', + 'principal' => $this->getOwner(), + 'protected' => true, + ] ]; } /** * @return array */ - public function getChildACL():array { + public function getChildACL() { return [ [ 'privilege' => '{DAV:}read', @@ -97,7 +83,6 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { 'principal' => $this->getOwner() . '/calendar-proxy-read', 'protected' => true, ], - ]; } @@ -111,7 +96,7 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { return parent::getOwner(); } - + public function delete() { $this->caldavBackend->deleteSubscription($this->calendarInfo['id']); } @@ -139,9 +124,9 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { } /** - * @return array + * @return INode[] */ - public function getChildren():array { + public function getChildren(): array { $objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); $children = []; @@ -169,11 +154,11 @@ class CachedSubscription extends \Sabre\CalDAV\Calendar { /** * @param string $name - * @param null $calendarData - * @return null|string|void + * @param null|resource|string $data + * @return null|string * @throws MethodNotAllowed */ - public function createFile($name, $calendarData = null) { + public function createFile($name, $data = null) { throw new MethodNotAllowed('Creating objects in cached subscription is not allowed'); } diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php new file mode 100644 index 00000000000..cc1bab6d4fc --- /dev/null +++ b/apps/dav/lib/CalDAV/CachedSubscriptionImpl.php @@ -0,0 +1,102 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCP\Calendar\ICalendar; +use OCP\Calendar\ICalendarIsEnabled; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; +use OCP\Constants; + +class CachedSubscriptionImpl implements ICalendar, ICalendarIsEnabled, ICalendarIsShared, ICalendarIsWritable { + + public function __construct( + private CachedSubscription $calendar, + /** @var array<string, mixed> */ + private array $calendarInfo, + private CalDavBackend $backend, + ) { + } + + /** + * @return string defining the technical unique key + * @since 13.0.0 + */ + public function getKey(): string { + return (string)$this->calendarInfo['id']; + } + + /** + * {@inheritDoc} + */ + public function getUri(): string { + return $this->calendarInfo['uri']; + } + + /** + * In comparison to getKey() this function returns a human readable (maybe translated) name + * @since 13.0.0 + */ + public function getDisplayName(): ?string { + return $this->calendarInfo['{DAV:}displayname']; + } + + /** + * Calendar color + * @since 13.0.0 + */ + public function getDisplayColor(): ?string { + return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color']; + } + + public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array { + return $this->backend->search($this->calendarInfo, $pattern, $searchProperties, $options, $limit, $offset); + } + + /** + * @return int build up using \OCP\Constants + * @since 13.0.0 + */ + public function getPermissions(): int { + $permissions = $this->calendar->getACL(); + $result = 0; + foreach ($permissions as $permission) { + switch ($permission['privilege']) { + case '{DAV:}read': + $result |= Constants::PERMISSION_READ; + break; + } + } + + return $result; + } + + /** + * @since 32.0.0 + */ + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + + public function isWritable(): bool { + return false; + } + + public function isDeleted(): bool { + return false; + } + + public function isShared(): bool { + return true; + } + + public function getSource(): string { + return $this->calendarInfo['source']; + } +} diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionObject.php b/apps/dav/lib/CalDAV/CachedSubscriptionObject.php index db8c9fa8e80..dc9141a61b8 100644 --- a/apps/dav/lib/CalDAV/CachedSubscriptionObject.php +++ b/apps/dav/lib/CalDAV/CachedSubscriptionObject.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018 Georg Ehrke <oc.list@georgehrke.com> - * - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -50,7 +33,7 @@ class CachedSubscriptionObject extends \Sabre\CalDAV\CalendarObject { /** * @param resource|string $calendarData - * @return string|void + * @return string * @throws MethodNotAllowed */ public function put($calendarData) { diff --git a/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php b/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php new file mode 100644 index 00000000000..d64f039d05b --- /dev/null +++ b/apps/dav/lib/CalDAV/CachedSubscriptionProvider.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCP\Calendar\ICalendarProvider; + +class CachedSubscriptionProvider implements ICalendarProvider { + + public function __construct( + private CalDavBackend $calDavBackend, + ) { + } + + public function getCalendars(string $principalUri, array $calendarUris = []): array { + $calendarInfos = $this->calDavBackend->getSubscriptionsForUser($principalUri); + + if (count($calendarUris) > 0) { + $calendarInfos = array_filter($calendarInfos, fn (array $subscription) => in_array($subscription['uri'], $calendarUris)); + } + + $calendarInfos = array_values(array_filter($calendarInfos)); + + $iCalendars = []; + foreach ($calendarInfos as $calendarInfo) { + $calendar = new CachedSubscription($this->calDavBackend, $calendarInfo); + $iCalendars[] = new CachedSubscriptionImpl( + $calendar, + $calendarInfo, + $this->calDavBackend, + ); + } + return $iCalendars; + } +} diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index f0d332adab5..d5b0d875ede 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1,48 +1,19 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2018 Georg Ehrke - * @copyright Copyright (c) 2020, leith abdulla (<online-nextcloud@eleith.com>) - * - * @author Chih-Hsuan Yen <yan12125@gmail.com> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author dartcafe <github@dartcafe.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author leith abdulla <online-nextcloud@eleith.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Simon Spannagel <simonspa@kth.se> - * @author Stefan Weil <sw@weilnetz.de> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vinicius Cubas Brand <vinicius@eita.org.br> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; use DateTime; +use DateTimeImmutable; use DateTimeInterface; +use Generator; use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\Sharing\Backend; use OCA\DAV\Connector\Sabre\Principal; -use OCA\DAV\DAV\Sharing\Backend; use OCA\DAV\DAV\Sharing\IShareable; use OCA\DAV\Events\CachedCalendarObjectCreatedEvent; use OCA\DAV\Events\CachedCalendarObjectDeletedEvent; @@ -50,11 +21,6 @@ use OCA\DAV\Events\CachedCalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarCreatedEvent; use OCA\DAV\Events\CalendarDeletedEvent; use OCA\DAV\Events\CalendarMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectCreatedEvent; -use OCA\DAV\Events\CalendarObjectDeletedEvent; -use OCA\DAV\Events\CalendarObjectMovedToTrashEvent; -use OCA\DAV\Events\CalendarObjectRestoredEvent; -use OCA\DAV\Events\CalendarObjectUpdatedEvent; use OCA\DAV\Events\CalendarPublishedEvent; use OCA\DAV\Events\CalendarRestoredEvent; use OCA\DAV\Events\CalendarShareUpdatedEvent; @@ -63,16 +29,23 @@ use OCA\DAV\Events\CalendarUpdatedEvent; use OCA\DAV\Events\SubscriptionCreatedEvent; use OCA\DAV\Events\SubscriptionDeletedEvent; use OCA\DAV\Events\SubscriptionUpdatedEvent; +use OCP\AppFramework\Db\TTransactional; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\Events\CalendarObjectCreatedEvent; +use OCP\Calendar\Events\CalendarObjectDeletedEvent; +use OCP\Calendar\Events\CalendarObjectMovedEvent; +use OCP\Calendar\Events\CalendarObjectMovedToTrashEvent; +use OCP\Calendar\Events\CalendarObjectRestoredEvent; +use OCP\Calendar\Events\CalendarObjectUpdatedEvent; +use OCP\Calendar\Exceptions\CalendarException; use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IDBConnection; -use OCP\IGroupManager; -use OCP\ILogger; -use OCP\IUser; use OCP\IUserManager; use OCP\Security\ISecureRandom; +use Psr\Log\LoggerInterface; use RuntimeException; use Sabre\CalDAV\Backend\AbstractBackend; use Sabre\CalDAV\Backend\SchedulingSupport; @@ -95,9 +68,10 @@ use Sabre\VObject\ParseException; use Sabre\VObject\Property; use Sabre\VObject\Reader; use Sabre\VObject\Recur\EventIterator; -use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\EventDispatcher\GenericEvent; +use Sabre\VObject\Recur\MaxInstancesExceededException; +use Sabre\VObject\Recur\NoInstancesException; use function array_column; +use function array_map; use function array_merge; use function array_values; use function explode; @@ -117,8 +91,23 @@ use function time; * Code is heavily inspired by https://github.com/fruux/sabre-dav/blob/master/lib/CalDAV/Backend/PDO.php * * @package OCA\DAV\CalDAV + * + * @psalm-type CalendarInfo = array{ + * id: int, + * uri: string, + * principaluri: string, + * '{http://calendarserver.org/ns/}getctag': string, + * '{http://sabredav.org/ns}sync-token': int, + * '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set': \Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet, + * '{urn:ietf:params:xml:ns:caldav}schedule-calendar-transp': \Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp, + * '{DAV:}displayname': string, + * '{urn:ietf:params:xml:ns:caldav}calendar-timezone': ?string, + * '{http://nextcloud.com/ns}owner-displayname': string, + * } */ class CalDavBackend extends AbstractBackend implements SyncSupport, SubscriptionSupport, SchedulingSupport { + use TTransactional; + public const CALENDAR_TYPE_CALENDAR = 0; public const CALENDAR_TYPE_SUBSCRIPTION = 1; @@ -150,7 +139,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @var array * @psalm-var array<string, string[]> */ - public $propertyMap = [ + public array $propertyMap = [ '{DAV:}displayname' => ['displayname', 'string'], '{urn:ietf:params:xml:ns:caldav}calendar-description' => ['description', 'string'], '{urn:ietf:params:xml:ns:caldav}calendar-timezone' => ['timezone', 'string'], @@ -164,7 +153,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @var array */ - public $subscriptionPropertyMap = [ + public array $subscriptionPropertyMap = [ '{DAV:}displayname' => ['displayname', 'string'], '{http://apple.com/ns/ical/}refreshrate' => ['refreshrate', 'string'], '{http://apple.com/ns/ical/}calendar-order' => ['calendarorder', 'int'], @@ -195,7 +184,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ]; /** @var array parameters to index */ - public static $indexParameters = [ + public static array $indexParameters = [ 'ATTENDEE' => ['CN'], 'ORGANIZER' => ['CN'], ]; @@ -203,86 +192,34 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * @var string[] Map of uid => display name */ - protected $userDisplayNames; - - /** @var IDBConnection */ - private $db; - - /** @var Backend */ - private $calendarSharingBackend; - - /** @var Principal */ - private $principalBackend; - - /** @var IUserManager */ - private $userManager; - - /** @var ISecureRandom */ - private $random; + protected array $userDisplayNames; - /** @var ILogger */ - private $logger; + private string $dbObjectsTable = 'calendarobjects'; + private string $dbObjectPropertiesTable = 'calendarobjects_props'; + private string $dbObjectInvitationsTable = 'calendar_invitations'; + private array $cachedObjects = []; - /** @var IEventDispatcher */ - private $dispatcher; - - /** @var EventDispatcherInterface */ - private $legacyDispatcher; - - /** @var IConfig */ - private $config; - - /** @var bool */ - private $legacyEndpoint; - - /** @var string */ - private $dbObjectPropertiesTable = 'calendarobjects_props'; - - /** - * CalDavBackend constructor. - * - * @param IDBConnection $db - * @param Principal $principalBackend - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param ISecureRandom $random - * @param ILogger $logger - * @param IEventDispatcher $dispatcher - * @param EventDispatcherInterface $legacyDispatcher - * @param bool $legacyEndpoint - */ - public function __construct(IDBConnection $db, - Principal $principalBackend, - IUserManager $userManager, - IGroupManager $groupManager, - ISecureRandom $random, - ILogger $logger, - IEventDispatcher $dispatcher, - EventDispatcherInterface $legacyDispatcher, - IConfig $config, - bool $legacyEndpoint = false) { - $this->db = $db; - $this->principalBackend = $principalBackend; - $this->userManager = $userManager; - $this->calendarSharingBackend = new Backend($this->db, $this->userManager, $groupManager, $principalBackend, 'calendar'); - $this->random = $random; - $this->logger = $logger; - $this->dispatcher = $dispatcher; - $this->legacyDispatcher = $legacyDispatcher; - $this->config = $config; - $this->legacyEndpoint = $legacyEndpoint; + public function __construct( + private IDBConnection $db, + private Principal $principalBackend, + private IUserManager $userManager, + private ISecureRandom $random, + private LoggerInterface $logger, + private IEventDispatcher $dispatcher, + private IConfig $config, + private Sharing\Backend $calendarSharingBackend, + private bool $legacyEndpoint = false, + ) { } /** - * Return the number of calendars for a principal + * Return the number of calendars owned by the given principal. * - * By default this excludes the automatically generated birthday calendar + * Calendars shared with the given principal are not counted! * - * @param $principalUri - * @param bool $excludeBirthday - * @return int + * By default, this excludes the automatically generated birthday calendar. */ - public function getCalendarsForUserCount($principalUri, $excludeBirthday = true) { + public function getCalendarsForUserCount(string $principalUri, bool $excludeBirthday = true): int { $principalUri = $this->convertPrincipal($principalUri, true); $query = $this->db->getQueryBuilder(); $query->select($query->func()->count('*')) @@ -305,6 +242,27 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * Return the number of subscriptions for a principal + */ + public function getSubscriptionsForUserCount(string $principalUri): int { + $principalUri = $this->convertPrincipal($principalUri, true); + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->count('*')) + ->from('calendarsubscriptions'); + + if ($principalUri === '') { + $query->where($query->expr()->emptyString('principaluri')); + } else { + $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); + } + + $result = $query->executeQuery(); + $column = (int)$result->fetchOne(); + $result->closeCursor(); + return $column; + } + + /** * @return array{id: int, deleted_at: int}[] */ public function getDeletedCalendars(int $deletedBefore): array { @@ -314,14 +272,15 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->where($qb->expr()->isNotNull('deleted_at')) ->andWhere($qb->expr()->lt('deleted_at', $qb->createNamedParameter($deletedBefore))); $result = $qb->executeQuery(); - $raw = $result->fetchAll(); - $result->closeCursor(); - return array_map(function ($row) { - return [ - 'id' => (int) $row['id'], - 'deleted_at' => (int) $row['deleted_at'], + $calendars = []; + while (($row = $result->fetch()) !== false) { + $calendars[] = [ + 'id' => (int)$row['id'], + 'deleted_at' => (int)$row['deleted_at'], ]; - }, $raw); + } + $result->closeCursor(); + return $calendars; } /** @@ -350,132 +309,143 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array */ public function getCalendarsForUser($principalUri) { - $principalUriOriginal = $principalUri; - $principalUri = $this->convertPrincipal($principalUri, true); - $fields = array_column($this->propertyMap, 0); - $fields[] = 'id'; - $fields[] = 'uri'; - $fields[] = 'synctoken'; - $fields[] = 'components'; - $fields[] = 'principaluri'; - $fields[] = 'transparent'; - - // Making fields a comma-delimited list - $query = $this->db->getQueryBuilder(); - $query->select($fields) - ->from('calendars') - ->orderBy('calendarorder', 'ASC'); - - if ($principalUri === '') { - $query->where($query->expr()->emptyString('principaluri')); - } else { - $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); - } - - $result = $query->executeQuery(); - - $calendars = []; - while ($row = $result->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; - $components = []; - if ($row['components']) { - $components = explode(',',$row['components']); - } - - $calendar = [ - 'id' => $row['id'], - 'uri' => $row['uri'], - 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', - '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), - '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), - ]; - - $calendar = $this->rowToCalendar($row, $calendar); - $calendar = $this->addOwnerPrincipalToCalendar($calendar); - $calendar = $this->addResourceTypeToCalendar($row, $calendar); + return $this->atomic(function () use ($principalUri) { + $principalUriOriginal = $principalUri; + $principalUri = $this->convertPrincipal($principalUri, true); + $fields = array_column($this->propertyMap, 0); + $fields[] = 'id'; + $fields[] = 'uri'; + $fields[] = 'synctoken'; + $fields[] = 'components'; + $fields[] = 'principaluri'; + $fields[] = 'transparent'; + + // Making fields a comma-delimited list + $query = $this->db->getQueryBuilder(); + $query->select($fields) + ->from('calendars') + ->orderBy('calendarorder', 'ASC'); - if (!isset($calendars[$calendar['id']])) { - $calendars[$calendar['id']] = $calendar; + if ($principalUri === '') { + $query->where($query->expr()->emptyString('principaluri')); + } else { + $query->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))); } - } - $result->closeCursor(); - // query for shared calendars - $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); - $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); + $result = $query->executeQuery(); - $principals[] = $principalUri; + $calendars = []; + while ($row = $result->fetch()) { + $row['principaluri'] = (string)$row['principaluri']; + $components = []; + if ($row['components']) { + $components = explode(',', $row['components']); + } - $fields = array_column($this->propertyMap, 0); - $fields[] = 'a.id'; - $fields[] = 'a.uri'; - $fields[] = 'a.synctoken'; - $fields[] = 'a.components'; - $fields[] = 'a.principaluri'; - $fields[] = 'a.transparent'; - $fields[] = 's.access'; - $query = $this->db->getQueryBuilder(); - $query->select($fields) - ->from('dav_shares', 's') - ->join('s', 'calendars', 'a', $query->expr()->eq('s.resourceid', 'a.id')) - ->where($query->expr()->in('s.principaluri', $query->createParameter('principaluri'))) - ->andWhere($query->expr()->eq('s.type', $query->createParameter('type'))) - ->setParameter('type', 'calendar') - ->setParameter('principaluri', $principals, \Doctrine\DBAL\Connection::PARAM_STR_ARRAY); + $calendar = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), + '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), + ]; - $result = $query->executeQuery(); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); - $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; - while ($row = $result->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; - if ($row['principaluri'] === $principalUri) { - continue; + if (!isset($calendars[$calendar['id']])) { + $calendars[$calendar['id']] = $calendar; + } } + $result->closeCursor(); - $readOnly = (int) $row['access'] === Backend::ACCESS_READ; - if (isset($calendars[$row['id']])) { - if ($readOnly) { - // New share can not have more permissions then the old one. + // query for shared calendars + $principals = $this->principalBackend->getGroupMembership($principalUriOriginal, true); + $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUriOriginal)); + $principals[] = $principalUri; + + $fields = array_column($this->propertyMap, 0); + $fields = array_map(function (string $field) { + return 'a.' . $field; + }, $fields); + $fields[] = 'a.id'; + $fields[] = 'a.uri'; + $fields[] = 'a.synctoken'; + $fields[] = 'a.components'; + $fields[] = 'a.principaluri'; + $fields[] = 'a.transparent'; + $fields[] = 's.access'; + + $select = $this->db->getQueryBuilder(); + $subSelect = $this->db->getQueryBuilder(); + + $subSelect->select('resourceid') + ->from('dav_shares', 'd') + ->where($subSelect->expr()->eq('d.access', $select->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->andWhere($subSelect->expr()->in('d.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)); + + $select->select($fields) + ->from('dav_shares', 's') + ->join('s', 'calendars', 'a', $select->expr()->eq('s.resourceid', 'a.id', IQueryBuilder::PARAM_INT)) + ->where($select->expr()->in('s.principaluri', $select->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->andWhere($select->expr()->eq('s.type', $select->createNamedParameter('calendar', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->andWhere($select->expr()->notIn('a.id', $select->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)); + + $results = $select->executeQuery(); + + $readOnlyPropertyName = '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only'; + while ($row = $results->fetch()) { + $row['principaluri'] = (string)$row['principaluri']; + if ($row['principaluri'] === $principalUri) { continue; } - if (isset($calendars[$row['id']][$readOnlyPropertyName]) && - $calendars[$row['id']][$readOnlyPropertyName] === 0) { - // Old share is already read-write, no more permissions can be gained - continue; + + $readOnly = (int)$row['access'] === Backend::ACCESS_READ; + if (isset($calendars[$row['id']])) { + if ($readOnly) { + // New share can not have more permissions than the old one. + continue; + } + if (isset($calendars[$row['id']][$readOnlyPropertyName]) + && $calendars[$row['id']][$readOnlyPropertyName] === 0) { + // Old share is already read-write, no more permissions can be gained + continue; + } } - } - [, $name] = Uri\split($row['principaluri']); - $uri = $row['uri'] . '_shared_by_' . $name; - $row['displayname'] = $row['displayname'] . ' (' . $this->getUserDisplayName($name) . ')'; - $components = []; - if ($row['components']) { - $components = explode(',',$row['components']); - } - $calendar = [ - 'id' => $row['id'], - 'uri' => $uri, - 'principaluri' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', - '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), - '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp('transparent'), - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - $readOnlyPropertyName => $readOnly, - ]; + [, $name] = Uri\split($row['principaluri']); + $uri = $row['uri'] . '_shared_by_' . $name; + $row['displayname'] = $row['displayname'] . ' (' . ($this->userManager->getDisplayName($name) ?? ($name ?? '')) . ')'; + $components = []; + if ($row['components']) { + $components = explode(',', $row['components']); + } + $calendar = [ + 'id' => $row['id'], + 'uri' => $uri, + 'principaluri' => $this->convertPrincipal($principalUri, !$this->legacyEndpoint), + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), + '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp('transparent'), + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), + $readOnlyPropertyName => $readOnly, + ]; - $calendar = $this->rowToCalendar($row, $calendar); - $calendar = $this->addOwnerPrincipalToCalendar($calendar); - $calendar = $this->addResourceTypeToCalendar($row, $calendar); + $calendar = $this->rowToCalendar($row, $calendar); + $calendar = $this->addOwnerPrincipalToCalendar($calendar); + $calendar = $this->addResourceTypeToCalendar($row, $calendar); - $calendars[$calendar['id']] = $calendar; - } - $result->closeCursor(); + $calendars[$calendar['id']] = $calendar; + } + $result->closeCursor(); - return array_values($calendars); + return array_values($calendars); + }, $this->db); } /** @@ -499,17 +469,17 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt = $query->executeQuery(); $calendars = []; while ($row = $stmt->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['uri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), ]; @@ -526,25 +496,6 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return array_values($calendars); } - - /** - * @param $uid - * @return string - */ - private function getUserDisplayName($uid) { - if (!isset($this->userDisplayNames[$uid])) { - $user = $this->userManager->get($uid); - - if ($user instanceof IUser) { - $this->userDisplayNames[$uid] = $user->getDisplayName(); - } else { - $this->userDisplayNames[$uid] = $uid; - } - } - - return $this->userDisplayNames[$uid]; - } - /** * @return array */ @@ -568,19 +519,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->executeQuery(); while ($row = $result->fetch()) { - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; [, $name] = Uri\split($row['principaluri']); $row['displayname'] = $row['displayname'] . "($name)"; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['publicuri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], $this->legacyEndpoint), @@ -633,19 +584,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription throw new NotFound('Node with name \'' . $uri . '\' could not be found'); } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; [, $name] = Uri\split($row['principaluri']); $row['displayname'] = $row['displayname'] . ' ' . "($name)"; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['publicuri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), @@ -688,18 +639,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['uri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), ]; @@ -712,10 +663,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** - * @param $calendarId + * @psalm-return CalendarInfo|null * @return array|null */ - public function getCalendarById($calendarId) { + public function getCalendarById(int $calendarId): ?array { $fields = array_column($this->propertyMap, 0); $fields[] = 'id'; $fields[] = 'uri'; @@ -737,18 +688,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; $components = []; if ($row['components']) { - $components = explode(',',$row['components']); + $components = explode(',', $row['components']); } $calendar = [ 'id' => $row['id'], 'uri' => $row['uri'], 'principaluri' => $this->convertPrincipal($row['principaluri'], !$this->legacyEndpoint), - '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken']?$row['synctoken']:'0'), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{' . Plugin::NS_CALENDARSERVER . '}getctag' => 'http://sabre.io/ns/sync/' . ($row['synctoken'] ?: '0'), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?? 0, '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet($components), '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp' => new ScheduleCalendarTransp($row['transparent']?'transparent':'opaque'), ]; @@ -785,7 +736,44 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } - $row['principaluri'] = (string) $row['principaluri']; + $row['principaluri'] = (string)$row['principaluri']; + $subscription = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'principaluri' => $row['principaluri'], + 'source' => $row['source'], + 'lastmodified' => $row['lastmodified'], + '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']), + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', + ]; + + return $this->rowToSubscription($row, $subscription); + } + + public function getSubscriptionByUri(string $principal, string $uri): ?array { + $fields = array_column($this->subscriptionPropertyMap, 0); + $fields[] = 'id'; + $fields[] = 'uri'; + $fields[] = 'source'; + $fields[] = 'synctoken'; + $fields[] = 'principaluri'; + $fields[] = 'lastmodified'; + + $query = $this->db->getQueryBuilder(); + $query->select($fields) + ->from('calendarsubscriptions') + ->where($query->expr()->eq('uri', $query->createNamedParameter($uri))) + ->andWhere($query->expr()->eq('principaluri', $query->createNamedParameter($principal))) + ->setMaxResults(1); + $stmt = $query->executeQuery(); + + $row = $stmt->fetch(); + $stmt->closeCursor(); + if ($row === false) { + return null; + } + + $row['principaluri'] = (string)$row['principaluri']; $subscription = [ 'id' => $row['id'], 'uri' => $row['uri'], @@ -793,7 +781,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'source' => $row['source'], 'lastmodified' => $row['lastmodified'], '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', ]; return $this->rowToSubscription($row, $subscription); @@ -809,14 +797,20 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $calendarUri * @param array $properties * @return int + * + * @throws CalendarException */ public function createCalendar($principalUri, $calendarUri, array $properties) { + if (strlen($calendarUri) > 255) { + throw new CalendarException('URI too long. Calendar not created'); + } + $values = [ 'principaluri' => $this->convertPrincipal($principalUri, true), 'uri' => $calendarUri, 'synctoken' => 1, 'transparent' => 0, - 'components' => 'VEVENT,VTODO', + 'components' => 'VEVENT,VTODO,VJOURNAL', 'displayname' => $calendarUri ]; @@ -826,7 +820,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription if (!($properties[$sccs] instanceof SupportedCalendarComponentSet)) { throw new DAV\Exception('The ' . $sccs . ' property must be of type: \Sabre\CalDAV\Property\SupportedCalendarComponentSet'); } - $values['components'] = implode(',',$properties[$sccs]->getValue()); + $values['components'] = implode(',', $properties[$sccs]->getValue()); } elseif (isset($properties['components'])) { // Allow to provide components internally without having // to create a SupportedCalendarComponentSet object @@ -835,7 +829,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $transp = '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp'; if (isset($properties[$transp])) { - $values['transparent'] = (int) ($properties[$transp]->getValue() === 'transparent'); + $values['transparent'] = (int)($properties[$transp]->getValue() === 'transparent'); } foreach ($this->propertyMap as $xmlName => [$dbName, $type]) { @@ -844,15 +838,19 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } - $query = $this->db->getQueryBuilder(); - $query->insert('calendars'); - foreach ($values as $column => $value) { - $query->setValue($column, $query->createNamedParameter($value)); - } - $query->executeStatement(); - $calendarId = $query->getLastInsertId(); + [$calendarId, $calendarData] = $this->atomic(function () use ($values) { + $query = $this->db->getQueryBuilder(); + $query->insert('calendars'); + foreach ($values as $column => $value) { + $query->setValue($column, $query->createNamedParameter($value)); + } + $query->executeStatement(); + $calendarId = $query->getLastInsertId(); + + $calendarData = $this->getCalendarById($calendarId); + return [$calendarId, $calendarData]; + }, $this->db); - $calendarData = $this->getCalendarById($calendarId); $this->dispatcher->dispatchTyped(new CalendarCreatedEvent((int)$calendarId, $calendarData)); return $calendarId; @@ -884,7 +882,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription switch ($propertyName) { case '{' . Plugin::NS_CALDAV . '}schedule-calendar-transp': $fieldName = 'transparent'; - $newValues[$fieldName] = (int) ($propertyValue->getValue() === 'transparent'); + $newValues[$fieldName] = (int)($propertyValue->getValue() === 'transparent'); break; default: $fieldName = $this->propertyMap[$propertyName][0]; @@ -892,19 +890,23 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription break; } } - $query = $this->db->getQueryBuilder(); - $query->update('calendars'); - foreach ($newValues as $fieldName => $value) { - $query->set($fieldName, $query->createNamedParameter($value)); - } - $query->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); - $query->executeStatement(); + [$calendarData, $shares] = $this->atomic(function () use ($calendarId, $newValues) { + $query = $this->db->getQueryBuilder(); + $query->update('calendars'); + foreach ($newValues as $fieldName => $value) { + $query->set($fieldName, $query->createNamedParameter($value)); + } + $query->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); + $query->executeStatement(); - $this->addChange($calendarId, "", 2); + $this->addChanges($calendarId, [''], 2); - $calendarData = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); - $this->dispatcher->dispatchTyped(new CalendarUpdatedEvent((int)$calendarId, $calendarData, $shares, $mutations)); + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + return [$calendarData, $shares]; + }, $this->db); + + $this->dispatcher->dispatchTyped(new CalendarUpdatedEvent($calendarId, $calendarData, $shares, $mutations)); return true; }); @@ -917,81 +919,162 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteCalendar($calendarId, bool $forceDeletePermanently = false) { - // The calendar is deleted right away if this is either enforced by the caller - // or the special contacts birthday calendar or when the preference of an empty - // retention (0 seconds) is set, which signals a disabled trashbin. - $calendarData = $this->getCalendarById($calendarId); - $isBirthdayCalendar = isset($calendarData['uri']) && $calendarData['uri'] === BirthdayService::BIRTHDAY_CALENDAR_URI; - $trashbinDisabled = $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0'; - if ($forceDeletePermanently || $isBirthdayCalendar || $trashbinDisabled) { + $this->atomic(function () use ($calendarId, $forceDeletePermanently): void { + // The calendar is deleted right away if this is either enforced by the caller + // or the special contacts birthday calendar or when the preference of an empty + // retention (0 seconds) is set, which signals a disabled trashbin. $calendarData = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $isBirthdayCalendar = isset($calendarData['uri']) && $calendarData['uri'] === BirthdayService::BIRTHDAY_CALENDAR_URI; + $trashbinDisabled = $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0'; + if ($forceDeletePermanently || $isBirthdayCalendar || $trashbinDisabled) { + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); - $qbDeleteCalendarObjectProps = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjectProps->delete($this->dbObjectPropertiesTable) - ->where($qbDeleteCalendarObjectProps->expr()->eq('calendarid', $qbDeleteCalendarObjectProps->createNamedParameter($calendarId))) - ->andWhere($qbDeleteCalendarObjectProps->expr()->eq('calendartype', $qbDeleteCalendarObjectProps->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) - ->executeStatement(); + $this->purgeCalendarInvitations($calendarId); - $qbDeleteCalendarObjects = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjects->delete('calendarobjects') - ->where($qbDeleteCalendarObjects->expr()->eq('calendarid', $qbDeleteCalendarObjects->createNamedParameter($calendarId))) - ->andWhere($qbDeleteCalendarObjects->expr()->eq('calendartype', $qbDeleteCalendarObjects->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) - ->executeStatement(); + $qbDeleteCalendarObjectProps = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjectProps->delete($this->dbObjectPropertiesTable) + ->where($qbDeleteCalendarObjectProps->expr()->eq('calendarid', $qbDeleteCalendarObjectProps->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarObjectProps->expr()->eq('calendartype', $qbDeleteCalendarObjectProps->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $qbDeleteCalendarChanges = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjects->delete('calendarchanges') - ->where($qbDeleteCalendarChanges->expr()->eq('calendarid', $qbDeleteCalendarChanges->createNamedParameter($calendarId))) - ->andWhere($qbDeleteCalendarChanges->expr()->eq('calendartype', $qbDeleteCalendarChanges->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) - ->executeStatement(); + $qbDeleteCalendarObjects = $this->db->getQueryBuilder(); + $qbDeleteCalendarObjects->delete('calendarobjects') + ->where($qbDeleteCalendarObjects->expr()->eq('calendarid', $qbDeleteCalendarObjects->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarObjects->expr()->eq('calendartype', $qbDeleteCalendarObjects->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $this->calendarSharingBackend->deleteAllShares($calendarId); + $qbDeleteCalendarChanges = $this->db->getQueryBuilder(); + $qbDeleteCalendarChanges->delete('calendarchanges') + ->where($qbDeleteCalendarChanges->expr()->eq('calendarid', $qbDeleteCalendarChanges->createNamedParameter($calendarId))) + ->andWhere($qbDeleteCalendarChanges->expr()->eq('calendartype', $qbDeleteCalendarChanges->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))) + ->executeStatement(); - $qbDeleteCalendar = $this->db->getQueryBuilder(); - $qbDeleteCalendarObjects->delete('calendars') - ->where($qbDeleteCalendar->expr()->eq('id', $qbDeleteCalendar->createNamedParameter($calendarId))) - ->executeStatement(); + $this->calendarSharingBackend->deleteAllShares($calendarId); - // Only dispatch if we actually deleted anything - if ($calendarData) { - $this->dispatcher->dispatchTyped(new CalendarDeletedEvent((int)$calendarId, $calendarData, $shares)); - } - } else { - $qbMarkCalendarDeleted = $this->db->getQueryBuilder(); - $qbMarkCalendarDeleted->update('calendars') - ->set('deleted_at', $qbMarkCalendarDeleted->createNamedParameter(time())) - ->where($qbMarkCalendarDeleted->expr()->eq('id', $qbMarkCalendarDeleted->createNamedParameter($calendarId))) - ->executeStatement(); + $qbDeleteCalendar = $this->db->getQueryBuilder(); + $qbDeleteCalendar->delete('calendars') + ->where($qbDeleteCalendar->expr()->eq('id', $qbDeleteCalendar->createNamedParameter($calendarId))) + ->executeStatement(); - $calendarData = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); - if ($calendarData) { - $this->dispatcher->dispatchTyped(new CalendarMovedToTrashEvent( - (int)$calendarId, - $calendarData, - $shares - )); + // Only dispatch if we actually deleted anything + if ($calendarData) { + $this->dispatcher->dispatchTyped(new CalendarDeletedEvent($calendarId, $calendarData, $shares)); + } + } else { + $qbMarkCalendarDeleted = $this->db->getQueryBuilder(); + $qbMarkCalendarDeleted->update('calendars') + ->set('deleted_at', $qbMarkCalendarDeleted->createNamedParameter(time())) + ->where($qbMarkCalendarDeleted->expr()->eq('id', $qbMarkCalendarDeleted->createNamedParameter($calendarId))) + ->executeStatement(); + + $calendarData = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + if ($calendarData) { + $this->dispatcher->dispatchTyped(new CalendarMovedToTrashEvent( + $calendarId, + $calendarData, + $shares + )); + } } - } + }, $this->db); } public function restoreCalendar(int $id): void { + $this->atomic(function () use ($id): void { + $qb = $this->db->getQueryBuilder(); + $update = $qb->update('calendars') + ->set('deleted_at', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $update->executeStatement(); + + $calendarData = $this->getCalendarById($id); + $shares = $this->getShares($id); + if ($calendarData === null) { + throw new RuntimeException('Calendar data that was just written can\'t be read back. Check your database configuration.'); + } + $this->dispatcher->dispatchTyped(new CalendarRestoredEvent( + $id, + $calendarData, + $shares + )); + }, $this->db); + } + + /** + * Returns all calendar entries as a stream of data + * + * @since 32.0.0 + * + * @return Generator<array> + */ + public function exportCalendar(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, ?CalendarExportOptions $options = null): Generator { + // extract options + $rangeStart = $options?->getRangeStart(); + $rangeCount = $options?->getRangeCount(); + // construct query $qb = $this->db->getQueryBuilder(); - $update = $qb->update('calendars') - ->set('deleted_at', $qb->createNamedParameter(null)) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - $update->executeStatement(); + $qb->select('*') + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + if ($rangeStart !== null) { + $qb->andWhere($qb->expr()->gt('uid', $qb->createNamedParameter($rangeStart))); + } + if ($rangeCount !== null) { + $qb->setMaxResults($rangeCount); + } + if ($rangeStart !== null || $rangeCount !== null) { + $qb->orderBy('uid', 'ASC'); + } + $rs = $qb->executeQuery(); + // iterate through results + try { + while (($row = $rs->fetch()) !== false) { + yield $row; + } + } finally { + $rs->closeCursor(); + } + } + + /** + * Returns all calendar objects with limited metadata for a calendar + * + * Every item contains an array with the following keys: + * * id - the table row id + * * etag - An arbitrary string + * * uri - a unique key which will be used to construct the uri. This can + * be any arbitrary string. + * * calendardata - The iCalendar-compatible calendar data + * + * @param mixed $calendarId + * @param int $calendarType + * @return array + */ + public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array { + $query = $this->db->getQueryBuilder(); + $query->select(['id','uid', 'etag', 'uri', 'calendardata']) + ->from('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->andWhere($query->expr()->isNull('deleted_at')); + $stmt = $query->executeQuery(); - $calendarData = $this->getCalendarById($id); - $shares = $this->getShares($id); - if ($calendarData === null) { - throw new RuntimeException('Calendar data that was just written can\'t be read back. Check your database configuration.'); + $result = []; + while (($row = $stmt->fetch()) !== false) { + $result[$row['uid']] = [ + 'id' => $row['id'], + 'etag' => $row['etag'], + 'uri' => $row['uri'], + 'calendardata' => $row['calendardata'], + ]; } - $this->dispatcher->dispatchTyped(new CalendarRestoredEvent( - $id, - $calendarData, - $shares - )); + $stmt->closeCursor(); + + return $result; } /** @@ -1046,7 +1129,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt = $query->executeQuery(); $result = []; - foreach ($stmt->fetchAll() as $row) { + while (($row = $stmt->fetch()) !== false) { $result[] = [ 'id' => $row['id'], 'uri' => $row['uri'], @@ -1073,18 +1156,18 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $stmt = $query->executeQuery(); $result = []; - foreach ($stmt->fetchAll() as $row) { + while (($row = $stmt->fetch()) !== false) { $result[] = [ 'id' => $row['id'], 'uri' => $row['uri'], 'lastmodified' => $row['lastmodified'], 'etag' => '"' . $row['etag'] . '"', - 'calendarid' => (int) $row['calendarid'], - 'calendartype' => (int) $row['calendartype'], - 'size' => (int) $row['size'], + 'calendarid' => (int)$row['calendarid'], + 'calendartype' => (int)$row['calendartype'], + 'size' => (int)$row['size'], 'component' => strtolower($row['componenttype']), - 'classification' => (int) $row['classification'], - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int) $row['deleted_at'], + 'classification' => (int)$row['classification'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], ]; } $stmt->closeCursor(); @@ -1123,7 +1206,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'size' => (int)$row['size'], 'component' => strtolower($row['componenttype']), 'classification' => (int)$row['classification'], - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int) $row['deleted_at'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], ]; } $stmt->closeCursor(); @@ -1149,8 +1232,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array|null */ public function getCalendarObject($calendarId, $objectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $key = $calendarId . '::' . $objectUri . '::' . $calendarType; + if (isset($this->cachedObjects[$key])) { + return $this->cachedObjects[$key]; + } $query = $this->db->getQueryBuilder(); - $query->select(['id', 'uri', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification']) + $query->select(['id', 'uri', 'uid', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification', 'deleted_at']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) @@ -1163,16 +1250,24 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription return null; } + $object = $this->rowToCalendarObject($row); + $this->cachedObjects[$key] = $object; + return $object; + } + + private function rowToCalendarObject(array $row): array { return [ 'id' => $row['id'], 'uri' => $row['uri'], + 'uid' => $row['uid'], 'lastmodified' => $row['lastmodified'], 'etag' => '"' . $row['etag'] . '"', 'calendarid' => $row['calendarid'], 'size' => (int)$row['size'], 'calendardata' => $this->readBlob($row['calendardata']), 'component' => strtolower($row['componenttype']), - 'classification' => (int)$row['classification'] + 'classification' => (int)$row['classification'], + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], ]; } @@ -1248,81 +1343,79 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return string */ public function createCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $this->cachedObjects = []; $extraData = $this->getDenormalizedData($calendarData); - // Try to detect duplicates - $qb = $this->db->getQueryBuilder(); - $qb->select($qb->func()->count('*')) - ->from('calendarobjects') - ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) - ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid']))) - ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) - ->andWhere($qb->expr()->isNull('deleted_at')); - $result = $qb->executeQuery(); - $count = (int) $result->fetchOne(); - $result->closeCursor(); + return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) { + // Try to detect duplicates + $qb = $this->db->getQueryBuilder(); + $qb->select($qb->func()->count('*')) + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('uid', $qb->createNamedParameter($extraData['uid']))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + $result = $qb->executeQuery(); + $count = (int)$result->fetchOne(); + $result->closeCursor(); - if ($count !== 0) { - throw new BadRequest('Calendar object with uid already exists in this calendar collection.'); - } - // For a more specific error message we also try to explicitly look up the UID but as a deleted entry - $qbDel = $this->db->getQueryBuilder(); - $qbDel->select($qb->func()->count('*')) - ->from('calendarobjects') - ->where($qbDel->expr()->eq('calendarid', $qbDel->createNamedParameter($calendarId))) - ->andWhere($qbDel->expr()->eq('uid', $qbDel->createNamedParameter($extraData['uid']))) - ->andWhere($qbDel->expr()->eq('calendartype', $qbDel->createNamedParameter($calendarType))) - ->andWhere($qbDel->expr()->isNotNull('deleted_at')); - $result = $qbDel->executeQuery(); - $count = (int) $result->fetchOne(); - $result->closeCursor(); - if ($count !== 0) { - throw new BadRequest('Deleted calendar object with uid already exists in this calendar collection.'); - } + if ($count !== 0) { + throw new BadRequest('Calendar object with uid already exists in this calendar collection.'); + } + // For a more specific error message we also try to explicitly look up the UID but as a deleted entry + $qbDel = $this->db->getQueryBuilder(); + $qbDel->select('*') + ->from('calendarobjects') + ->where($qbDel->expr()->eq('calendarid', $qbDel->createNamedParameter($calendarId))) + ->andWhere($qbDel->expr()->eq('uid', $qbDel->createNamedParameter($extraData['uid']))) + ->andWhere($qbDel->expr()->eq('calendartype', $qbDel->createNamedParameter($calendarType))) + ->andWhere($qbDel->expr()->isNotNull('deleted_at')); + $result = $qbDel->executeQuery(); + $found = $result->fetch(); + $result->closeCursor(); + if ($found !== false) { + // the object existed previously but has been deleted + // remove the trashbin entry and continue as if it was a new object + $this->deleteCalendarObject($calendarId, $found['uri']); + } - $query = $this->db->getQueryBuilder(); - $query->insert('calendarobjects') - ->values([ - 'calendarid' => $query->createNamedParameter($calendarId), - 'uri' => $query->createNamedParameter($objectUri), - 'calendardata' => $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB), - 'lastmodified' => $query->createNamedParameter(time()), - 'etag' => $query->createNamedParameter($extraData['etag']), - 'size' => $query->createNamedParameter($extraData['size']), - 'componenttype' => $query->createNamedParameter($extraData['componentType']), - 'firstoccurence' => $query->createNamedParameter($extraData['firstOccurence']), - 'lastoccurence' => $query->createNamedParameter($extraData['lastOccurence']), - 'classification' => $query->createNamedParameter($extraData['classification']), - 'uid' => $query->createNamedParameter($extraData['uid']), - 'calendartype' => $query->createNamedParameter($calendarType), - ]) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->insert('calendarobjects') + ->values([ + 'calendarid' => $query->createNamedParameter($calendarId), + 'uri' => $query->createNamedParameter($objectUri), + 'calendardata' => $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB), + 'lastmodified' => $query->createNamedParameter(time()), + 'etag' => $query->createNamedParameter($extraData['etag']), + 'size' => $query->createNamedParameter($extraData['size']), + 'componenttype' => $query->createNamedParameter($extraData['componentType']), + 'firstoccurence' => $query->createNamedParameter($extraData['firstOccurence']), + 'lastoccurence' => $query->createNamedParameter($extraData['lastOccurence']), + 'classification' => $query->createNamedParameter($extraData['classification']), + 'uid' => $query->createNamedParameter($extraData['uid']), + 'calendartype' => $query->createNamedParameter($calendarType), + ]) + ->executeStatement(); - $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); - $this->addChange($calendarId, $objectUri, 1, $calendarType); + $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); + $this->addChanges($calendarId, [$objectUri], 1, $calendarType); - $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $calendarRow = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + assert($objectRow !== null); - $this->dispatcher->dispatchTyped(new CalendarObjectCreatedEvent((int)$calendarId, $calendarRow, $shares, $objectRow)); - } else { - $subscriptionRow = $this->getSubscriptionById($calendarId); - - $this->dispatcher->dispatchTyped(new CachedCalendarObjectCreatedEvent((int)$calendarId, $subscriptionRow, [], $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createCachedCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::createCachedCalendarObject', - [ - 'subscriptionId' => $calendarId, - 'calendarData' => $subscriptionRow, - 'shares' => [], - 'objectData' => $objectRow, - ] - )); - } + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $calendarRow = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); + + $this->dispatcher->dispatchTyped(new CalendarObjectCreatedEvent($calendarId, $calendarRow, $shares, $objectRow)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); - return '"' . $extraData['etag'] . '"'; + $this->dispatcher->dispatchTyped(new CachedCalendarObjectCreatedEvent($calendarId, $subscriptionRow, [], $objectRow)); + } + + return '"' . $extraData['etag'] . '"'; + }, $this->db); } /** @@ -1345,9 +1438,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return string */ public function updateCalendarObject($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + $this->cachedObjects = []; $extraData = $this->getDenormalizedData($calendarData); - $query = $this->db->getQueryBuilder(); - $query->update('calendarobjects') + + return $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $extraData, $calendarType) { + $query = $this->db->getQueryBuilder(); + $query->update('calendarobjects') ->set('calendardata', $query->createNamedParameter($calendarData, IQueryBuilder::PARAM_LOB)) ->set('lastmodified', $query->createNamedParameter(time())) ->set('etag', $query->createNamedParameter($extraData['etag'])) @@ -1357,105 +1453,88 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription ->set('lastoccurence', $query->createNamedParameter($extraData['lastOccurence'])) ->set('classification', $query->createNamedParameter($extraData['classification'])) ->set('uid', $query->createNamedParameter($extraData['uid'])) - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) - ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) - ->executeStatement(); + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) + ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) + ->executeStatement(); - $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); - $this->addChange($calendarId, $objectUri, 2, $calendarType); + $this->updateProperties($calendarId, $objectUri, $calendarData, $calendarType); + $this->addChanges($calendarId, [$objectUri], 2, $calendarType); - $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); - if (is_array($objectRow)) { - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $calendarRow = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $objectRow = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + if (is_array($objectRow)) { + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $calendarRow = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); - $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent((int)$calendarId, $calendarRow, $shares, $objectRow)); - } else { - $subscriptionRow = $this->getSubscriptionById($calendarId); + $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($calendarId, $calendarRow, $shares, $objectRow)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); - $this->dispatcher->dispatchTyped(new CachedCalendarObjectUpdatedEvent((int)$calendarId, $subscriptionRow, [], $objectRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateCachedCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateCachedCalendarObject', - [ - 'subscriptionId' => $calendarId, - 'calendarData' => $subscriptionRow, - 'shares' => [], - 'objectData' => $objectRow, - ] - )); + $this->dispatcher->dispatchTyped(new CachedCalendarObjectUpdatedEvent($calendarId, $subscriptionRow, [], $objectRow)); + } } - } - return '"' . $extraData['etag'] . '"'; + return '"' . $extraData['etag'] . '"'; + }, $this->db); } /** * Moves a calendar object from calendar to calendar. * - * @param int $sourceCalendarId + * @param string $sourcePrincipalUri + * @param int $sourceObjectId + * @param string $targetPrincipalUri * @param int $targetCalendarId - * @param int $objectId - * @param string $principalUri + * @param string $tragetObjectUri * @param int $calendarType * @return bool * @throws Exception */ - public function moveCalendarObject(int $sourceCalendarId, int $targetCalendarId, int $objectId, string $principalUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR): bool { - $object = $this->getCalendarObjectById($principalUri, $objectId); - if (empty($object)) { - return false; - } - - $query = $this->db->getQueryBuilder(); - $query->update('calendarobjects') - ->set('calendarid', $query->createNamedParameter($targetCalendarId, IQueryBuilder::PARAM_INT)) - ->where($query->expr()->eq('id', $query->createNamedParameter($objectId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) - ->executeStatement(); + public function moveCalendarObject(string $sourcePrincipalUri, int $sourceObjectId, string $targetPrincipalUri, int $targetCalendarId, string $tragetObjectUri, int $calendarType = self::CALENDAR_TYPE_CALENDAR): bool { + $this->cachedObjects = []; + return $this->atomic(function () use ($sourcePrincipalUri, $sourceObjectId, $targetPrincipalUri, $targetCalendarId, $tragetObjectUri, $calendarType) { + $object = $this->getCalendarObjectById($sourcePrincipalUri, $sourceObjectId); + if (empty($object)) { + return false; + } - $this->purgeProperties($sourceCalendarId, $objectId); - $this->updateProperties($targetCalendarId, $object['uri'], $object['calendardata'], $calendarType); + $sourceCalendarId = $object['calendarid']; + $sourceObjectUri = $object['uri']; - $this->addChange($sourceCalendarId, $object['uri'], 1, $calendarType); - $this->addChange($targetCalendarId, $object['uri'], 3, $calendarType); + $query = $this->db->getQueryBuilder(); + $query->update('calendarobjects') + ->set('calendarid', $query->createNamedParameter($targetCalendarId, IQueryBuilder::PARAM_INT)) + ->set('uri', $query->createNamedParameter($tragetObjectUri, IQueryBuilder::PARAM_STR)) + ->where($query->expr()->eq('id', $query->createNamedParameter($sourceObjectId, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->executeStatement(); - $object = $this->getCalendarObjectById($principalUri, $objectId); - // Calendar Object wasn't found - possibly because it was deleted in the meantime by a different client - if (empty($object)) { - return false; - } + $this->purgeProperties($sourceCalendarId, $sourceObjectId); + $this->updateProperties($targetCalendarId, $tragetObjectUri, $object['calendardata'], $calendarType); - $calendarRow = $this->getCalendarById($targetCalendarId); - // the calendar this event is being moved to does not exist any longer - if (empty($calendarRow)) { - return false; - } + $this->addChanges($sourceCalendarId, [$sourceObjectUri], 3, $calendarType); + $this->addChanges($targetCalendarId, [$tragetObjectUri], 1, $calendarType); - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $shares = $this->getShares($targetCalendarId); - $this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($targetCalendarId, $calendarRow, $shares, $object)); - } - return true; - } + $object = $this->getCalendarObjectById($targetPrincipalUri, $sourceObjectId); + // Calendar Object wasn't found - possibly because it was deleted in the meantime by a different client + if (empty($object)) { + return false; + } + $targetCalendarRow = $this->getCalendarById($targetCalendarId); + // the calendar this event is being moved to does not exist any longer + if (empty($targetCalendarRow)) { + return false; + } - /** - * @param int $calendarObjectId - * @param int $classification - */ - public function setClassification($calendarObjectId, $classification) { - if (!in_array($classification, [ - self::CLASSIFICATION_PUBLIC, self::CLASSIFICATION_PRIVATE, self::CLASSIFICATION_CONFIDENTIAL - ])) { - throw new \InvalidArgumentException(); - } - $query = $this->db->getQueryBuilder(); - $query->update('calendarobjects') - ->set('classification', $query->createNamedParameter($classification)) - ->where($query->expr()->eq('id', $query->createNamedParameter($calendarObjectId))) - ->executeStatement(); + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $sourceShares = $this->getShares($sourceCalendarId); + $targetShares = $this->getShares($targetCalendarId); + $sourceCalendarRow = $this->getCalendarById($sourceCalendarId); + $this->dispatcher->dispatchTyped(new CalendarObjectMovedEvent($sourceCalendarId, $sourceCalendarRow, $targetCalendarId, $targetCalendarRow, $sourceShares, $targetShares, $object)); + } + return true; + }, $this->db); } /** @@ -1470,86 +1549,82 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteCalendarObject($calendarId, $objectUri, $calendarType = self::CALENDAR_TYPE_CALENDAR, bool $forceDeletePermanently = false) { - $data = $this->getCalendarObject($calendarId, $objectUri, $calendarType); + $this->cachedObjects = []; + $this->atomic(function () use ($calendarId, $objectUri, $calendarType, $forceDeletePermanently): void { + $data = $this->getCalendarObject($calendarId, $objectUri, $calendarType); - if ($data === null) { - // Nothing to delete - return; - } + if ($data === null) { + // Nothing to delete + return; + } - if ($forceDeletePermanently || $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0') { - $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ? AND `calendartype` = ?'); - $stmt->execute([$calendarId, $objectUri, $calendarType]); + if ($forceDeletePermanently || $this->config->getAppValue(Application::APP_ID, RetentionService::RETENTION_CONFIG_KEY) === '0') { + $stmt = $this->db->prepare('DELETE FROM `*PREFIX*calendarobjects` WHERE `calendarid` = ? AND `uri` = ? AND `calendartype` = ?'); + $stmt->execute([$calendarId, $objectUri, $calendarType]); - $this->purgeProperties($calendarId, $data['id']); + $this->purgeProperties($calendarId, $data['id']); - if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { - $calendarRow = $this->getCalendarById($calendarId); - $shares = $this->getShares($calendarId); + $this->purgeObjectInvitations($data['uid']); - $this->dispatcher->dispatchTyped(new CalendarObjectDeletedEvent((int)$calendarId, $calendarRow, $shares, $data)); - } else { - $subscriptionRow = $this->getSubscriptionById($calendarId); + if ($calendarType === self::CALENDAR_TYPE_CALENDAR) { + $calendarRow = $this->getCalendarById($calendarId); + $shares = $this->getShares($calendarId); - $this->dispatcher->dispatchTyped(new CachedCalendarObjectDeletedEvent((int)$calendarId, $subscriptionRow, [], $data)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteCachedCalendarObject', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::deleteCachedCalendarObject', - [ - 'subscriptionId' => $calendarId, - 'calendarData' => $subscriptionRow, - 'shares' => [], - 'objectData' => $data, - ] - )); - } - } else { - $pathInfo = pathinfo($data['uri']); - if (!empty($pathInfo['extension'])) { - // Append a suffix to "free" the old URI for recreation - $newUri = sprintf( - "%s-deleted.%s", - $pathInfo['filename'], - $pathInfo['extension'] - ); - } else { - $newUri = sprintf( - "%s-deleted", - $pathInfo['filename'] - ); - } + $this->dispatcher->dispatchTyped(new CalendarObjectDeletedEvent($calendarId, $calendarRow, $shares, $data)); + } else { + $subscriptionRow = $this->getSubscriptionById($calendarId); - // Try to detect conflicts before the DB does - // As unlikely as it seems, this can happen when the user imports, then deletes, imports and deletes again - $newObject = $this->getCalendarObject($calendarId, $newUri, $calendarType); - if ($newObject !== null) { - throw new Forbidden("A calendar object with URI $newUri already exists in calendar $calendarId, therefore this object can't be moved into the trashbin"); - } + $this->dispatcher->dispatchTyped(new CachedCalendarObjectDeletedEvent($calendarId, $subscriptionRow, [], $data)); + } + } else { + $pathInfo = pathinfo($data['uri']); + if (!empty($pathInfo['extension'])) { + // Append a suffix to "free" the old URI for recreation + $newUri = sprintf( + '%s-deleted.%s', + $pathInfo['filename'], + $pathInfo['extension'] + ); + } else { + $newUri = sprintf( + '%s-deleted', + $pathInfo['filename'] + ); + } - $qb = $this->db->getQueryBuilder(); - $markObjectDeletedQuery = $qb->update('calendarobjects') - ->set('deleted_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) - ->set('uri', $qb->createNamedParameter($newUri)) - ->where( - $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), - $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), - $qb->expr()->eq('uri', $qb->createNamedParameter($objectUri)) - ); - $markObjectDeletedQuery->executeStatement(); + // Try to detect conflicts before the DB does + // As unlikely as it seems, this can happen when the user imports, then deletes, imports and deletes again + $newObject = $this->getCalendarObject($calendarId, $newUri, $calendarType); + if ($newObject !== null) { + throw new Forbidden("A calendar object with URI $newUri already exists in calendar $calendarId, therefore this object can't be moved into the trashbin"); + } - $calendarData = $this->getCalendarById($calendarId); - if ($calendarData !== null) { - $this->dispatcher->dispatchTyped( - new CalendarObjectMovedToTrashEvent( - (int)$calendarId, - $calendarData, - $this->getShares($calendarId), - $data - ) - ); + $qb = $this->db->getQueryBuilder(); + $markObjectDeletedQuery = $qb->update('calendarobjects') + ->set('deleted_at', $qb->createNamedParameter(time(), IQueryBuilder::PARAM_INT)) + ->set('uri', $qb->createNamedParameter($newUri)) + ->where( + $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), + $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $qb->expr()->eq('uri', $qb->createNamedParameter($objectUri)) + ); + $markObjectDeletedQuery->executeStatement(); + + $calendarData = $this->getCalendarById($calendarId); + if ($calendarData !== null) { + $this->dispatcher->dispatchTyped( + new CalendarObjectMovedToTrashEvent( + $calendarId, + $calendarData, + $this->getShares($calendarId), + $data + ) + ); + } } - } - $this->addChange($calendarId, $objectUri, 3, $calendarType); + $this->addChanges($calendarId, [$objectUri], 3, $calendarType); + }, $this->db); } /** @@ -1558,50 +1633,53 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @throws Forbidden */ public function restoreCalendarObject(array $objectData): void { - $id = (int) $objectData['id']; - $restoreUri = str_replace("-deleted.ics", ".ics", $objectData['uri']); - $targetObject = $this->getCalendarObject( - $objectData['calendarid'], - $restoreUri - ); - if ($targetObject !== null) { - throw new Forbidden("Can not restore calendar $id because a calendar object with the URI $restoreUri already exists"); - } + $this->cachedObjects = []; + $this->atomic(function () use ($objectData): void { + $id = (int)$objectData['id']; + $restoreUri = str_replace('-deleted.ics', '.ics', $objectData['uri']); + $targetObject = $this->getCalendarObject( + $objectData['calendarid'], + $restoreUri + ); + if ($targetObject !== null) { + throw new Forbidden("Can not restore calendar $id because a calendar object with the URI $restoreUri already exists"); + } - $qb = $this->db->getQueryBuilder(); - $update = $qb->update('calendarobjects') - ->set('uri', $qb->createNamedParameter($restoreUri)) - ->set('deleted_at', $qb->createNamedParameter(null)) - ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - $update->executeStatement(); - - // Make sure this change is tracked in the changes table - $qb2 = $this->db->getQueryBuilder(); - $selectObject = $qb2->select('calendardata', 'uri', 'calendarid', 'calendartype') - ->selectAlias('componenttype', 'component') - ->from('calendarobjects') - ->where($qb2->expr()->eq('id', $qb2->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); - $result = $selectObject->executeQuery(); - $row = $result->fetch(); - $result->closeCursor(); - if ($row === false) { - // Welp, this should possibly not have happened, but let's ignore - return; - } - $this->addChange($row['calendarid'], $row['uri'], 1, (int) $row['calendartype']); + $qb = $this->db->getQueryBuilder(); + $update = $qb->update('calendarobjects') + ->set('uri', $qb->createNamedParameter($restoreUri)) + ->set('deleted_at', $qb->createNamedParameter(null)) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $update->executeStatement(); + + // Make sure this change is tracked in the changes table + $qb2 = $this->db->getQueryBuilder(); + $selectObject = $qb2->select('calendardata', 'uri', 'calendarid', 'calendartype') + ->selectAlias('componenttype', 'component') + ->from('calendarobjects') + ->where($qb2->expr()->eq('id', $qb2->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)); + $result = $selectObject->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + if ($row === false) { + // Welp, this should possibly not have happened, but let's ignore + return; + } + $this->addChanges($row['calendarid'], [$row['uri']], 1, (int)$row['calendartype']); - $calendarRow = $this->getCalendarById((int) $row['calendarid']); - if ($calendarRow === null) { - throw new RuntimeException('Calendar object data that was just written can\'t be read back. Check your database configuration.'); - } - $this->dispatcher->dispatchTyped( - new CalendarObjectRestoredEvent( - (int) $objectData['calendarid'], - $calendarRow, - $this->getShares((int) $row['calendarid']), - $row - ) - ); + $calendarRow = $this->getCalendarById((int)$row['calendarid']); + if ($calendarRow === null) { + throw new RuntimeException('Calendar object data that was just written can\'t be read back. Check your database configuration.'); + } + $this->dispatcher->dispatchTyped( + new CalendarObjectRestoredEvent( + (int)$objectData['calendarid'], + $calendarRow, + $this->getShares((int)$row['calendarid']), + $row + ) + ); + }, $this->db); } /** @@ -1644,7 +1722,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * Note that especially time-range-filters may be difficult to parse. A * time-range filter specified on a VEVENT must for instance also handle * recurrence rules correctly. - * A good example of how to interprete all these filters can also simply + * A good example of how to interpret all these filters can also simply * be found in Sabre\CalDAV\CalendarQueryFilter. This class is as correct * as possible, so it gives you a good idea on what type of stuff you need * to think of. @@ -1683,12 +1761,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } } - $columns = ['uri']; - if ($requirePostFilter) { - $columns = ['uri', 'calendardata']; - } $query = $this->db->getQueryBuilder(); - $query->select($columns) + $query->select(['id', 'uri', 'uid', 'lastmodified', 'etag', 'calendarid', 'size', 'calendardata', 'componenttype', 'classification', 'deleted_at']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) @@ -1709,21 +1783,32 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $result = []; while ($row = $stmt->fetch()) { + // if we leave it as a blob we can't read it both from the post filter and the rowToCalendarObject + if (isset($row['calendardata'])) { + $row['calendardata'] = $this->readBlob($row['calendardata']); + } + if ($requirePostFilter) { // validateFilterForObject will parse the calendar data // catch parsing errors try { $matches = $this->validateFilterForObject($row, $filters); } catch (ParseException $ex) { - $this->logger->logException($ex, [ + $this->logger->error('Caught parsing exception for calendar data. This usually indicates invalid calendar data. calendar-id:' . $calendarId . ' uri:' . $row['uri'], [ 'app' => 'dav', - 'message' => 'Caught parsing exception for calendar data. This usually indicates invalid calendar data. calendar-id:'.$calendarId.' uri:'.$row['uri'] + 'exception' => $ex, ]); continue; } catch (InvalidDataException $ex) { - $this->logger->logException($ex, [ + $this->logger->error('Caught invalid data exception for calendar data. This usually indicates invalid calendar data. calendar-id:' . $calendarId . ' uri:' . $row['uri'], [ + 'app' => 'dav', + 'exception' => $ex, + ]); + continue; + } catch (MaxInstancesExceededException $ex) { + $this->logger->warning('Caught max instances exceeded exception for calendar data. This usually indicates too much recurring (more than 3500) event in calendar data. Object uri: ' . $row['uri'], [ 'app' => 'dav', - 'message' => 'Caught invalid data exception for calendar data. This usually indicates invalid calendar data. calendar-id:'.$calendarId.' uri:'.$row['uri'] + 'exception' => $ex, ]); continue; } @@ -1733,6 +1818,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } $result[] = $row['uri']; + $key = $calendarId . '::' . $row['uri'] . '::' . $calendarType; + $this->cachedObjects[$key] = $this->rowToCalendarObject($row); } return $result; @@ -1750,118 +1837,120 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array */ public function calendarSearch($principalUri, array $filters, $limit = null, $offset = null) { - $calendars = $this->getCalendarsForUser($principalUri); - $ownCalendars = []; - $sharedCalendars = []; + return $this->atomic(function () use ($principalUri, $filters, $limit, $offset) { + $calendars = $this->getCalendarsForUser($principalUri); + $ownCalendars = []; + $sharedCalendars = []; - $uriMapper = []; + $uriMapper = []; - foreach ($calendars as $calendar) { - if ($calendar['{http://owncloud.org/ns}owner-principal'] === $principalUri) { - $ownCalendars[] = $calendar['id']; - } else { - $sharedCalendars[] = $calendar['id']; + foreach ($calendars as $calendar) { + if ($calendar['{http://owncloud.org/ns}owner-principal'] === $principalUri) { + $ownCalendars[] = $calendar['id']; + } else { + $sharedCalendars[] = $calendar['id']; + } + $uriMapper[$calendar['id']] = $calendar['uri']; + } + if (count($ownCalendars) === 0 && count($sharedCalendars) === 0) { + return []; } - $uriMapper[$calendar['id']] = $calendar['uri']; - } - if (count($ownCalendars) === 0 && count($sharedCalendars) === 0) { - return []; - } - $query = $this->db->getQueryBuilder(); - // Calendar id expressions - $calendarExpressions = []; - foreach ($ownCalendars as $id) { - $calendarExpressions[] = $query->expr()->andX( - $query->expr()->eq('c.calendarid', - $query->createNamedParameter($id)), - $query->expr()->eq('c.calendartype', + $query = $this->db->getQueryBuilder(); + // Calendar id expressions + $calendarExpressions = []; + foreach ($ownCalendars as $id) { + $calendarExpressions[] = $query->expr()->andX( + $query->expr()->eq('c.calendarid', + $query->createNamedParameter($id)), + $query->expr()->eq('c.calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); - } - foreach ($sharedCalendars as $id) { - $calendarExpressions[] = $query->expr()->andX( - $query->expr()->eq('c.calendarid', - $query->createNamedParameter($id)), - $query->expr()->eq('c.classification', - $query->createNamedParameter(self::CLASSIFICATION_PUBLIC)), - $query->expr()->eq('c.calendartype', - $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); - } - - if (count($calendarExpressions) === 1) { - $calExpr = $calendarExpressions[0]; - } else { - $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions); - } + } + foreach ($sharedCalendars as $id) { + $calendarExpressions[] = $query->expr()->andX( + $query->expr()->eq('c.calendarid', + $query->createNamedParameter($id)), + $query->expr()->eq('c.classification', + $query->createNamedParameter(self::CLASSIFICATION_PUBLIC)), + $query->expr()->eq('c.calendartype', + $query->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); + } - // Component expressions - $compExpressions = []; - foreach ($filters['comps'] as $comp) { - $compExpressions[] = $query->expr() - ->eq('c.componenttype', $query->createNamedParameter($comp)); - } + if (count($calendarExpressions) === 1) { + $calExpr = $calendarExpressions[0]; + } else { + $calExpr = call_user_func_array([$query->expr(), 'orX'], $calendarExpressions); + } - if (count($compExpressions) === 1) { - $compExpr = $compExpressions[0]; - } else { - $compExpr = call_user_func_array([$query->expr(), 'orX'], $compExpressions); - } + // Component expressions + $compExpressions = []; + foreach ($filters['comps'] as $comp) { + $compExpressions[] = $query->expr() + ->eq('c.componenttype', $query->createNamedParameter($comp)); + } - if (!isset($filters['props'])) { - $filters['props'] = []; - } - if (!isset($filters['params'])) { - $filters['params'] = []; - } + if (count($compExpressions) === 1) { + $compExpr = $compExpressions[0]; + } else { + $compExpr = call_user_func_array([$query->expr(), 'orX'], $compExpressions); + } - $propParamExpressions = []; - foreach ($filters['props'] as $prop) { - $propParamExpressions[] = $query->expr()->andX( - $query->expr()->eq('i.name', $query->createNamedParameter($prop)), - $query->expr()->isNull('i.parameter') - ); - } - foreach ($filters['params'] as $param) { - $propParamExpressions[] = $query->expr()->andX( - $query->expr()->eq('i.name', $query->createNamedParameter($param['property'])), - $query->expr()->eq('i.parameter', $query->createNamedParameter($param['parameter'])) - ); - } + if (!isset($filters['props'])) { + $filters['props'] = []; + } + if (!isset($filters['params'])) { + $filters['params'] = []; + } - if (count($propParamExpressions) === 1) { - $propParamExpr = $propParamExpressions[0]; - } else { - $propParamExpr = call_user_func_array([$query->expr(), 'orX'], $propParamExpressions); - } + $propParamExpressions = []; + foreach ($filters['props'] as $prop) { + $propParamExpressions[] = $query->expr()->andX( + $query->expr()->eq('i.name', $query->createNamedParameter($prop)), + $query->expr()->isNull('i.parameter') + ); + } + foreach ($filters['params'] as $param) { + $propParamExpressions[] = $query->expr()->andX( + $query->expr()->eq('i.name', $query->createNamedParameter($param['property'])), + $query->expr()->eq('i.parameter', $query->createNamedParameter($param['parameter'])) + ); + } - $query->select(['c.calendarid', 'c.uri']) - ->from($this->dbObjectPropertiesTable, 'i') - ->join('i', 'calendarobjects', 'c', $query->expr()->eq('i.objectid', 'c.id')) - ->where($calExpr) - ->andWhere($compExpr) - ->andWhere($propParamExpr) - ->andWhere($query->expr()->iLike('i.value', - $query->createNamedParameter('%'.$this->db->escapeLikeParameter($filters['search-term']).'%'))) - ->andWhere($query->expr()->isNull('deleted_at')); + if (count($propParamExpressions) === 1) { + $propParamExpr = $propParamExpressions[0]; + } else { + $propParamExpr = call_user_func_array([$query->expr(), 'orX'], $propParamExpressions); + } - if ($offset) { - $query->setFirstResult($offset); - } - if ($limit) { - $query->setMaxResults($limit); - } + $query->select(['c.calendarid', 'c.uri']) + ->from($this->dbObjectPropertiesTable, 'i') + ->join('i', 'calendarobjects', 'c', $query->expr()->eq('i.objectid', 'c.id')) + ->where($calExpr) + ->andWhere($compExpr) + ->andWhere($propParamExpr) + ->andWhere($query->expr()->iLike('i.value', + $query->createNamedParameter('%' . $this->db->escapeLikeParameter($filters['search-term']) . '%'))) + ->andWhere($query->expr()->isNull('deleted_at')); + + if ($offset) { + $query->setFirstResult($offset); + } + if ($limit) { + $query->setMaxResults($limit); + } - $stmt = $query->executeQuery(); + $stmt = $query->executeQuery(); - $result = []; - while ($row = $stmt->fetch()) { - $path = $uriMapper[$row['calendarid']] . '/' . $row['uri']; - if (!in_array($path, $result)) { - $result[] = $path; + $result = []; + while ($row = $stmt->fetch()) { + $path = $uriMapper[$row['calendarid']] . '/' . $row['uri']; + if (!in_array($path, $result)) { + $result[] = $path; + } } - } - return $result; + return $result; + }, $this->db); } /** @@ -1876,110 +1965,151 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * * @return array */ - public function search(array $calendarInfo, $pattern, array $searchProperties, - array $options, $limit, $offset) { + public function search( + array $calendarInfo, + $pattern, + array $searchProperties, + array $options, + $limit, + $offset, + ) { $outerQuery = $this->db->getQueryBuilder(); $innerQuery = $this->db->getQueryBuilder(); + if (isset($calendarInfo['source'])) { + $calendarType = self::CALENDAR_TYPE_SUBSCRIPTION; + } else { + $calendarType = self::CALENDAR_TYPE_CALENDAR; + } + $innerQuery->selectDistinct('op.objectid') ->from($this->dbObjectPropertiesTable, 'op') ->andWhere($innerQuery->expr()->eq('op.calendarid', $outerQuery->createNamedParameter($calendarInfo['id']))) ->andWhere($innerQuery->expr()->eq('op.calendartype', - $outerQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); + $outerQuery->createNamedParameter($calendarType))); + + $outerQuery->select('c.id', 'c.calendardata', 'c.componenttype', 'c.uid', 'c.uri') + ->from('calendarobjects', 'c') + ->where($outerQuery->expr()->isNull('deleted_at')); // only return public items for shared calendars for now if (isset($calendarInfo['{http://owncloud.org/ns}owner-principal']) === false || $calendarInfo['principaluri'] !== $calendarInfo['{http://owncloud.org/ns}owner-principal']) { - $innerQuery->andWhere($innerQuery->expr()->eq('c.classification', + $outerQuery->andWhere($outerQuery->expr()->eq('c.classification', $outerQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); } if (!empty($searchProperties)) { - $or = $innerQuery->expr()->orX(); + $or = []; foreach ($searchProperties as $searchProperty) { - $or->add($innerQuery->expr()->eq('op.name', - $outerQuery->createNamedParameter($searchProperty))); + $or[] = $innerQuery->expr()->eq('op.name', + $outerQuery->createNamedParameter($searchProperty)); } - $innerQuery->andWhere($or); + $innerQuery->andWhere($innerQuery->expr()->orX(...$or)); } if ($pattern !== '') { $innerQuery->andWhere($innerQuery->expr()->iLike('op.value', - $outerQuery->createNamedParameter('%' . - $this->db->escapeLikeParameter($pattern) . '%'))); + $outerQuery->createNamedParameter('%' + . $this->db->escapeLikeParameter($pattern) . '%'))); } - $outerQuery->select('c.id', 'c.calendardata', 'c.componenttype', 'c.uid', 'c.uri') - ->from('calendarobjects', 'c') - ->where($outerQuery->expr()->isNull('deleted_at')); + $start = null; + $end = null; - if (isset($options['timerange'])) { - if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { - $outerQuery->andWhere($outerQuery->expr()->gt('lastoccurence', - $outerQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()))); - } - if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { - $outerQuery->andWhere($outerQuery->expr()->lt('firstoccurence', - $outerQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()))); - } + $hasLimit = is_int($limit); + $hasTimeRange = false; + + if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $start */ + $start = $options['timerange']['start']; + $outerQuery->andWhere( + $outerQuery->expr()->gt( + 'lastoccurence', + $outerQuery->createNamedParameter($start->getTimestamp()) + ) + ); + $hasTimeRange = true; + } + + if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { + /** @var DateTimeInterface $end */ + $end = $options['timerange']['end']; + $outerQuery->andWhere( + $outerQuery->expr()->lt( + 'firstoccurence', + $outerQuery->createNamedParameter($end->getTimestamp()) + ) + ); + $hasTimeRange = true; + } + + if (isset($options['uid'])) { + $outerQuery->andWhere($outerQuery->expr()->eq('uid', $outerQuery->createNamedParameter($options['uid']))); } if (!empty($options['types'])) { - $or = $outerQuery->expr()->orX(); + $or = []; foreach ($options['types'] as $type) { - $or->add($outerQuery->expr()->eq('componenttype', - $outerQuery->createNamedParameter($type))); + $or[] = $outerQuery->expr()->eq('componenttype', + $outerQuery->createNamedParameter($type)); } - $outerQuery->andWhere($or); + $outerQuery->andWhere($outerQuery->expr()->orX(...$or)); } $outerQuery->andWhere($outerQuery->expr()->in('c.id', $outerQuery->createFunction($innerQuery->getSQL()))); - if ($offset) { - $outerQuery->setFirstResult($offset); - } - if ($limit) { + // Without explicit order by its undefined in which order the SQL server returns the events. + // For the pagination with hasLimit and hasTimeRange, a stable ordering is helpful. + $outerQuery->addOrderBy('id'); + + $offset = (int)$offset; + $outerQuery->setFirstResult($offset); + + $calendarObjects = []; + + if ($hasLimit && $hasTimeRange) { + /** + * Event recurrences are evaluated at runtime because the database only knows the first and last occurrence. + * + * Given, a user created 8 events with a yearly reoccurrence and two for events tomorrow. + * The upcoming event widget asks the CalDAV backend for 7 events within the next 14 days. + * + * If limit 7 is applied to the SQL query, we find the 7 events with a yearly reoccurrence + * and discard the events after evaluating the reoccurrence rules because they are not due within + * the next 14 days and end up with an empty result even if there are two events to show. + * + * The workaround for search requests with a limit and time range is asking for more row than requested + * and retrying if we have not reached the limit. + * + * 25 rows and 3 retries is entirely arbitrary. + */ + $maxResults = (int)max($limit, 25); + $outerQuery->setMaxResults($maxResults); + + for ($attempt = $objectsCount = 0; $attempt < 3 && $objectsCount < $limit; $attempt++) { + $objectsCount = array_push($calendarObjects, ...$this->searchCalendarObjects($outerQuery, $start, $end)); + $outerQuery->setFirstResult($offset += $maxResults); + } + + $calendarObjects = array_slice($calendarObjects, 0, $limit, false); + } else { $outerQuery->setMaxResults($limit); + $calendarObjects = $this->searchCalendarObjects($outerQuery, $start, $end); } - $result = $outerQuery->executeQuery(); - $calendarObjects = array_filter($result->fetchAll(), function (array $row) use ($options) { - $start = $options['timerange']['start'] ?? null; - $end = $options['timerange']['end'] ?? null; + $calendarObjects = array_map(function ($o) use ($options) { + $calendarData = Reader::read($o['calendardata']); - if ($start === null || !($start instanceof DateTimeInterface) || $end === null || !($end instanceof DateTimeInterface)) { - // No filter required - return true; + // Expand recurrences if an explicit time range is requested + if ($calendarData instanceof VCalendar + && isset($options['timerange']['start'], $options['timerange']['end'])) { + $calendarData = $calendarData->expand( + $options['timerange']['start'], + $options['timerange']['end'], + ); } - $isValid = $this->validateFilterForObject($row, [ - 'name' => 'VCALENDAR', - 'comp-filters' => [ - [ - 'name' => 'VEVENT', - 'comp-filters' => [], - 'prop-filters' => [], - 'is-not-defined' => false, - 'time-range' => [ - 'start' => $start, - 'end' => $end, - ], - ], - ], - 'prop-filters' => [], - 'is-not-defined' => false, - 'time-range' => null, - ]); - if (is_resource($row['calendardata'])) { - // Put the stream back to the beginning so it can be read another time - rewind($row['calendardata']); - } - return $isValid; - }); - $result->closeCursor(); - - return array_map(function ($o) { - $calendarData = Reader::read($o['calendardata']); $comps = $calendarData->getComponents(); $objects = []; $timezones = []; @@ -2004,6 +2134,72 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription }, $timezones), ]; }, $calendarObjects); + + usort($calendarObjects, function (array $a, array $b) { + /** @var DateTimeImmutable $startA */ + $startA = $a['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE); + /** @var DateTimeImmutable $startB */ + $startB = $b['objects'][0]['DTSTART'][0] ?? new DateTimeImmutable(self::MAX_DATE); + + return $startA->getTimestamp() <=> $startB->getTimestamp(); + }); + + return $calendarObjects; + } + + private function searchCalendarObjects(IQueryBuilder $query, ?DateTimeInterface $start, ?DateTimeInterface $end): array { + $calendarObjects = []; + $filterByTimeRange = ($start instanceof DateTimeInterface) || ($end instanceof DateTimeInterface); + + $result = $query->executeQuery(); + + while (($row = $result->fetch()) !== false) { + if ($filterByTimeRange === false) { + // No filter required + $calendarObjects[] = $row; + continue; + } + + try { + $isValid = $this->validateFilterForObject($row, [ + 'name' => 'VCALENDAR', + 'comp-filters' => [ + [ + 'name' => 'VEVENT', + 'comp-filters' => [], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => [ + 'start' => $start, + 'end' => $end, + ], + ], + ], + 'prop-filters' => [], + 'is-not-defined' => false, + 'time-range' => null, + ]); + } catch (MaxInstancesExceededException $ex) { + $this->logger->warning('Caught max instances exceeded exception for calendar data. This usually indicates too much recurring (more than 3500) event in calendar data. Object uri: ' . $row['uri'], [ + 'app' => 'dav', + 'exception' => $ex, + ]); + continue; + } + + if (is_resource($row['calendardata'])) { + // Put the stream back to the beginning so it can be read another time + rewind($row['calendardata']); + } + + if ($isValid) { + $calendarObjects[] = $row; + } + } + + $result->closeCursor(); + + return $calendarObjects; } /** @@ -2077,115 +2273,136 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return array */ public function searchPrincipalUri(string $principalUri, - string $pattern, - array $componentTypes, - array $searchProperties, - array $searchParameters, - array $options = []): array { - $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; - - $calendarObjectIdQuery = $this->db->getQueryBuilder(); - $calendarOr = $calendarObjectIdQuery->expr()->orX(); - $searchOr = $calendarObjectIdQuery->expr()->orX(); + string $pattern, + array $componentTypes, + array $searchProperties, + array $searchParameters, + array $options = [], + ): array { + return $this->atomic(function () use ($principalUri, $pattern, $componentTypes, $searchProperties, $searchParameters, $options) { + $escapePattern = !\array_key_exists('escape_like_param', $options) || $options['escape_like_param'] !== false; + + $calendarObjectIdQuery = $this->db->getQueryBuilder(); + $calendarOr = []; + $searchOr = []; + + // Fetch calendars and subscription + $calendars = $this->getCalendarsForUser($principalUri); + $subscriptions = $this->getSubscriptionsForUser($principalUri); + foreach ($calendars as $calendar) { + $calendarAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$calendar['id'])), + $calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR)), + ); - // Fetch calendars and subscription - $calendars = $this->getCalendarsForUser($principalUri); - $subscriptions = $this->getSubscriptionsForUser($principalUri); - foreach ($calendars as $calendar) { - $calendarAnd = $calendarObjectIdQuery->expr()->andX(); - $calendarAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$calendar['id']))); - $calendarAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_CALENDAR))); + // If it's shared, limit search to public events + if (isset($calendar['{http://owncloud.org/ns}owner-principal']) + && $calendar['principaluri'] !== $calendar['{http://owncloud.org/ns}owner-principal']) { + $calendarAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + } - // If it's shared, limit search to public events - if (isset($calendar['{http://owncloud.org/ns}owner-principal']) - && $calendar['principaluri'] !== $calendar['{http://owncloud.org/ns}owner-principal']) { - $calendarAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + $calendarOr[] = $calendarAnd; } + foreach ($subscriptions as $subscription) { + $subscriptionAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$subscription['id'])), + $calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION)), + ); - $calendarOr->add($calendarAnd); - } - foreach ($subscriptions as $subscription) { - $subscriptionAnd = $calendarObjectIdQuery->expr()->andX(); - $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendarid', $calendarObjectIdQuery->createNamedParameter((int)$subscription['id']))); - $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('cob.calendartype', $calendarObjectIdQuery->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))); + // If it's shared, limit search to public events + if (isset($subscription['{http://owncloud.org/ns}owner-principal']) + && $subscription['principaluri'] !== $subscription['{http://owncloud.org/ns}owner-principal']) { + $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + } - // If it's shared, limit search to public events - if (isset($subscription['{http://owncloud.org/ns}owner-principal']) - && $subscription['principaluri'] !== $subscription['{http://owncloud.org/ns}owner-principal']) { - $subscriptionAnd->add($calendarObjectIdQuery->expr()->eq('co.classification', $calendarObjectIdQuery->createNamedParameter(self::CLASSIFICATION_PUBLIC))); + $calendarOr[] = $subscriptionAnd; } - $calendarOr->add($subscriptionAnd); - } - - foreach ($searchProperties as $property) { - $propertyAnd = $calendarObjectIdQuery->expr()->andX(); - $propertyAnd->add($calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR))); - $propertyAnd->add($calendarObjectIdQuery->expr()->isNull('cob.parameter')); - - $searchOr->add($propertyAnd); - } - foreach ($searchParameters as $property => $parameter) { - $parameterAnd = $calendarObjectIdQuery->expr()->andX(); - $parameterAnd->add($calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR))); - $parameterAnd->add($calendarObjectIdQuery->expr()->eq('cob.parameter', $calendarObjectIdQuery->createNamedParameter($parameter, IQueryBuilder::PARAM_STR_ARRAY))); - - $searchOr->add($parameterAnd); - } + foreach ($searchProperties as $property) { + $propertyAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)), + $calendarObjectIdQuery->expr()->isNull('cob.parameter'), + ); - if ($calendarOr->count() === 0) { - return []; - } - if ($searchOr->count() === 0) { - return []; - } + $searchOr[] = $propertyAnd; + } + foreach ($searchParameters as $property => $parameter) { + $parameterAnd = $calendarObjectIdQuery->expr()->andX( + $calendarObjectIdQuery->expr()->eq('cob.name', $calendarObjectIdQuery->createNamedParameter($property, IQueryBuilder::PARAM_STR)), + $calendarObjectIdQuery->expr()->eq('cob.parameter', $calendarObjectIdQuery->createNamedParameter($parameter, IQueryBuilder::PARAM_STR_ARRAY)), + ); - $calendarObjectIdQuery->selectDistinct('cob.objectid') - ->from($this->dbObjectPropertiesTable, 'cob') - ->leftJoin('cob', 'calendarobjects', 'co', $calendarObjectIdQuery->expr()->eq('co.id', 'cob.objectid')) - ->andWhere($calendarObjectIdQuery->expr()->in('co.componenttype', $calendarObjectIdQuery->createNamedParameter($componentTypes, IQueryBuilder::PARAM_STR_ARRAY))) - ->andWhere($calendarOr) - ->andWhere($searchOr) - ->andWhere($calendarObjectIdQuery->expr()->isNull('deleted_at')); + $searchOr[] = $parameterAnd; + } - if ('' !== $pattern) { - if (!$escapePattern) { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter($pattern))); - } else { - $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%'))); + if (empty($calendarOr)) { + return []; + } + if (empty($searchOr)) { + return []; } - } - if (isset($options['limit'])) { - $calendarObjectIdQuery->setMaxResults($options['limit']); - } - if (isset($options['offset'])) { - $calendarObjectIdQuery->setFirstResult($options['offset']); - } + $calendarObjectIdQuery->selectDistinct('cob.objectid') + ->from($this->dbObjectPropertiesTable, 'cob') + ->leftJoin('cob', 'calendarobjects', 'co', $calendarObjectIdQuery->expr()->eq('co.id', 'cob.objectid')) + ->andWhere($calendarObjectIdQuery->expr()->in('co.componenttype', $calendarObjectIdQuery->createNamedParameter($componentTypes, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($calendarObjectIdQuery->expr()->orX(...$calendarOr)) + ->andWhere($calendarObjectIdQuery->expr()->orX(...$searchOr)) + ->andWhere($calendarObjectIdQuery->expr()->isNull('deleted_at')); + + if ($pattern !== '') { + if (!$escapePattern) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter($pattern))); + } else { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->ilike('cob.value', $calendarObjectIdQuery->createNamedParameter('%' . $this->db->escapeLikeParameter($pattern) . '%'))); + } + } - $result = $calendarObjectIdQuery->executeQuery(); - $matches = $result->fetchAll(); - $result->closeCursor(); - $matches = array_map(static function (array $match):int { - return (int) $match['objectid']; - }, $matches); + if (isset($options['limit'])) { + $calendarObjectIdQuery->setMaxResults($options['limit']); + } + if (isset($options['offset'])) { + $calendarObjectIdQuery->setFirstResult($options['offset']); + } + if (isset($options['timerange'])) { + if (isset($options['timerange']['start']) && $options['timerange']['start'] instanceof DateTimeInterface) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->gt( + 'lastoccurence', + $calendarObjectIdQuery->createNamedParameter($options['timerange']['start']->getTimeStamp()), + )); + } + if (isset($options['timerange']['end']) && $options['timerange']['end'] instanceof DateTimeInterface) { + $calendarObjectIdQuery->andWhere($calendarObjectIdQuery->expr()->lt( + 'firstoccurence', + $calendarObjectIdQuery->createNamedParameter($options['timerange']['end']->getTimeStamp()), + )); + } + } - $query = $this->db->getQueryBuilder(); - $query->select('calendardata', 'uri', 'calendarid', 'calendartype') - ->from('calendarobjects') - ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); + $result = $calendarObjectIdQuery->executeQuery(); + $matches = []; + while (($row = $result->fetch()) !== false) { + $matches[] = (int)$row['objectid']; + } + $result->closeCursor(); - $result = $query->executeQuery(); - $calendarObjects = $result->fetchAll(); - $result->closeCursor(); + $query = $this->db->getQueryBuilder(); + $query->select('calendardata', 'uri', 'calendarid', 'calendartype') + ->from('calendarobjects') + ->where($query->expr()->in('id', $query->createNamedParameter($matches, IQueryBuilder::PARAM_INT_ARRAY))); - return array_map(function (array $array): array { - $array['calendarid'] = (int)$array['calendarid']; - $array['calendartype'] = (int)$array['calendartype']; - $array['calendardata'] = $this->readBlob($array['calendardata']); + $result = $query->executeQuery(); + $calendarObjects = []; + while (($array = $result->fetch()) !== false) { + $array['calendarid'] = (int)$array['calendarid']; + $array['calendartype'] = (int)$array['calendartype']; + $array['calendardata'] = $this->readBlob($array['calendardata']); - return $array; - }, $calendarObjects); + $calendarObjects[] = $array; + } + $result->closeCursor(); + return $calendarObjects; + }, $this->db); } /** @@ -2252,7 +2469,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'calendardata' => $this->readBlob($row['calendardata']), 'component' => strtolower($row['componenttype']), 'classification' => (int)$row['classification'], - 'deleted_at' => isset($row['deleted_at']) ? ((int) $row['deleted_at']) : null, + 'deleted_at' => isset($row['deleted_at']) ? ((int)$row['deleted_at']) : null, ]; } @@ -2311,87 +2528,74 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $syncLevel * @param int|null $limit * @param int $calendarType - * @return array + * @return ?array */ public function getChangesForCalendar($calendarId, $syncToken, $syncLevel, $limit = null, $calendarType = self::CALENDAR_TYPE_CALENDAR) { - // Current synctoken - $qb = $this->db->getQueryBuilder(); - $qb->select('synctoken') - ->from('calendars') - ->where( - $qb->expr()->eq('id', $qb->createNamedParameter($calendarId)) - ); - $stmt = $qb->executeQuery(); - $currentToken = $stmt->fetchOne(); - - if ($currentToken === false) { - return null; - } - - $result = [ - 'syncToken' => $currentToken, - 'added' => [], - 'modified' => [], - 'deleted' => [], - ]; + $table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions'; - if ($syncToken) { + return $this->atomic(function () use ($calendarId, $syncToken, $syncLevel, $limit, $calendarType, $table) { + // Current synctoken $qb = $this->db->getQueryBuilder(); - - $qb->select('uri', 'operation') - ->from('calendarchanges') + $qb->select('synctoken') + ->from($table) ->where( - $qb->expr()->andX( - $qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken)), - $qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken)), - $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), - $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)) - ) - )->orderBy('synctoken'); - if (is_int($limit) && $limit > 0) { - $qb->setMaxResults($limit); - } - - // Fetching all changes + $qb->expr()->eq('id', $qb->createNamedParameter($calendarId)) + ); $stmt = $qb->executeQuery(); - $changes = []; + $currentToken = $stmt->fetchOne(); + $initialSync = !is_numeric($syncToken); - // This loop ensures that any duplicates are overwritten, only the - // last change on a node is relevant. - while ($row = $stmt->fetch()) { - $changes[$row['uri']] = $row['operation']; + if ($currentToken === false) { + return null; } - $stmt->closeCursor(); - foreach ($changes as $uri => $operation) { - switch ($operation) { - case 1: - $result['added'][] = $uri; - break; - case 2: - $result['modified'][] = $uri; - break; - case 3: - $result['deleted'][] = $uri; - break; - } + // evaluate if this is a initial sync and construct appropriate command + if ($initialSync) { + $qb = $this->db->getQueryBuilder(); + $qb->select('uri') + ->from('calendarobjects') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->isNull('deleted_at')); + } else { + $qb = $this->db->getQueryBuilder(); + $qb->select('uri', $qb->func()->max('operation')) + ->from('calendarchanges') + ->where($qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId))) + ->andWhere($qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType))) + ->andWhere($qb->expr()->gte('synctoken', $qb->createNamedParameter($syncToken))) + ->andWhere($qb->expr()->lt('synctoken', $qb->createNamedParameter($currentToken))) + ->groupBy('uri'); } - } else { - // No synctoken supplied, this is the initial sync. - $qb = $this->db->getQueryBuilder(); - $qb->select('uri') - ->from('calendarobjects') - ->where( - $qb->expr()->andX( - $qb->expr()->eq('calendarid', $qb->createNamedParameter($calendarId)), - $qb->expr()->eq('calendartype', $qb->createNamedParameter($calendarType)) - ) - ); + // evaluate if limit exists + if (is_numeric($limit)) { + $qb->setMaxResults($limit); + } + // execute command $stmt = $qb->executeQuery(); - $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); + // build results + $result = ['syncToken' => $currentToken, 'added' => [], 'modified' => [], 'deleted' => []]; + // retrieve results + if ($initialSync) { + $result['added'] = $stmt->fetchAll(\PDO::FETCH_COLUMN); + } else { + // \PDO::FETCH_NUM is needed due to the inconsistent field names + // produced by doctrine for MAX() with different databases + while ($entry = $stmt->fetch(\PDO::FETCH_NUM)) { + // assign uri (column 0) to appropriate mutation based on operation (column 1) + // forced (int) is needed as doctrine with OCI returns the operation field as string not integer + match ((int)$entry[1]) { + 1 => $result['added'][] = $entry[0], + 2 => $result['modified'][] = $entry[0], + 3 => $result['deleted'][] = $entry[0], + default => $this->logger->debug('Unknown calendar change operation detected') + }; + } + } $stmt->closeCursor(); - } - return $result; + + return $result; + }, $this->db); } /** @@ -2452,7 +2656,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'lastmodified' => $row['lastmodified'], '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VTODO', 'VEVENT']), - '{http://sabredav.org/ns}sync-token' => $row['synctoken']?$row['synctoken']:'0', + '{http://sabredav.org/ns}sync-token' => $row['synctoken'] ?: '0', ]; $subscriptions[] = $this->rowToSubscription($row, $subscription); @@ -2495,28 +2699,23 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } - $valuesToInsert = []; - - $query = $this->db->getQueryBuilder(); - - foreach (array_keys($values) as $name) { - $valuesToInsert[$name] = $query->createNamedParameter($values[$name]); - } + [$subscriptionId, $subscriptionRow] = $this->atomic(function () use ($values) { + $valuesToInsert = []; + $query = $this->db->getQueryBuilder(); + foreach (array_keys($values) as $name) { + $valuesToInsert[$name] = $query->createNamedParameter($values[$name]); + } + $query->insert('calendarsubscriptions') + ->values($valuesToInsert) + ->executeStatement(); - $query->insert('calendarsubscriptions') - ->values($valuesToInsert) - ->executeStatement(); + $subscriptionId = $query->getLastInsertId(); - $subscriptionId = $query->getLastInsertId(); + $subscriptionRow = $this->getSubscriptionById($subscriptionId); + return [$subscriptionId, $subscriptionRow]; + }, $this->db); - $subscriptionRow = $this->getSubscriptionById($subscriptionId); $this->dispatcher->dispatchTyped(new SubscriptionCreatedEvent($subscriptionId, $subscriptionRow)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::createSubscription', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::createSubscription', - [ - 'subscriptionId' => $subscriptionId, - 'subscriptionData' => $subscriptionRow, - ])); return $subscriptionId; } @@ -2553,24 +2752,20 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } } - $query = $this->db->getQueryBuilder(); - $query->update('calendarsubscriptions') - ->set('lastmodified', $query->createNamedParameter(time())); - foreach ($newValues as $fieldName => $value) { - $query->set($fieldName, $query->createNamedParameter($value)); - } - $query->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) - ->executeStatement(); + $subscriptionRow = $this->atomic(function () use ($subscriptionId, $newValues) { + $query = $this->db->getQueryBuilder(); + $query->update('calendarsubscriptions') + ->set('lastmodified', $query->createNamedParameter(time())); + foreach ($newValues as $fieldName => $value) { + $query->set($fieldName, $query->createNamedParameter($value)); + } + $query->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) + ->executeStatement(); + + return $this->getSubscriptionById($subscriptionId); + }, $this->db); - $subscriptionRow = $this->getSubscriptionById($subscriptionId); $this->dispatcher->dispatchTyped(new SubscriptionUpdatedEvent((int)$subscriptionId, $subscriptionRow, [], $mutations)); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateSubscription', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateSubscription', - [ - 'subscriptionId' => $subscriptionId, - 'subscriptionData' => $subscriptionRow, - 'propertyMutations' => $mutations, - ])); return true; }); @@ -2583,39 +2778,34 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteSubscription($subscriptionId) { - $subscriptionRow = $this->getSubscriptionById($subscriptionId); - - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::deleteSubscription', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::deleteSubscription', - [ - 'subscriptionId' => $subscriptionId, - 'subscriptionData' => $this->getSubscriptionById($subscriptionId), - ])); + $this->atomic(function () use ($subscriptionId): void { + $subscriptionRow = $this->getSubscriptionById($subscriptionId); - $query = $this->db->getQueryBuilder(); - $query->delete('calendarsubscriptions') - ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarsubscriptions') + ->where($query->expr()->eq('id', $query->createNamedParameter($subscriptionId))) + ->executeStatement(); - $query = $this->db->getQueryBuilder(); - $query->delete('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete('calendarchanges') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query->delete('calendarchanges') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete($this->dbObjectPropertiesTable) - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - if ($subscriptionRow) { - $this->dispatcher->dispatchTyped(new SubscriptionDeletedEvent((int)$subscriptionId, $subscriptionRow, [])); - } + if ($subscriptionRow) { + $this->dispatcher->dispatchTyped(new SubscriptionDeletedEvent((int)$subscriptionId, $subscriptionRow, [])); + } + }, $this->db); } /** @@ -2671,13 +2861,13 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription public function getSchedulingObjects($principalUri) { $query = $this->db->getQueryBuilder(); $stmt = $query->select(['uri', 'calendardata', 'lastmodified', 'etag', 'size']) - ->from('schedulingobjects') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) - ->executeQuery(); + ->from('schedulingobjects') + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->executeQuery(); - $result = []; - foreach ($stmt->fetchAll() as $row) { - $result[] = [ + $results = []; + while (($row = $stmt->fetch()) !== false) { + $results[] = [ 'calendardata' => $row['calendardata'], 'uri' => $row['uri'], 'lastmodified' => $row['lastmodified'], @@ -2687,7 +2877,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } $stmt->closeCursor(); - return $result; + return $results; } /** @@ -2698,11 +2888,50 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function deleteSchedulingObject($principalUri, $objectUri) { + $this->cachedObjects = []; $query = $this->db->getQueryBuilder(); $query->delete('schedulingobjects') - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) - ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) - ->executeStatement(); + ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->andWhere($query->expr()->eq('uri', $query->createNamedParameter($objectUri))) + ->executeStatement(); + } + + /** + * Deletes all scheduling objects last modified before $modifiedBefore from the inbox collection. + * + * @param int $modifiedBefore + * @param int $limit + * @return void + */ + public function deleteOutdatedSchedulingObjects(int $modifiedBefore, int $limit): void { + $query = $this->db->getQueryBuilder(); + $query->select('id') + ->from('schedulingobjects') + ->where($query->expr()->lt('lastmodified', $query->createNamedParameter($modifiedBefore))) + ->setMaxResults($limit); + $result = $query->executeQuery(); + $count = $result->rowCount(); + if ($count === 0) { + return; + } + $ids = array_map(static function (array $id) { + return (int)$id[0]; + }, $result->fetchAll(\PDO::FETCH_NUM)); + $result->closeCursor(); + + $numDeleted = 0; + $deleteQuery = $this->db->getQueryBuilder(); + $deleteQuery->delete('schedulingobjects') + ->where($deleteQuery->expr()->in('id', $deleteQuery->createParameter('ids'), IQueryBuilder::PARAM_INT_ARRAY)); + foreach (array_chunk($ids, 1000) as $chunk) { + $deleteQuery->setParameter('ids', $chunk, IQueryBuilder::PARAM_INT_ARRAY); + $numDeleted += $deleteQuery->executeStatement(); + } + + if ($numDeleted === $limit) { + $this->logger->info("Deleted $limit scheduling objects, continuing with next batch"); + $this->deleteOutdatedSchedulingObjects($modifiedBefore, $limit); + } } /** @@ -2714,6 +2943,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @return void */ public function createSchedulingObject($principalUri, $objectUri, $objectData) { + $this->cachedObjects = []; $query = $this->db->getQueryBuilder(); $query->insert('schedulingobjects') ->values([ @@ -2731,37 +2961,86 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * Adds a change record to the calendarchanges table. * * @param mixed $calendarId - * @param string $objectUri + * @param string[] $objectUris * @param int $operation 1 = add, 2 = modify, 3 = delete. * @param int $calendarType * @return void */ - protected function addChange($calendarId, $objectUri, $operation, $calendarType = self::CALENDAR_TYPE_CALENDAR) { + protected function addChanges(int $calendarId, array $objectUris, int $operation, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void { + $this->cachedObjects = []; $table = $calendarType === self::CALENDAR_TYPE_CALENDAR ? 'calendars': 'calendarsubscriptions'; - $query = $this->db->getQueryBuilder(); - $query->select('synctoken') - ->from($table) - ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); - $result = $query->executeQuery(); - $syncToken = (int)$result->fetchOne(); - $result->closeCursor(); + $this->atomic(function () use ($calendarId, $objectUris, $operation, $calendarType, $table): void { + $query = $this->db->getQueryBuilder(); + $query->select('synctoken') + ->from($table) + ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))); + $result = $query->executeQuery(); + $syncToken = (int)$result->fetchOne(); + $result->closeCursor(); - $query = $this->db->getQueryBuilder(); - $query->insert('calendarchanges') - ->values([ - 'uri' => $query->createNamedParameter($objectUri), - 'synctoken' => $query->createNamedParameter($syncToken), - 'calendarid' => $query->createNamedParameter($calendarId), - 'operation' => $query->createNamedParameter($operation), - 'calendartype' => $query->createNamedParameter($calendarType), - ]) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->insert('calendarchanges') + ->values([ + 'uri' => $query->createParameter('uri'), + 'synctoken' => $query->createNamedParameter($syncToken), + 'calendarid' => $query->createNamedParameter($calendarId), + 'operation' => $query->createNamedParameter($operation), + 'calendartype' => $query->createNamedParameter($calendarType), + 'created_at' => $query->createNamedParameter(time()), + ]); + foreach ($objectUris as $uri) { + $query->setParameter('uri', $uri); + $query->executeStatement(); + } - $stmt = $this->db->prepare("UPDATE `*PREFIX*$table` SET `synctoken` = `synctoken` + 1 WHERE `id` = ?"); - $stmt->execute([ - $calendarId - ]); + $query = $this->db->getQueryBuilder(); + $query->update($table) + ->set('synctoken', $query->createNamedParameter($syncToken + 1, IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('id', $query->createNamedParameter($calendarId))) + ->executeStatement(); + }, $this->db); + } + + public function restoreChanges(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR): void { + $this->cachedObjects = []; + + $this->atomic(function () use ($calendarId, $calendarType): void { + $qbAdded = $this->db->getQueryBuilder(); + $qbAdded->select('uri') + ->from('calendarobjects') + ->where( + $qbAdded->expr()->andX( + $qbAdded->expr()->eq('calendarid', $qbAdded->createNamedParameter($calendarId)), + $qbAdded->expr()->eq('calendartype', $qbAdded->createNamedParameter($calendarType)), + $qbAdded->expr()->isNull('deleted_at'), + ) + ); + $resultAdded = $qbAdded->executeQuery(); + $addedUris = $resultAdded->fetchAll(\PDO::FETCH_COLUMN); + $resultAdded->closeCursor(); + // Track everything as changed + // Tracking the creation is not necessary because \OCA\DAV\CalDAV\CalDavBackend::getChangesForCalendar + // only returns the last change per object. + $this->addChanges($calendarId, $addedUris, 2, $calendarType); + + $qbDeleted = $this->db->getQueryBuilder(); + $qbDeleted->select('uri') + ->from('calendarobjects') + ->where( + $qbDeleted->expr()->andX( + $qbDeleted->expr()->eq('calendarid', $qbDeleted->createNamedParameter($calendarId)), + $qbDeleted->expr()->eq('calendartype', $qbDeleted->createNamedParameter($calendarType)), + $qbDeleted->expr()->isNotNull('deleted_at'), + ) + ); + $resultDeleted = $qbDeleted->executeQuery(); + $deletedUris = array_map(function (string $uri) { + return str_replace('-deleted.ics', '.ics', $uri); + }, $resultDeleted->fetchAll(\PDO::FETCH_COLUMN)); + $resultDeleted->closeCursor(); + $this->addChanges($calendarId, $deletedUris, 3, $calendarType); + }, $this->db); } /** @@ -2779,7 +3058,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param string $calendarData * @return array */ - public function getDenormalizedData($calendarData) { + public function getDenormalizedData(string $calendarData): array { $vObject = Reader::read($calendarData); $vEvents = []; $componentType = null; @@ -2793,7 +3072,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription if ($component->name !== 'VTIMEZONE') { // Finding all VEVENTs, and track them if ($component->name === 'VEVENT') { - array_push($vEvents, $component); + $vEvents[] = $component; if ($component->DTSTART) { $hasDTSTART = true; } @@ -2829,7 +3108,15 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription $lastOccurrence = $firstOccurrence; } } else { - $it = new EventIterator($vEvents); + try { + $it = new EventIterator($vEvents); + } catch (NoInstancesException $e) { + $this->logger->debug('Caught no instance exception for calendar data. This usually indicates invalid calendar data.', [ + 'app' => 'dav', + 'exception' => $e, + ]); + throw new Forbidden($e->getMessage()); + } $maxDate = new DateTime(self::MAX_DATE); $firstOccurrence = $it->getDtStart()->getTimestamp(); if ($it->isInfinite()) { @@ -2861,7 +3148,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription 'size' => strlen($calendarData), 'componentType' => $componentType, 'firstOccurence' => is_null($firstOccurrence) ? null : max(0, $firstOccurrence), - 'lastOccurence' => $lastOccurrence, + 'lastOccurence' => is_null($lastOccurrence) ? null : max(0, $lastOccurrence), 'uid' => $uid, 'classification' => $classification ]; @@ -2880,80 +3167,73 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** - * @param IShareable $shareable - * @param array $add - * @param array $remove + * @param list<array{href: string, commonName: string, readOnly: bool}> $add + * @param list<string> $remove */ - public function updateShares($shareable, $add, $remove) { - $calendarId = $shareable->getResourceId(); - $calendarRow = $this->getCalendarById($calendarId); - $oldShares = $this->getShares($calendarId); + public function updateShares(IShareable $shareable, array $add, array $remove): void { + $this->atomic(function () use ($shareable, $add, $remove): void { + $calendarId = $shareable->getResourceId(); + $calendarRow = $this->getCalendarById($calendarId); + if ($calendarRow === null) { + throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $calendarId); + } + $oldShares = $this->getShares($calendarId); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::updateShares', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateShares', - [ - 'calendarId' => $calendarId, - 'calendarData' => $calendarRow, - 'shares' => $oldShares, - 'add' => $add, - 'remove' => $remove, - ])); - $this->calendarSharingBackend->updateShares($shareable, $add, $remove); + $this->calendarSharingBackend->updateShares($shareable, $add, $remove, $oldShares); - $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent((int)$calendarId, $calendarRow, $oldShares, $add, $remove)); + $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent($calendarId, $calendarRow, $oldShares, $add, $remove)); + }, $this->db); } /** - * @param int $resourceId - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares($resourceId) { + public function getShares(int $resourceId): array { return $this->calendarSharingBackend->getShares($resourceId); } + public function preloadShares(array $resourceIds): void { + $this->calendarSharingBackend->preloadShares($resourceIds); + } + /** * @param boolean $value - * @param \OCA\DAV\CalDAV\Calendar $calendar + * @param Calendar $calendar * @return string|null */ public function setPublishStatus($value, $calendar) { - $calendarId = $calendar->getResourceId(); - $calendarData = $this->getCalendarById($calendarId); - $this->legacyDispatcher->dispatch('\OCA\DAV\CalDAV\CalDavBackend::publishCalendar', new GenericEvent( - '\OCA\DAV\CalDAV\CalDavBackend::updateShares', - [ - 'calendarId' => $calendarId, - 'calendarData' => $calendarData, - 'public' => $value, - ])); + return $this->atomic(function () use ($value, $calendar) { + $calendarId = $calendar->getResourceId(); + $calendarData = $this->getCalendarById($calendarId); - $query = $this->db->getQueryBuilder(); - if ($value) { - $publicUri = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE); - $query->insert('dav_shares') - ->values([ - 'principaluri' => $query->createNamedParameter($calendar->getPrincipalURI()), - 'type' => $query->createNamedParameter('calendar'), - 'access' => $query->createNamedParameter(self::ACCESS_PUBLIC), - 'resourceid' => $query->createNamedParameter($calendar->getResourceId()), - 'publicuri' => $query->createNamedParameter($publicUri) - ]); - $query->executeStatement(); + $query = $this->db->getQueryBuilder(); + if ($value) { + $publicUri = $this->random->generate(16, ISecureRandom::CHAR_HUMAN_READABLE); + $query->insert('dav_shares') + ->values([ + 'principaluri' => $query->createNamedParameter($calendar->getPrincipalURI()), + 'type' => $query->createNamedParameter('calendar'), + 'access' => $query->createNamedParameter(self::ACCESS_PUBLIC), + 'resourceid' => $query->createNamedParameter($calendar->getResourceId()), + 'publicuri' => $query->createNamedParameter($publicUri) + ]); + $query->executeStatement(); - $this->dispatcher->dispatchTyped(new CalendarPublishedEvent((int)$calendarId, $calendarData, $publicUri)); - return $publicUri; - } - $query->delete('dav_shares') - ->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId()))) - ->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC))); - $query->executeStatement(); + $this->dispatcher->dispatchTyped(new CalendarPublishedEvent($calendarId, $calendarData, $publicUri)); + return $publicUri; + } + $query->delete('dav_shares') + ->where($query->expr()->eq('resourceid', $query->createNamedParameter($calendar->getResourceId()))) + ->andWhere($query->expr()->eq('access', $query->createNamedParameter(self::ACCESS_PUBLIC))); + $query->executeStatement(); - $this->dispatcher->dispatchTyped(new CalendarUnpublishedEvent((int)$calendarId, $calendarData)); - return null; + $this->dispatcher->dispatchTyped(new CalendarUnpublishedEvent($calendarId, $calendarData)); + return null; + }, $this->db); } /** - * @param \OCA\DAV\CalDAV\Calendar $calendar + * @param Calendar $calendar * @return mixed */ public function getPublishStatus($calendar) { @@ -2971,15 +3251,14 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription /** * @param int $resourceId - * @param array $acl - * @return array + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @return list<array{privilege: string, principal: string, protected: bool}> */ - public function applyShareAcl($resourceId, $acl) { - return $this->calendarSharingBackend->applyShareAcl($resourceId, $acl); + public function applyShareAcl(int $resourceId, array $acl): array { + $shares = $this->calendarSharingBackend->getShares($resourceId); + return $this->calendarSharingBackend->applyShareAcl($shares, $acl); } - - /** * update properties table * @@ -2989,127 +3268,172 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $calendarType */ public function updateProperties($calendarId, $objectUri, $calendarData, $calendarType = self::CALENDAR_TYPE_CALENDAR) { - $objectId = $this->getCalendarObjectId($calendarId, $objectUri, $calendarType); + $this->cachedObjects = []; + $this->atomic(function () use ($calendarId, $objectUri, $calendarData, $calendarType): void { + $objectId = $this->getCalendarObjectId($calendarId, $objectUri, $calendarType); + + try { + $vCalendar = $this->readCalendarData($calendarData); + } catch (\Exception $ex) { + return; + } - try { - $vCalendar = $this->readCalendarData($calendarData); - } catch (\Exception $ex) { - return; - } + $this->purgeProperties($calendarId, $objectId); - $this->purgeProperties($calendarId, $objectId); + $query = $this->db->getQueryBuilder(); + $query->insert($this->dbObjectPropertiesTable) + ->values( + [ + 'calendarid' => $query->createNamedParameter($calendarId), + 'calendartype' => $query->createNamedParameter($calendarType), + 'objectid' => $query->createNamedParameter($objectId), + 'name' => $query->createParameter('name'), + 'parameter' => $query->createParameter('parameter'), + 'value' => $query->createParameter('value'), + ] + ); - $query = $this->db->getQueryBuilder(); - $query->insert($this->dbObjectPropertiesTable) - ->values( - [ - 'calendarid' => $query->createNamedParameter($calendarId), - 'calendartype' => $query->createNamedParameter($calendarType), - 'objectid' => $query->createNamedParameter($objectId), - 'name' => $query->createParameter('name'), - 'parameter' => $query->createParameter('parameter'), - 'value' => $query->createParameter('value'), - ] - ); + $indexComponents = ['VEVENT', 'VJOURNAL', 'VTODO']; + foreach ($vCalendar->getComponents() as $component) { + if (!in_array($component->name, $indexComponents)) { + continue; + } - $indexComponents = ['VEVENT', 'VJOURNAL', 'VTODO']; - foreach ($vCalendar->getComponents() as $component) { - if (!in_array($component->name, $indexComponents)) { - continue; - } + foreach ($component->children() as $property) { + if (in_array($property->name, self::INDEXED_PROPERTIES, true)) { + $value = $property->getValue(); + // is this a shitty db? + if (!$this->db->supports4ByteText()) { + $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + } + $value = mb_strcut($value, 0, 254); - foreach ($component->children() as $property) { - if (in_array($property->name, self::INDEXED_PROPERTIES, true)) { - $value = $property->getValue(); - // is this a shitty db? - if (!$this->db->supports4ByteText()) { - $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + $query->setParameter('name', $property->name); + $query->setParameter('parameter', null); + $query->setParameter('value', mb_strcut($value, 0, 254)); + $query->executeStatement(); } - $value = mb_strcut($value, 0, 254); - - $query->setParameter('name', $property->name); - $query->setParameter('parameter', null); - $query->setParameter('value', $value); - $query->executeStatement(); - } - - if (array_key_exists($property->name, self::$indexParameters)) { - $parameters = $property->parameters(); - $indexedParametersForProperty = self::$indexParameters[$property->name]; - foreach ($parameters as $key => $value) { - if (in_array($key, $indexedParametersForProperty)) { - // is this a shitty db? - if ($this->db->supports4ByteText()) { - $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + if (array_key_exists($property->name, self::$indexParameters)) { + $parameters = $property->parameters(); + $indexedParametersForProperty = self::$indexParameters[$property->name]; + + foreach ($parameters as $key => $value) { + if (in_array($key, $indexedParametersForProperty)) { + // is this a shitty db? + if ($this->db->supports4ByteText()) { + $value = preg_replace('/[\x{10000}-\x{10FFFF}]/u', "\xEF\xBF\xBD", $value); + } + + $query->setParameter('name', $property->name); + $query->setParameter('parameter', mb_strcut($key, 0, 254)); + $query->setParameter('value', mb_strcut($value, 0, 254)); + $query->executeStatement(); } - - $query->setParameter('name', $property->name); - $query->setParameter('parameter', mb_strcut($key, 0, 254)); - $query->setParameter('value', mb_strcut($value, 0, 254)); - $query->executeStatement(); } } } } - } + }, $this->db); } /** * deletes all birthday calendars */ public function deleteAllBirthdayCalendars() { - $query = $this->db->getQueryBuilder(); - $result = $query->select(['id'])->from('calendars') - ->where($query->expr()->eq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI))) - ->executeQuery(); + $this->atomic(function (): void { + $query = $this->db->getQueryBuilder(); + $result = $query->select(['id'])->from('calendars') + ->where($query->expr()->eq('uri', $query->createNamedParameter(BirthdayService::BIRTHDAY_CALENDAR_URI))) + ->executeQuery(); - $ids = $result->fetchAll(); - $result->closeCursor(); - foreach ($ids as $id) { - $this->deleteCalendar( - $id['id'], - true // No data to keep in the trashbin, if the user re-enables then we regenerate - ); - } + while (($row = $result->fetch()) !== false) { + $this->deleteCalendar( + $row['id'], + true // No data to keep in the trashbin, if the user re-enables then we regenerate + ); + } + $result->closeCursor(); + }, $this->db); } /** * @param $subscriptionId */ public function purgeAllCachedEventsForSubscription($subscriptionId) { - $query = $this->db->getQueryBuilder(); - $query->select('uri') - ->from('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))); - $stmt = $query->executeQuery(); + $this->atomic(function () use ($subscriptionId): void { + $query = $this->db->getQueryBuilder(); + $query->select('uri') + ->from('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))); + $stmt = $query->executeQuery(); - $uris = []; - foreach ($stmt->fetchAll() as $row) { - $uris[] = $row['uri']; - } - $stmt->closeCursor(); + $uris = []; + while (($row = $stmt->fetch()) !== false) { + $uris[] = $row['uri']; + } + $stmt->closeCursor(); - $query = $this->db->getQueryBuilder(); - $query->delete('calendarobjects') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete('calendarchanges') - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete('calendarchanges') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); - $query->delete($this->dbObjectPropertiesTable) - ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) - ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) - ->executeStatement(); + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->executeStatement(); + + $this->addChanges($subscriptionId, $uris, 3, self::CALENDAR_TYPE_SUBSCRIPTION); + }, $this->db); + } - foreach ($uris as $uri) { - $this->addChange($subscriptionId, $uri, 3, self::CALENDAR_TYPE_SUBSCRIPTION); + /** + * @param int $subscriptionId + * @param array<int> $calendarObjectIds + * @param array<string> $calendarObjectUris + */ + public function purgeCachedEventsForSubscription(int $subscriptionId, array $calendarObjectIds, array $calendarObjectUris): void { + if (empty($calendarObjectUris)) { + return; } + + $this->atomic(function () use ($subscriptionId, $calendarObjectIds, $calendarObjectUris): void { + foreach (array_chunk($calendarObjectIds, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->delete($this->dbObjectPropertiesTable) + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY)) + ->executeStatement(); + + $query = $this->db->getQueryBuilder(); + $query->delete('calendarobjects') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->andWhere($query->expr()->in('id', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY), IQueryBuilder::PARAM_INT_ARRAY)) + ->executeStatement(); + } + + foreach (array_chunk($calendarObjectUris, 1000) as $chunk) { + $query = $this->db->getQueryBuilder(); + $query->delete('calendarchanges') + ->where($query->expr()->eq('calendarid', $query->createNamedParameter($subscriptionId))) + ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter(self::CALENDAR_TYPE_SUBSCRIPTION))) + ->andWhere($query->expr()->in('uri', $query->createNamedParameter($chunk, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->executeStatement(); + } + $this->addChanges($subscriptionId, $calendarObjectUris, 3, self::CALENDAR_TYPE_SUBSCRIPTION); + }, $this->db); } /** @@ -3147,6 +3471,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription * @param int $objectId */ protected function purgeProperties($calendarId, $objectId) { + $this->cachedObjects = []; $query = $this->db->getQueryBuilder(); $query->delete($this->dbObjectPropertiesTable) ->where($query->expr()->eq('objectid', $query->createNamedParameter($objectId))) @@ -3182,6 +3507,34 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } /** + * @throws \InvalidArgumentException + */ + public function pruneOutdatedSyncTokens(int $keep, int $retention): int { + if ($keep < 0) { + throw new \InvalidArgumentException(); + } + + $query = $this->db->getQueryBuilder(); + $query->select($query->func()->max('id')) + ->from('calendarchanges'); + + $result = $query->executeQuery(); + $maxId = (int)$result->fetchOne(); + $result->closeCursor(); + if (!$maxId || $maxId < $keep) { + return 0; + } + + $query = $this->db->getQueryBuilder(); + $query->delete('calendarchanges') + ->where( + $query->expr()->lte('id', $query->createNamedParameter($maxId - $keep, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT), + $query->expr()->lte('created_at', $query->createNamedParameter($retention)), + ); + return $query->executeStatement(); + } + + /** * return legacy endpoint principal name to new principal name * * @param $principalUri @@ -3270,4 +3623,68 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription } return $subscription; } + + /** + * delete all invitations from a given calendar + * + * @since 31.0.0 + * + * @param int $calendarId + * + * @return void + */ + protected function purgeCalendarInvitations(int $calendarId): void { + // select all calendar object uid's + $cmd = $this->db->getQueryBuilder(); + $cmd->select('uid') + ->from($this->dbObjectsTable) + ->where($cmd->expr()->eq('calendarid', $cmd->createNamedParameter($calendarId))); + $allIds = $cmd->executeQuery()->fetchAll(\PDO::FETCH_COLUMN); + // delete all links that match object uid's + $cmd = $this->db->getQueryBuilder(); + $cmd->delete($this->dbObjectInvitationsTable) + ->where($cmd->expr()->in('uid', $cmd->createParameter('uids'), IQueryBuilder::PARAM_STR_ARRAY)); + foreach (array_chunk($allIds, 1000) as $chunkIds) { + $cmd->setParameter('uids', $chunkIds, IQueryBuilder::PARAM_STR_ARRAY); + $cmd->executeStatement(); + } + } + + /** + * Delete all invitations from a given calendar event + * + * @since 31.0.0 + * + * @param string $eventId UID of the event + * + * @return void + */ + protected function purgeObjectInvitations(string $eventId): void { + $cmd = $this->db->getQueryBuilder(); + $cmd->delete($this->dbObjectInvitationsTable) + ->where($cmd->expr()->eq('uid', $cmd->createNamedParameter($eventId, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)); + $cmd->executeStatement(); + } + + public function unshare(IShareable $shareable, string $principal): void { + $this->atomic(function () use ($shareable, $principal): void { + $calendarData = $this->getCalendarById($shareable->getResourceId()); + if ($calendarData === null) { + throw new \RuntimeException('Trying to update shares for non-existing calendar: ' . $shareable->getResourceId()); + } + + $oldShares = $this->getShares($shareable->getResourceId()); + $unshare = $this->calendarSharingBackend->unshare($shareable, $principal); + + if ($unshare) { + $this->dispatcher->dispatchTyped(new CalendarShareUpdatedEvent( + $shareable->getResourceId(), + $calendarData, + $oldShares, + [], + [$principal] + )); + } + }, $this->db); + } } diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php index 75c815c3b0a..deb00caa93d 100644 --- a/apps/dav/lib/CalDAV/Calendar.php +++ b/apps/dav/lib/CalDAV/Calendar.php @@ -1,30 +1,10 @@ <?php + + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Gary Kim <gary@garykim.dev> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -51,28 +31,16 @@ use Sabre\DAV\PropPatch; * @property CalDavBackend $caldavBackend */ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable, IMoveTarget { - - /** @var IConfig */ - private $config; - - /** @var IL10N */ - protected $l10n; - - /** @var bool */ - private $useTrashbin = true; - - /** @var LoggerInterface */ - private $logger; - - /** - * Calendar constructor. - * - * @param BackendInterface $caldavBackend - * @param $calendarInfo - * @param IL10N $l10n - * @param IConfig $config - */ - public function __construct(BackendInterface $caldavBackend, $calendarInfo, IL10N $l10n, IConfig $config, LoggerInterface $logger) { + protected IL10N $l10n; + private bool $useTrashbin = true; + + public function __construct( + BackendInterface $caldavBackend, + array $calendarInfo, + IL10N $l10n, + private IConfig $config, + private LoggerInterface $logger, + ) { // Convert deletion date to ISO8601 string if (isset($calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT])) { $calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT] = (new DateTimeImmutable()) @@ -82,39 +50,25 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable parent::__construct($caldavBackend, $calendarInfo); - if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI) { + if ($this->getName() === BirthdayService::BIRTHDAY_CALENDAR_URI && strcasecmp($this->calendarInfo['{DAV:}displayname'], 'Contact birthdays') === 0) { $this->calendarInfo['{DAV:}displayname'] = $l10n->t('Contact birthdays'); } - if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI && - $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { + if ($this->getName() === CalDavBackend::PERSONAL_CALENDAR_URI + && $this->calendarInfo['{DAV:}displayname'] === CalDavBackend::PERSONAL_CALENDAR_NAME) { $this->calendarInfo['{DAV:}displayname'] = $l10n->t('Personal'); } - - $this->config = $config; $this->l10n = $l10n; - $this->logger = $logger; + } + + public function getUri(): string { + return $this->calendarInfo['uri']; } /** - * Updates the list of shares. - * - * The first array is a list of people that are to be added to the - * resource. - * - * Every element in the add array has the following properties: - * * href - A url. Usually a mailto: address - * * commonName - Usually a first and last name, or false - * * summary - A description of the share, can also be false - * * readOnly - A boolean value - * - * Every element in the remove array is just the address string. - * - * @param array $add - * @param array $remove - * @return void + * {@inheritdoc} * @throws Forbidden */ - public function updateShares(array $add, array $remove) { + public function updateShares(array $add, array $remove): void { if ($this->isShared()) { throw new Forbidden(); } @@ -131,19 +85,16 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable * * readOnly - boolean * * summary - Optional, a description for the share * - * @return array + * @return list<array{href: string, commonName: string, status: int, readOnly: bool, '{http://owncloud.org/ns}principal': string, '{http://owncloud.org/ns}group-share': bool}> */ - public function getShares() { + public function getShares(): array { if ($this->isShared()) { return []; } return $this->caldavBackend->getShares($this->getResourceId()); } - /** - * @return int - */ - public function getResourceId() { + public function getResourceId(): int { return $this->calendarInfo['id']; } @@ -155,7 +106,9 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable } /** - * @return array + * @param int $resourceId + * @param list<array{privilege: string, principal: string, protected: bool}> $acl + * @return list<array{privilege: string, principal: ?string, protected: bool}> */ public function getACL() { $acl = [ @@ -241,21 +194,23 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable $acl = $this->caldavBackend->applyShareAcl($this->getResourceId(), $acl); $allowedPrincipals = [ $this->getOwner(), - $this->getOwner(). '/calendar-proxy-read', - $this->getOwner(). '/calendar-proxy-write', + $this->getOwner() . '/calendar-proxy-read', + $this->getOwner() . '/calendar-proxy-write', parent::getOwner(), 'principals/system/public' ]; - return array_filter($acl, function ($rule) use ($allowedPrincipals) { + /** @var list<array{privilege: string, principal: string, protected: bool}> $acl */ + $acl = array_filter($acl, function (array $rule) use ($allowedPrincipals): bool { return \in_array($rule['principal'], $allowedPrincipals, true); }); + return $acl; } public function getChildACL() { return $this->getACL(); } - public function getOwner() { + public function getOwner(): ?string { if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { return $this->calendarInfo['{http://owncloud.org/ns}owner-principal']; } @@ -263,20 +218,8 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable } public function delete() { - if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal']) && - $this->calendarInfo['{http://owncloud.org/ns}owner-principal'] !== $this->calendarInfo['principaluri']) { - $principal = 'principal:' . parent::getOwner(); - $shares = $this->caldavBackend->getShares($this->getResourceId()); - $shares = array_filter($shares, function ($share) use ($principal) { - return $share['href'] === $principal; - }); - if (empty($shares)) { - throw new Forbidden(); - } - - $this->caldavBackend->updateShares($this, [], [ - $principal - ]); + if ($this->isShared()) { + $this->caldavBackend->unshare($this, 'principal:' . $this->getPrincipalURI()); return; } @@ -400,7 +343,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable return isset($this->calendarInfo['{http://owncloud.org/ns}public']); } - protected function isShared() { + public function isShared() { if (!isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { return false; } @@ -412,6 +355,13 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable return isset($this->calendarInfo['{http://calendarserver.org/ns/}source']); } + public function isDeleted(): bool { + if (!isset($this->calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT])) { + return false; + } + return $this->calendarInfo[TrashbinPlugin::PROPERTY_DELETED_AT] !== null; + } + /** * @inheritDoc */ @@ -427,7 +377,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable * @inheritDoc */ public function restore(): void { - $this->caldavBackend->restoreCalendar((int) $this->calendarInfo['id']); + $this->caldavBackend->restoreCalendar((int)$this->calendarInfo['id']); } public function disableTrashbin(): void { @@ -441,9 +391,14 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable if (!($sourceNode instanceof CalendarObject)) { return false; } - try { - return $this->caldavBackend->moveCalendarObject($sourceNode->getCalendarId(), (int)$this->calendarInfo['id'], $sourceNode->getId(), $sourceNode->getPrincipalUri()); + return $this->caldavBackend->moveCalendarObject( + $sourceNode->getOwner(), + $sourceNode->getId(), + $this->getOwner(), + $this->getResourceId(), + $targetName, + ); } catch (Exception $e) { $this->logger->error('Could not move calendar object: ' . $e->getMessage(), ['exception' => $e]); return false; diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php index ceeba31800e..89b78ba9007 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -30,6 +11,10 @@ use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\Integration\ExternalCalendar; use OCA\DAV\CalDAV\Integration\ICalendarProvider; use OCA\DAV\CalDAV\Trashbin\TrashbinHome; +use OCP\App\IAppManager; +use OCP\IConfig; +use OCP\IL10N; +use OCP\Server; use Psr\Log\LoggerInterface; use Sabre\CalDAV\Backend\BackendInterface; use Sabre\CalDAV\Backend\NotificationSupport; @@ -44,30 +29,29 @@ use Sabre\DAV\MkCol; class CalendarHome extends \Sabre\CalDAV\CalendarHome { - /** @var \OCP\IL10N */ + /** @var IL10N */ private $l10n; - /** @var \OCP\IConfig */ + /** @var IConfig */ private $config; /** @var PluginManager */ private $pluginManager; - - /** @var bool */ - private $returnCachedSubscriptions = false; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(BackendInterface $caldavBackend, $principalInfo, LoggerInterface $logger) { + private ?array $cachedChildren = null; + + public function __construct( + BackendInterface $caldavBackend, + array $principalInfo, + private LoggerInterface $logger, + private bool $returnCachedSubscriptions, + ) { parent::__construct($caldavBackend, $principalInfo); $this->l10n = \OC::$server->getL10N('dav'); - $this->config = \OC::$server->getConfig(); + $this->config = Server::get(IConfig::class); $this->pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + Server::get(IAppManager::class) ); - $this->logger = $logger; } /** @@ -97,6 +81,9 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { * @inheritdoc */ public function getChildren() { + if ($this->cachedChildren) { + return $this->cachedChildren; + } $calendars = $this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']); $objects = []; foreach ($calendars as $calendar) { @@ -136,6 +123,7 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { } } + $this->cachedChildren = $objects; return $objects; } @@ -159,7 +147,16 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { return new TrashbinHome($this->caldavBackend, $this->principalInfo); } - // Calendars + // Calendar - this covers all "regular" calendars, but not shared + // only check if the method is available + if ($this->caldavBackend instanceof CalDavBackend) { + $calendar = $this->caldavBackend->getCalendarByUri($this->principalInfo['uri'], $name); + if (!empty($calendar)) { + return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger); + } + } + + // Fallback to cover shared calendars foreach ($this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']) as $calendar) { if ($calendar['uri'] === $name) { return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger); @@ -205,9 +202,4 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome { $principalUri = $this->principalInfo['uri']; return $this->caldavBackend->calendarSearch($principalUri, $filters, $limit, $offset); } - - - public function enableCachedSubscriptionsForThisRequest() { - $this->returnCachedSubscriptions = true; - } } diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index 406389e3a3d..5f912da732e 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -3,70 +3,48 @@ 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 Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV; +use Generator; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\InvitationResponse\InvitationResponseServer; +use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\Exceptions\CalendarException; +use OCP\Calendar\ICalendarExport; +use OCP\Calendar\ICalendarIsEnabled; +use OCP\Calendar\ICalendarIsShared; +use OCP\Calendar\ICalendarIsWritable; use OCP\Calendar\ICreateFromString; +use OCP\Calendar\IHandleImipMessage; use OCP\Constants; +use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp; use Sabre\DAV\Exception\Conflict; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\ITip\Message; +use Sabre\VObject\Property; +use Sabre\VObject\Reader; use function Sabre\Uri\split as uriSplit; -class CalendarImpl implements ICreateFromString { - - /** @var CalDavBackend */ - private $backend; - - /** @var Calendar */ - private $calendar; - - /** @var array */ - private $calendarInfo; - - /** - * CalendarImpl constructor. - * - * @param Calendar $calendar - * @param array $calendarInfo - * @param CalDavBackend $backend - */ - public function __construct(Calendar $calendar, - array $calendarInfo, - CalDavBackend $backend) { - $this->calendar = $calendar; - $this->calendarInfo = $calendarInfo; - $this->backend = $backend; +class CalendarImpl implements ICreateFromString, IHandleImipMessage, ICalendarIsWritable, ICalendarIsShared, ICalendarExport, ICalendarIsEnabled { + public function __construct( + private Calendar $calendar, + /** @var array<string, mixed> */ + private array $calendarInfo, + private CalDavBackend $backend, + ) { } /** * @return string defining the technical unique key * @since 13.0.0 */ - public function getKey() { - return $this->calendarInfo['id']; + public function getKey(): string { + return (string)$this->calendarInfo['id']; } /** @@ -78,45 +56,60 @@ class CalendarImpl implements ICreateFromString { /** * In comparison to getKey() this function returns a human readable (maybe translated) name - * @return null|string * @since 13.0.0 */ - public function getDisplayName() { + public function getDisplayName(): ?string { return $this->calendarInfo['{DAV:}displayname']; } /** * Calendar color - * @return null|string * @since 13.0.0 */ - public function getDisplayColor() { + public function getDisplayColor(): ?string { return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color']; } - /** - * @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(...)]] - * @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 key-value-pairs - * @since 13.0.0 - */ - public function search($pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null) { + public function getSchedulingTransparency(): ?ScheduleCalendarTransp { + return $this->calendarInfo['{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}schedule-calendar-transp']; + } + + public function getSchedulingTimezone(): ?VTimeZone { + $tzProp = '{' . \OCA\DAV\CalDAV\Schedule\Plugin::NS_CALDAV . '}calendar-timezone'; + if (!isset($this->calendarInfo[$tzProp])) { + return null; + } + // This property contains a VCALENDAR with a single VTIMEZONE + /** @var string $timezoneProp */ + $timezoneProp = $this->calendarInfo[$tzProp]; + /** @var VCalendar $vobj */ + $vobj = Reader::read($timezoneProp); + $components = $vobj->getComponents(); + if (empty($components)) { + return null; + } + /** @var VTimeZone $vtimezone */ + $vtimezone = $components[0]; + return $vtimezone; + } + + public function search(string $pattern, array $searchProperties = [], array $options = [], $limit = null, $offset = null): array { return $this->backend->search($this->calendarInfo, $pattern, $searchProperties, $options, $limit, $offset); } /** - * @return integer build up using \OCP\Constants + * @return int build up using \OCP\Constants * @since 13.0.0 */ - public function getPermissions() { + public function getPermissions(): int { $permissions = $this->calendar->getACL(); $result = 0; foreach ($permissions as $permission) { + if ($this->calendarInfo['principaluri'] !== $permission['principal']) { + continue; + } + switch ($permission['privilege']) { case '{DAV:}read': $result |= Constants::PERMISSION_READ; @@ -135,19 +128,43 @@ class CalendarImpl implements ICreateFromString { } /** - * Create a new calendar event for this calendar - * by way of an ICS string - * - * @param string $name the file name - needs to contan the .ics ending - * @param string $calendarData a string containing a valid VEVENT ics - * - * @throws CalendarException + * @since 32.0.0 */ - public function createFromString(string $name, string $calendarData): void { - $server = new InvitationResponseServer(false); + public function isEnabled(): bool { + return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true; + } + + /** + * @since 31.0.0 + */ + public function isWritable(): bool { + return $this->calendar->canWrite(); + } + + /** + * @since 26.0.0 + */ + public function isDeleted(): bool { + return $this->calendar->isDeleted(); + } + + /** + * @since 31.0.0 + */ + public function isShared(): bool { + return $this->calendar->isShared(); + } + /** + * @throws CalendarException + */ + private function createFromStringInServer( + string $name, + string $calendarData, + \OCA\DAV\Connector\Sabre\Server $server, + ): void { /** @var CustomPrincipalPlugin $plugin */ - $plugin = $server->server->getPlugin('auth'); + $plugin = $server->getPlugin('auth'); // we're working around the previous implementation // that only allowed the public system principal to be used // so set the custom principal here @@ -163,18 +180,113 @@ class CalendarImpl implements ICreateFromString { // Force calendar change URI /** @var Schedule\Plugin $schedulingPlugin */ - $schedulingPlugin = $server->server->getPlugin('caldav-schedule'); + $schedulingPlugin = $server->getPlugin('caldav-schedule'); $schedulingPlugin->setPathOfCalendarObjectChange($fullCalendarFilename); $stream = fopen('php://memory', 'rb+'); fwrite($stream, $calendarData); rewind($stream); try { - $server->server->createFile($fullCalendarFilename, $stream); + $server->createFile($fullCalendarFilename, $stream); } catch (Conflict $e) { throw new CalendarException('Could not create new calendar event: ' . $e->getMessage(), 0, $e); } finally { fclose($stream); } } + + public function createFromString(string $name, string $calendarData): void { + $server = new EmbeddedCalDavServer(false); + $this->createFromStringInServer($name, $calendarData, $server->getServer()); + } + + public function createFromStringMinimal(string $name, string $calendarData): void { + $server = new InvitationResponseServer(false); + $this->createFromStringInServer($name, $calendarData, $server->getServer()); + } + + /** + * @throws CalendarException + */ + public function handleIMipMessage(string $name, string $calendarData): void { + $server = $this->getInvitationResponseServer(); + + /** @var CustomPrincipalPlugin $plugin */ + $plugin = $server->getServer()->getPlugin('auth'); + // we're working around the previous implementation + // that only allowed the public system principal to be used + // so set the custom principal here + $plugin->setCurrentPrincipal($this->calendar->getPrincipalURI()); + + if (empty($this->calendarInfo['uri'])) { + throw new CalendarException('Could not write to calendar as URI parameter is missing'); + } + // Force calendar change URI + /** @var Schedule\Plugin $schedulingPlugin */ + $schedulingPlugin = $server->getServer()->getPlugin('caldav-schedule'); + // Let sabre handle the rest + $iTipMessage = new Message(); + /** @var VCalendar $vObject */ + $vObject = Reader::read($calendarData); + /** @var VEvent $vEvent */ + $vEvent = $vObject->{'VEVENT'}; + + if ($vObject->{'METHOD'} === null) { + throw new CalendarException('No Method provided for scheduling data. Could not process message'); + } + + if (!isset($vEvent->{'ORGANIZER'}) || !isset($vEvent->{'ATTENDEE'})) { + throw new CalendarException('Could not process scheduling data, neccessary data missing from ICAL'); + } + $organizer = $vEvent->{'ORGANIZER'}->getValue(); + $attendee = $vEvent->{'ATTENDEE'}->getValue(); + + $iTipMessage->method = $vObject->{'METHOD'}->getValue(); + if ($iTipMessage->method === 'REQUEST') { + $iTipMessage->sender = $organizer; + $iTipMessage->recipient = $attendee; + } elseif ($iTipMessage->method === 'REPLY') { + if ($server->isExternalAttendee($vEvent->{'ATTENDEE'}->getValue())) { + $iTipMessage->recipient = $organizer; + } else { + $iTipMessage->recipient = $attendee; + } + $iTipMessage->sender = $attendee; + } elseif ($iTipMessage->method === 'CANCEL') { + $iTipMessage->recipient = $attendee; + $iTipMessage->sender = $organizer; + } + $iTipMessage->uid = isset($vEvent->{'UID'}) ? $vEvent->{'UID'}->getValue() : ''; + $iTipMessage->component = 'VEVENT'; + $iTipMessage->sequence = isset($vEvent->{'SEQUENCE'}) ? (int)$vEvent->{'SEQUENCE'}->getValue() : 0; + $iTipMessage->message = $vObject; + $server->server->emit('schedule', [$iTipMessage]); + } + + public function getInvitationResponseServer(): InvitationResponseServer { + return new InvitationResponseServer(false); + } + + /** + * Export objects + * + * @since 32.0.0 + * + * @return Generator<mixed, \Sabre\VObject\Component\VCalendar, mixed, mixed> + */ + public function export(?CalendarExportOptions $options = null): Generator { + foreach ( + $this->backend->exportCalendar( + $this->calendarInfo['id'], + $this->backend::CALENDAR_TYPE_CALENDAR, + $options + ) as $event + ) { + $vObject = Reader::read($event['calendardata']); + if ($vObject instanceof VCalendar) { + yield $vObject; + } + } + } + } diff --git a/apps/dav/lib/CalDAV/CalendarManager.php b/apps/dav/lib/CalDAV/CalendarManager.php index daa96a51392..a2d2f1cda8a 100644 --- a/apps/dav/lib/CalDAV/CalendarManager.php +++ b/apps/dav/lib/CalDAV/CalendarManager.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright 2017, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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 OCA\DAV\CalDAV; @@ -31,18 +13,6 @@ use Psr\Log\LoggerInterface; class CalendarManager { - /** @var CalDavBackend */ - private $backend; - - /** @var IL10N */ - private $l10n; - - /** @var IConfig */ - private $config; - - /** @var LoggerInterface */ - private $logger; - /** * CalendarManager constructor. * @@ -50,11 +20,12 @@ class CalendarManager { * @param IL10N $l10n * @param IConfig $config */ - public function __construct(CalDavBackend $backend, IL10N $l10n, IConfig $config, LoggerInterface $logger) { - $this->backend = $backend; - $this->l10n = $l10n; - $this->config = $config; - $this->logger = $logger; + public function __construct( + private CalDavBackend $backend, + private IL10N $l10n, + private IConfig $config, + private LoggerInterface $logger, + ) { } /** diff --git a/apps/dav/lib/CalDAV/CalendarObject.php b/apps/dav/lib/CalDAV/CalendarObject.php index c927254fba3..02178b4236f 100644 --- a/apps/dav/lib/CalDAV/CalendarObject.php +++ b/apps/dav/lib/CalDAV/CalendarObject.php @@ -1,28 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke - * @copyright Copyright (c) 2020, Gary Kim <gary@garykim.dev> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Gary Kim <gary@garykim.dev> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -33,9 +14,6 @@ use Sabre\VObject\Reader; class CalendarObject extends \Sabre\CalDAV\CalendarObject { - /** @var IL10N */ - protected $l10n; - /** * CalendarObject constructor. * @@ -44,16 +22,17 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { * @param array $calendarInfo * @param array $objectData */ - public function __construct(CalDavBackend $caldavBackend, IL10N $l10n, - array $calendarInfo, - array $objectData) { + public function __construct( + CalDavBackend $caldavBackend, + protected IL10N $l10n, + array $calendarInfo, + array $objectData, + ) { parent::__construct($caldavBackend, $calendarInfo, $objectData); if ($this->isShared()) { unset($this->objectData['size']); } - - $this->l10n = $l10n; } /** @@ -82,7 +61,7 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { } public function getId(): int { - return (int) $this->objectData['id']; + return (int)$this->objectData['id']; } protected function isShared() { @@ -97,28 +76,29 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { * @param Component\VCalendar $vObject * @return void */ - private function createConfidentialObject(Component\VCalendar $vObject) { + private function createConfidentialObject(Component\VCalendar $vObject): void { /** @var Component $vElement */ - $vElement = null; - if (isset($vObject->VEVENT)) { - $vElement = $vObject->VEVENT; - } - if (isset($vObject->VJOURNAL)) { - $vElement = $vObject->VJOURNAL; - } - if (isset($vObject->VTODO)) { - $vElement = $vObject->VTODO; - } - if (!is_null($vElement)) { + $vElements = array_filter($vObject->getComponents(), static function ($vElement) { + return $vElement instanceof Component\VEvent || $vElement instanceof Component\VJournal || $vElement instanceof Component\VTodo; + }); + + foreach ($vElements as $vElement) { + if (empty($vElement->select('SUMMARY'))) { + $vElement->add('SUMMARY', $this->l10n->t('Busy')); // This is needed to mask "Untitled Event" events + } foreach ($vElement->children() as &$property) { /** @var Property $property */ switch ($property->name) { case 'CREATED': case 'DTSTART': case 'RRULE': + case 'RECURRENCE-ID': + case 'RDATE': case 'DURATION': case 'DTEND': case 'CLASS': + case 'EXRULE': + case 'EXDATE': case 'UID': break; case 'SUMMARY': @@ -162,4 +142,11 @@ class CalendarObject extends \Sabre\CalDAV\CalendarObject { public function getPrincipalUri(): string { return $this->calendarInfo['principaluri']; } + + public function getOwner(): ?string { + if (isset($this->calendarInfo['{http://owncloud.org/ns}owner-principal'])) { + return $this->calendarInfo['{http://owncloud.org/ns}owner-principal']; + } + return parent::getOwner(); + } } diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php index f29c601db2d..a8b818e59aa 100644 --- a/apps/dav/lib/CalDAV/CalendarProvider.php +++ b/apps/dav/lib/CalDAV/CalendarProvider.php @@ -3,28 +3,13 @@ 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 OCA\DAV\CalDAV; +use OCA\DAV\Db\Property; +use OCA\DAV\Db\PropertyMapper; use OCP\Calendar\ICalendarProvider; use OCP\IConfig; use OCP\IL10N; @@ -32,39 +17,33 @@ use Psr\Log\LoggerInterface; class CalendarProvider implements ICalendarProvider { - /** @var CalDavBackend */ - private $calDavBackend; - - /** @var IL10N */ - private $l10n; - - /** @var IConfig */ - private $config; - - /** @var LoggerInterface */ - private $logger; - - public function __construct(CalDavBackend $calDavBackend, IL10N $l10n, IConfig $config, LoggerInterface $logger) { - $this->calDavBackend = $calDavBackend; - $this->l10n = $l10n; - $this->config = $config; - $this->logger = $logger; + public function __construct( + private CalDavBackend $calDavBackend, + private IL10N $l10n, + private IConfig $config, + private LoggerInterface $logger, + private PropertyMapper $propertyMapper, + ) { } public function getCalendars(string $principalUri, array $calendarUris = []): array { - $calendarInfos = []; - if (empty($calendarUris)) { - $calendarInfos = $this->calDavBackend->getCalendarsForUser($principalUri); - } else { - foreach ($calendarUris as $calendarUri) { - $calendarInfos[] = $this->calDavBackend->getCalendarByUri($principalUri, $calendarUri); - } - } - $calendarInfos = array_filter($calendarInfos); + $calendarInfos = $this->calDavBackend->getCalendarsForUser($principalUri) ?? []; + + if (!empty($calendarUris)) { + $calendarInfos = array_filter($calendarInfos, function ($calendar) use ($calendarUris) { + return in_array($calendar['uri'], $calendarUris); + }); + } + $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos); $iCalendars = []; foreach ($calendarInfos as $calendarInfo) { + $user = str_replace('principals/users/', '', $calendarInfo['principaluri']); + $path = 'calendars/' . $user . '/' . $calendarInfo['uri']; + + $calendarInfo = array_merge($calendarInfo, $additionalProperties[$path] ?? []); + $calendar = new Calendar($this->calDavBackend, $calendarInfo, $this->l10n, $this->config, $this->logger); $iCalendars[] = new CalendarImpl( $calendar, @@ -74,4 +53,41 @@ class CalendarProvider implements ICalendarProvider { } return $iCalendars; } + + /** + * @param array{ + * principaluri: string, + * uri: string, + * }[] $uris + * @return array<string, array<string, string|bool>> + */ + private function getAdditionalPropertiesForCalendars(array $uris): array { + $calendars = []; + foreach ($uris as $uri) { + /** @var string $user */ + $user = str_replace('principals/users/', '', $uri['principaluri']); + if (!array_key_exists($user, $calendars)) { + $calendars[$user] = []; + } + $calendars[$user][] = 'calendars/' . $user . '/' . $uri['uri']; + } + + $properties = $this->propertyMapper->findPropertiesByPathsAndUsers($calendars); + + $list = []; + foreach ($properties as $property) { + if ($property instanceof Property) { + if (!isset($list[$property->getPropertypath()])) { + $list[$property->getPropertypath()] = []; + } + + $list[$property->getPropertypath()][$property->getPropertyname()] = match ($property->getPropertyname()) { + '{http://owncloud.org/ns}calendar-enabled' => (bool)$property->getPropertyvalue(), + default => $property->getPropertyvalue() + }; + } + } + + return $list; + } } diff --git a/apps/dav/lib/CalDAV/CalendarRoot.php b/apps/dav/lib/CalDAV/CalendarRoot.php index 0c701d9cdcf..c0a313955bb 100644 --- a/apps/dav/lib/CalDAV/CalendarRoot.php +++ b/apps/dav/lib/CalDAV/CalendarRoot.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -30,25 +12,29 @@ use Sabre\CalDAV\Backend; use Sabre\DAVACL\PrincipalBackend; class CalendarRoot extends \Sabre\CalDAV\CalendarRoot { - private LoggerInterface $logger; + private array $returnCachedSubscriptions = []; public function __construct( PrincipalBackend\BackendInterface $principalBackend, Backend\BackendInterface $caldavBackend, $principalPrefix, - LoggerInterface $logger + private LoggerInterface $logger, ) { parent::__construct($principalBackend, $caldavBackend, $principalPrefix); - $this->logger = $logger; } public function getChildForPrincipal(array $principal) { - return new CalendarHome($this->caldavBackend, $principal, $this->logger); + return new CalendarHome( + $this->caldavBackend, + $principal, + $this->logger, + array_key_exists($principal['uri'], $this->returnCachedSubscriptions) + ); } public function getName() { - if ($this->principalPrefix === 'principals/calendar-resources' || - $this->principalPrefix === 'principals/calendar-rooms') { + if ($this->principalPrefix === 'principals/calendar-resources' + || $this->principalPrefix === 'principals/calendar-rooms') { $parts = explode('/', $this->principalPrefix); return $parts[1]; @@ -56,4 +42,8 @@ class CalendarRoot extends \Sabre\CalDAV\CalendarRoot { return parent::getName(); } + + public function enableReturnCachedSubscriptions(string $principalUri): void { + $this->returnCachedSubscriptions['principals/users/' . $principalUri] = true; + } } diff --git a/apps/dav/lib/CalDAV/DefaultCalendarValidator.php b/apps/dav/lib/CalDAV/DefaultCalendarValidator.php new file mode 100644 index 00000000000..266e07ef255 --- /dev/null +++ b/apps/dav/lib/CalDAV/DefaultCalendarValidator.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use Sabre\DAV\Exception as DavException; + +class DefaultCalendarValidator { + /** + * Check if a given Calendar node is suitable to be used as the default calendar for scheduling. + * + * @throws DavException If the calendar is not suitable to be used as the default calendar + */ + public function validateScheduleDefaultCalendar(Calendar $calendar): void { + // Sanity checks for a calendar that should handle invitations + if ($calendar->isSubscription() + || !$calendar->canWrite() + || $calendar->isShared() + || $calendar->isDeleted()) { + throw new DavException('Calendar is a subscription, not writable, shared or deleted'); + } + + // Calendar must support VEVENTs + $sCCS = '{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set'; + $calendarProperties = $calendar->getProperties([$sCCS]); + if (isset($calendarProperties[$sCCS])) { + $supportedComponents = $calendarProperties[$sCCS]->getValue(); + } else { + $supportedComponents = ['VJOURNAL', 'VTODO', 'VEVENT']; + } + if (!in_array('VEVENT', $supportedComponents, true)) { + throw new DavException('Calendar does not support VEVENT components'); + } + } +} diff --git a/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php new file mode 100644 index 00000000000..d9d6d840c5e --- /dev/null +++ b/apps/dav/lib/CalDAV/EmbeddedCalDavServer.php @@ -0,0 +1,122 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use OCA\DAV\AppInfo\PluginManager; +use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; +use OCA\DAV\CalDAV\Auth\PublicPrincipalPlugin; +use OCA\DAV\CalDAV\Publishing\PublishPlugin; +use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin; +use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; +use OCA\DAV\Connector\Sabre\CachingTree; +use OCA\DAV\Connector\Sabre\DavAclPlugin; +use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin; +use OCA\DAV\Connector\Sabre\LockPlugin; +use OCA\DAV\Connector\Sabre\MaintenancePlugin; +use OCA\DAV\Connector\Sabre\PropFindPreloadNotifyPlugin; +use OCA\DAV\Events\SabrePluginAuthInitEvent; +use OCA\DAV\RootCollection; +use OCA\Theming\ThemingDefaults; +use OCP\App\IAppManager; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\L10N\IFactory as IL10NFactory; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class EmbeddedCalDavServer { + private readonly \OCA\DAV\Connector\Sabre\Server $server; + + public function __construct(bool $public = true) { + $baseUri = \OC::$WEBROOT . '/remote.php/dav/'; + $logger = Server::get(LoggerInterface::class); + $dispatcher = Server::get(IEventDispatcher::class); + $appConfig = Server::get(IAppConfig::class); + $l10nFactory = Server::get(IL10NFactory::class); + $l10n = $l10nFactory->get('dav'); + + $root = new RootCollection(); + $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root)); + + // Add maintenance plugin + $this->server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), $l10n)); + + // Set URL explicitly due to reverse-proxy situations + $this->server->httpRequest->setUrl($baseUri); + $this->server->setBaseUri($baseUri); + + $this->server->addPlugin(new BlockLegacyClientPlugin( + Server::get(IConfig::class), + Server::get(ThemingDefaults::class), + )); + $this->server->addPlugin(new AnonymousOptionsPlugin()); + + // allow custom principal uri option + if ($public) { + $this->server->addPlugin(new PublicPrincipalPlugin()); + } else { + $this->server->addPlugin(new CustomPrincipalPlugin()); + } + + // allow setup of additional auth backends + $event = new SabrePluginAuthInitEvent($this->server); + $dispatcher->dispatchTyped($event); + + $this->server->addPlugin(new ExceptionLoggerPlugin('webdav', $logger)); + $this->server->addPlugin(new LockPlugin()); + $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin()); + + // acl + $acl = new DavAclPlugin(); + $acl->principalCollectionSet = [ + 'principals/users', 'principals/groups' + ]; + $this->server->addPlugin($acl); + + // calendar plugins + $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); + $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class))); + $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); + $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); + //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest())); + $this->server->addPlugin(new PublishPlugin( + Server::get(IConfig::class), + Server::get(IURLGenerator::class) + )); + if ($appConfig->getValueString('dav', 'sendInvitations', 'yes') === 'yes') { + $this->server->addPlugin(Server::get(\OCA\DAV\CalDAV\Schedule\IMipPlugin::class)); + } + + // collection preload plugin + $this->server->addPlugin(new PropFindPreloadNotifyPlugin()); + + // wait with registering these until auth is handled and the filesystem is setup + $this->server->on('beforeMethod:*', function () use ($root): void { + // register plugins from apps + $pluginManager = new PluginManager( + \OC::$server, + Server::get(IAppManager::class) + ); + foreach ($pluginManager->getAppPlugins() as $appPlugin) { + $this->server->addPlugin($appPlugin); + } + foreach ($pluginManager->getAppCollections() as $appCollection) { + $root->addChild($appCollection); + } + }); + } + + public function getServer(): \OCA\DAV\Connector\Sabre\Server { + return $this->server; + } +} diff --git a/apps/dav/lib/CalDAV/EventComparisonService.php b/apps/dav/lib/CalDAV/EventComparisonService.php new file mode 100644 index 00000000000..63395e7ce1c --- /dev/null +++ b/apps/dav/lib/CalDAV/EventComparisonService.php @@ -0,0 +1,100 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV; + +use OCA\DAV\CalDAV\Schedule\IMipService; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; + +class EventComparisonService { + + /** @var string[] */ + private const EVENT_DIFF = [ + 'RECURRENCE-ID', + 'RRULE', + 'SEQUENCE', + 'LAST-MODIFIED' + ]; + + + /** + * If found, remove the event from $eventsToFilter that + * is identical to the passed $filterEvent + * and return whether an identical event was found + * + * This function takes into account the SEQUENCE, + * RRULE, RECURRENCE-ID and LAST-MODIFIED parameters + * + * @param VEvent $filterEvent + * @param array $eventsToFilter + * @return bool true if there was an identical event found and removed, false if there wasn't + */ + private function removeIfUnchanged(VEvent $filterEvent, array &$eventsToFilter): bool { + $filterEventData = []; + foreach (self::EVENT_DIFF as $eventDiff) { + $filterEventData[] = IMipService::readPropertyWithDefault($filterEvent, $eventDiff, ''); + } + + /** @var VEvent $component */ + foreach ($eventsToFilter as $k => $eventToFilter) { + $eventToFilterData = []; + foreach (self::EVENT_DIFF as $eventDiff) { + $eventToFilterData[] = IMipService::readPropertyWithDefault($eventToFilter, $eventDiff, ''); + } + // events are identical and can be removed + if ($filterEventData === $eventToFilterData) { + unset($eventsToFilter[$k]); + return true; + } + } + return false; + } + + /** + * Compare two VCalendars with each other and find all changed elements + * + * Returns an array of old and new events + * + * Old events are only detected if they are also changed + * If there is no corresponding old event for a VEvent, it + * has been newly created + * + * @param VCalendar $new + * @param VCalendar|null $old + * @return array<string, VEvent[]|null> + */ + public function findModified(VCalendar $new, ?VCalendar $old): array { + $newEventComponents = $new->getComponents(); + + foreach ($newEventComponents as $k => $event) { + if (!$event instanceof VEvent) { + unset($newEventComponents[$k]); + } + } + + if (empty($old)) { + return ['old' => null, 'new' => $newEventComponents]; + } + + $oldEventComponents = $old->getComponents(); + if (is_array($oldEventComponents) && !empty($oldEventComponents)) { + foreach ($oldEventComponents as $k => $event) { + if (!$event instanceof VEvent) { + unset($oldEventComponents[$k]); + continue; + } + if ($this->removeIfUnchanged($event, $newEventComponents)) { + unset($oldEventComponents[$k]); + } + } + } + + return ['old' => array_values($oldEventComponents), 'new' => array_values($newEventComponents)]; + } +} diff --git a/apps/dav/lib/CalDAV/EventReader.php b/apps/dav/lib/CalDAV/EventReader.php new file mode 100644 index 00000000000..ee2b8f33f9a --- /dev/null +++ b/apps/dav/lib/CalDAV/EventReader.php @@ -0,0 +1,771 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTime; +use DateTimeImmutable; +use DateTimeInterface; +use DateTimeZone; +use InvalidArgumentException; + +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\Reader; + +class EventReader { + + protected VEvent $baseEvent; + protected DateTimeInterface $baseEventStartDate; + protected DateTimeZone $baseEventStartTimeZone; + protected DateTimeInterface $baseEventEndDate; + protected DateTimeZone $baseEventEndTimeZone; + protected bool $baseEventStartDateFloating = false; + protected bool $baseEventEndDateFloating = false; + protected int $baseEventDuration; + + protected ?EventReaderRRule $rruleIterator = null; + protected ?EventReaderRDate $rdateIterator = null; + protected ?EventReaderRRule $eruleIterator = null; + protected ?EventReaderRDate $edateIterator = null; + + protected array $recurrenceModified; + protected ?DateTimeInterface $recurrenceCurrentDate; + + protected array $dayNamesMap = [ + 'MO' => 'Monday', 'TU' => 'Tuesday', 'WE' => 'Wednesday', 'TH' => 'Thursday', 'FR' => 'Friday', 'SA' => 'Saturday', 'SU' => 'Sunday' + ]; + protected array $monthNamesMap = [ + 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', 5 => 'May', 6 => 'June', + 7 => 'July', 8 => 'August', 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December' + ]; + protected array $relativePositionNamesMap = [ + 1 => 'First', 2 => 'Second', 3 => 'Third', 4 => 'Fourth', 5 => 'Fifth', + -1 => 'Last', -2 => 'Second Last', -3 => 'Third Last', -4 => 'Fourth Last', -5 => 'Fifth Last' + ]; + + /** + * Initilizes the Event Reader + * + * There is several ways to set up the iterator. + * + * 1. You can pass a VCALENDAR component (as object or string) and a UID. + * 2. You can pass an array of VEVENTs (all UIDS should match). + * 3. You can pass a single VEVENT component (as object or string). + * + * Only the second method is recommended. The other 1 and 3 will be removed + * at some point in the future. + * + * The $uid parameter is only required for the first method. + * + * @since 30.0.0 + * + * @param VCalendar|VEvent|Array|String $input + * @param string|null $uid + * @param DateTimeZone|null $timeZone reference timezone for floating dates and times + */ + public function __construct(VCalendar|VEvent|array|string $input, ?string $uid = null, ?DateTimeZone $timeZone = null) { + + $timeZoneFactory = new TimeZoneFactory(); + + // evaluate if the input is a string and convert it to and vobject if required + if (is_string($input)) { + $input = Reader::read($input); + } + // evaluate if input is a single event vobject and convert it to a collection + if ($input instanceof VEvent) { + $events = [$input]; + } + // evaluate if input is a calendar vobject + elseif ($input instanceof VCalendar) { + // Calendar + UID mode. + if ($uid === null) { + throw new InvalidArgumentException('The UID argument is required when a VCALENDAR object is used'); + } + // extract events from calendar + $events = $input->getByUID($uid); + // evaluate if any event where found + if (count($events) === 0) { + throw new InvalidArgumentException('This VCALENDAR did not have an event with UID: ' . $uid); + } + // extract calendar timezone + if (isset($input->VTIMEZONE) && isset($input->VTIMEZONE->TZID)) { + $calendarTimeZone = $timeZoneFactory->fromName($input->VTIMEZONE->TZID->getValue()); + } + } + // evaluate if input is a collection of event vobjects + elseif (is_array($input)) { + $events = $input; + } else { + throw new InvalidArgumentException('Invalid input data type'); + } + // find base event instance and remove it from events collection + foreach ($events as $key => $vevent) { + if (!isset($vevent->{'RECURRENCE-ID'})) { + $this->baseEvent = $vevent; + unset($events[$key]); + } + } + + // No base event was found. CalDAV does allow cases where only + // overridden instances are stored. + // + // In this particular case, we're just going to grab the first + // event and use that instead. This may not always give the + // desired result. + if (!isset($this->baseEvent) && count($events) > 0) { + $this->baseEvent = array_shift($events); + } + + // determine the event starting time zone + // we require this to align all other dates times + // evaluate if timezone parameter was used (treat this as a override) + if ($timeZone !== null) { + $this->baseEventStartTimeZone = $timeZone; + } + // evaluate if event start date has a timezone parameter + elseif (isset($this->baseEvent->DTSTART->parameters['TZID'])) { + $this->baseEventStartTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTSTART->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC'); + } + // evaluate if event parent calendar has a time zone + elseif (isset($calendarTimeZone)) { + $this->baseEventStartTimeZone = clone $calendarTimeZone; + } + // otherwise, as a last resort use the UTC timezone + else { + $this->baseEventStartTimeZone = new DateTimeZone('UTC'); + } + + // determine the event end time zone + // we require this to align all other dates and times + // evaluate if timezone parameter was used (treat this as a override) + if ($timeZone !== null) { + $this->baseEventEndTimeZone = $timeZone; + } + // evaluate if event end date has a timezone parameter + elseif (isset($this->baseEvent->DTEND->parameters['TZID'])) { + $this->baseEventEndTimeZone = $timeZoneFactory->fromName($this->baseEvent->DTEND->parameters['TZID']->getValue()) ?? new DateTimeZone('UTC'); + } + // evaluate if event parent calendar has a time zone + elseif (isset($calendarTimeZone)) { + $this->baseEventEndTimeZone = clone $calendarTimeZone; + } + // otherwise, as a last resort use the start date time zone + else { + $this->baseEventEndTimeZone = clone $this->baseEventStartTimeZone; + } + // extract start date and time + $this->baseEventStartDate = $this->baseEvent->DTSTART->getDateTime($this->baseEventStartTimeZone); + $this->baseEventStartDateFloating = $this->baseEvent->DTSTART->isFloating(); + // determine event end date and duration + // evaluate if end date exists + // extract end date and calculate duration + if (isset($this->baseEvent->DTEND)) { + $this->baseEventEndDate = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone); + $this->baseEventEndDateFloating = $this->baseEvent->DTEND->isFloating(); + $this->baseEventDuration + = $this->baseEvent->DTEND->getDateTime($this->baseEventEndTimeZone)->getTimeStamp() + - $this->baseEventStartDate->getTimeStamp(); + } + // evaluate if duration exists + // extract duration and calculate end date + elseif (isset($this->baseEvent->DURATION)) { + $this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate) + ->add($this->baseEvent->DURATION->getDateInterval()); + $this->baseEventDuration = $this->baseEventEndDate->getTimestamp() - $this->baseEventStartDate->getTimestamp(); + } + // evaluate if start date is floating + // set duration to 24 hours and calculate the end date + // according to the rfc any event without a end date or duration is a complete day + elseif ($this->baseEventStartDateFloating == true) { + $this->baseEventDuration = 86400; + $this->baseEventEndDate = DateTimeImmutable::createFromInterface($this->baseEventStartDate) + ->setTimestamp($this->baseEventStartDate->getTimestamp() + $this->baseEventDuration); + } + // otherwise, set duration to zero this should never happen + else { + $this->baseEventDuration = 0; + $this->baseEventEndDate = $this->baseEventStartDate; + } + // evaluate if RRULE exist and construct iterator + if (isset($this->baseEvent->RRULE)) { + $this->rruleIterator = new EventReaderRRule( + $this->baseEvent->RRULE->getParts(), + $this->baseEventStartDate + ); + } + // evaluate if RDATE exist and construct iterator + if (isset($this->baseEvent->RDATE)) { + $dates = []; + foreach ($this->baseEvent->RDATE as $entry) { + $dates[] = $entry->getValue(); + } + $this->rdateIterator = new EventReaderRDate( + implode(',', $dates), + $this->baseEventStartDate + ); + } + // evaluate if EXRULE exist and construct iterator + if (isset($this->baseEvent->EXRULE)) { + $this->eruleIterator = new EventReaderRRule( + $this->baseEvent->EXRULE->getParts(), + $this->baseEventStartDate + ); + } + // evaluate if EXDATE exist and construct iterator + if (isset($this->baseEvent->EXDATE)) { + $dates = []; + foreach ($this->baseEvent->EXDATE as $entry) { + $dates[] = $entry->getValue(); + } + $this->edateIterator = new EventReaderRDate( + implode(',', $dates), + $this->baseEventStartDate + ); + } + // construct collection of modified events with recurrence id as hash + foreach ($events as $vevent) { + $this->recurrenceModified[$vevent->{'RECURRENCE-ID'}->getDateTime($this->baseEventStartTimeZone)->getTimeStamp()] = $vevent; + } + + $this->recurrenceCurrentDate = clone $this->baseEventStartDate; + } + + /** + * retrieve date and time of event start + * + * @since 30.0.0 + * + * @return DateTime + */ + public function startDateTime(): DateTime { + return DateTime::createFromInterface($this->baseEventStartDate); + } + + /** + * retrieve time zone of event start + * + * @since 30.0.0 + * + * @return DateTimeZone + */ + public function startTimeZone(): DateTimeZone { + return $this->baseEventStartTimeZone; + } + + /** + * retrieve date and time of event end + * + * @since 30.0.0 + * + * @return DateTime + */ + public function endDateTime(): DateTime { + return DateTime::createFromInterface($this->baseEventEndDate); + } + + /** + * retrieve time zone of event end + * + * @since 30.0.0 + * + * @return DateTimeZone + */ + public function endTimeZone(): DateTimeZone { + return $this->baseEventEndTimeZone; + } + + /** + * is this an all day event + * + * @since 30.0.0 + * + * @return bool + */ + public function entireDay(): bool { + return $this->baseEventStartDateFloating; + } + + /** + * is this a recurring event + * + * @since 30.0.0 + * + * @return bool + */ + public function recurs(): bool { + return ($this->rruleIterator !== null || $this->rdateIterator !== null); + } + + /** + * event recurrence pattern + * + * @since 30.0.0 + * + * @return string|null R - Relative or A - Absolute + */ + public function recurringPattern(): ?string { + if ($this->rruleIterator === null && $this->rdateIterator === null) { + return null; + } + if ($this->rruleIterator?->isRelative()) { + return 'R'; + } + return 'A'; + } + + /** + * event recurrence precision + * + * @since 30.0.0 + * + * @return string|null daily, weekly, monthly, yearly, fixed + */ + public function recurringPrecision(): ?string { + if ($this->rruleIterator !== null) { + return $this->rruleIterator->precision(); + } + if ($this->rdateIterator !== null) { + return 'fixed'; + } + return null; + } + + /** + * event recurrence interval + * + * @since 30.0.0 + * + * @return int|null + */ + public function recurringInterval(): ?int { + return $this->rruleIterator?->interval(); + } + + /** + * event recurrence conclusion + * + * returns true if RRULE with UNTIL or COUNT (calculated) is used + * returns true RDATE is used + * returns false if RRULE or RDATE are absent, or RRRULE is infinite + * + * @since 30.0.0 + * + * @return bool + */ + public function recurringConcludes(): bool { + + // retrieve rrule conclusions + if ($this->rruleIterator?->concludesOn() !== null + || $this->rruleIterator?->concludesAfter() !== null) { + return true; + } + // retrieve rdate conclusions + if ($this->rdateIterator?->concludesAfter() !== null) { + return true; + } + + return false; + + } + + /** + * event recurrence conclusion iterations + * + * returns the COUNT value if RRULE is used + * returns the collection count if RDATE is used + * returns combined count of RRULE COUNT and RDATE if both are used + * returns null if RRULE and RDATE are absent + * + * @since 30.0.0 + * + * @return int|null + */ + public function recurringConcludesAfter(): ?int { + + // construct count place holder + $count = 0; + // retrieve and add RRULE iterations count + $count += (int)$this->rruleIterator?->concludesAfter(); + // retrieve and add RDATE iterations count + $count += (int)$this->rdateIterator?->concludesAfter(); + // return count + return !empty($count) ? $count : null; + + } + + /** + * event recurrence conclusion date + * + * returns the last date of UNTIL or COUNT (calculated) if RRULE is used + * returns the last date in the collection if RDATE is used + * returns the highest date if both RRULE and RDATE are used + * returns null if RRULE and RDATE are absent or RRULE is infinite + * + * @since 30.0.0 + * + * @return DateTime|null + */ + public function recurringConcludesOn(): ?DateTime { + + if ($this->rruleIterator !== null) { + // retrieve rrule conclusion date + $rrule = $this->rruleIterator->concludes(); + // evaluate if rrule conclusion is null + // if this is null that means the recurrence is infinate + if ($rrule === null) { + return null; + } + } + // retrieve rdate conclusion date + if ($this->rdateIterator !== null) { + $rdate = $this->rdateIterator->concludes(); + } + // evaluate if both rrule and rdate have date + if (isset($rdate) && isset($rrule)) { + // return the highest date + return (($rdate > $rrule) ? $rdate : $rrule); + } elseif (isset($rrule)) { + return $rrule; + } elseif (isset($rdate)) { + return $rdate; + } + + return null; + + } + + /** + * event recurrence days of the week + * + * returns collection of RRULE BYDAY day(s) ['MO','WE','FR'] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfWeek(): array { + // evaluate if RRULE exists and return day(s) of the week + return $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : []; + } + + /** + * event recurrence days of the week (named) + * + * returns collection of RRULE BYDAY day(s) ['Monday','Wednesday','Friday'] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfWeekNamed(): array { + // evaluate if RRULE exists and extract day(s) of the week + $days = $this->rruleIterator !== null ? $this->rruleIterator->daysOfWeek() : []; + // convert numberic month to month name + foreach ($days as $key => $value) { + $days[$key] = $this->dayNamesMap[$value]; + } + // return names collection + return $days; + } + + /** + * event recurrence days of the month + * + * returns collection of RRULE BYMONTHDAY day(s) [7, 15, 31] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfMonth(): array { + // evaluate if RRULE exists and return day(s) of the month + return $this->rruleIterator !== null ? $this->rruleIterator->daysOfMonth() : []; + } + + /** + * event recurrence days of the year + * + * returns collection of RRULE BYYEARDAY day(s) [57, 205, 365] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringDaysOfYear(): array { + // evaluate if RRULE exists and return day(s) of the year + return $this->rruleIterator !== null ? $this->rruleIterator->daysOfYear() : []; + } + + /** + * event recurrence weeks of the month + * + * returns collection of RRULE SETPOS weeks(s) [1, 3, -1] + * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringWeeksOfMonth(): array { + // evaluate if RRULE exists and RRULE is relative return relative position(s) + return $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : []; + } + + /** + * event recurrence weeks of the month (named) + * + * returns collection of RRULE SETPOS weeks(s) [1, 3, -1] + * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringWeeksOfMonthNamed(): array { + // evaluate if RRULE exists and extract relative position(s) + $positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : []; + // convert numberic relative position to relative label + foreach ($positions as $key => $value) { + $positions[$key] = $this->relativePositionNamesMap[$value]; + } + // return positions collection + return $positions; + } + + /** + * event recurrence weeks of the year + * + * returns collection of RRULE BYWEEKNO weeks(s) [12, 32, 52] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringWeeksOfYear(): array { + // evaluate if RRULE exists and return weeks(s) of the year + return $this->rruleIterator !== null ? $this->rruleIterator->weeksOfYear() : []; + } + + /** + * event recurrence months of the year + * + * returns collection of RRULE BYMONTH month(s) [3, 7, 12] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringMonthsOfYear(): array { + // evaluate if RRULE exists and return month(s) of the year + return $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : []; + } + + /** + * event recurrence months of the year (named) + * + * returns collection of RRULE BYMONTH month(s) [3, 7, 12] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringMonthsOfYearNamed(): array { + // evaluate if RRULE exists and extract month(s) of the year + $months = $this->rruleIterator !== null ? $this->rruleIterator->monthsOfYear() : []; + // convert numberic month to month name + foreach ($months as $key => $value) { + $months[$key] = $this->monthNamesMap[$value]; + } + // return months collection + return $months; + } + + /** + * event recurrence relative positions + * + * returns collection of RRULE SETPOS value(s) [1, 5, -3] + * returns blank collection if RRULE is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringRelativePosition(): array { + // evaluate if RRULE exists and return relative position(s) + return $this->rruleIterator !== null ? $this->rruleIterator->relativePosition() : []; + } + + /** + * event recurrence relative positions (named) + * + * returns collection of RRULE SETPOS [1, 3, -1] + * returns blank collection if RRULE is absent or SETPOS is absent, RDATE presents or absents has no affect + * + * @since 30.0.0 + * + * @return array + */ + public function recurringRelativePositionNamed(): array { + // evaluate if RRULE exists and extract relative position(s) + $positions = $this->rruleIterator?->isRelative() ? $this->rruleIterator->relativePosition() : []; + // convert numberic relative position to relative label + foreach ($positions as $key => $value) { + $positions[$key] = $this->relativePositionNamesMap[$value]; + } + // return positions collection + return $positions; + } + + /** + * event recurrence date + * + * returns date of currently selected recurrence + * + * @since 30.0.0 + * + * @return DateTime + */ + public function recurrenceDate(): ?DateTime { + if ($this->recurrenceCurrentDate !== null) { + return DateTime::createFromInterface($this->recurrenceCurrentDate); + } else { + return null; + } + } + + /** + * event recurrence rewind + * + * sets the current recurrence to the first recurrence in the collection + * + * @since 30.0.0 + * + * @return void + */ + public function recurrenceRewind(): void { + // rewind and increment rrule + if ($this->rruleIterator !== null) { + $this->rruleIterator->rewind(); + } + // rewind and increment rdate + if ($this->rdateIterator !== null) { + $this->rdateIterator->rewind(); + } + // rewind and increment exrule + if ($this->eruleIterator !== null) { + $this->eruleIterator->rewind(); + } + // rewind and increment exdate + if ($this->edateIterator !== null) { + $this->edateIterator->rewind(); + } + // set current date to event start date + $this->recurrenceCurrentDate = clone $this->baseEventStartDate; + } + + /** + * event recurrence advance + * + * sets the current recurrence to the next recurrence in the collection + * + * @since 30.0.0 + * + * @return void + */ + public function recurrenceAdvance(): void { + // place holders + $nextOccurrenceDate = null; + $nextExceptionDate = null; + $rruleDate = null; + $rdateDate = null; + $eruleDate = null; + $edateDate = null; + // evaludate if rrule is set and advance one interation past current date + if ($this->rruleIterator !== null) { + // forward rrule to the next future date + while ($this->rruleIterator->valid() && $this->rruleIterator->current() <= $this->recurrenceCurrentDate) { + $this->rruleIterator->next(); + } + $rruleDate = $this->rruleIterator->current(); + } + // evaludate if rdate is set and advance one interation past current date + if ($this->rdateIterator !== null) { + // forward rdate to the next future date + while ($this->rdateIterator->valid() && $this->rdateIterator->current() <= $this->recurrenceCurrentDate) { + $this->rdateIterator->next(); + } + $rdateDate = $this->rdateIterator->current(); + } + if ($rruleDate !== null && $rdateDate !== null) { + $nextOccurrenceDate = ($rruleDate <= $rdateDate) ? $rruleDate : $rdateDate; + } elseif ($rruleDate !== null) { + $nextOccurrenceDate = $rruleDate; + } elseif ($rdateDate !== null) { + $nextOccurrenceDate = $rdateDate; + } + + // evaludate if exrule is set and advance one interation past current date + if ($this->eruleIterator !== null) { + // forward exrule to the next future date + while ($this->eruleIterator->valid() && $this->eruleIterator->current() <= $this->recurrenceCurrentDate) { + $this->eruleIterator->next(); + } + $eruleDate = $this->eruleIterator->current(); + } + // evaludate if exdate is set and advance one interation past current date + if ($this->edateIterator !== null) { + // forward exdate to the next future date + while ($this->edateIterator->valid() && $this->edateIterator->current() <= $this->recurrenceCurrentDate) { + $this->edateIterator->next(); + } + $edateDate = $this->edateIterator->current(); + } + // evaludate if exrule and exdate are set and set nextExDate to the first next date + if ($eruleDate !== null && $edateDate !== null) { + $nextExceptionDate = ($eruleDate <= $edateDate) ? $eruleDate : $edateDate; + } elseif ($eruleDate !== null) { + $nextExceptionDate = $eruleDate; + } elseif ($edateDate !== null) { + $nextExceptionDate = $edateDate; + } + // if the next date is part of exrule or exdate find another date + if ($nextOccurrenceDate !== null && $nextExceptionDate !== null && $nextOccurrenceDate == $nextExceptionDate) { + $this->recurrenceCurrentDate = $nextOccurrenceDate; + $this->recurrenceAdvance(); + } else { + $this->recurrenceCurrentDate = $nextOccurrenceDate; + } + } + + /** + * event recurrence advance + * + * sets the current recurrence to the next recurrence in the collection after the specific date + * + * @since 30.0.0 + * + * @param DateTimeInterface $dt date and time to advance + * + * @return void + */ + public function recurrenceAdvanceTo(DateTimeInterface $dt): void { + while ($this->recurrenceCurrentDate !== null && $this->recurrenceCurrentDate < $dt) { + $this->recurrenceAdvance(); + } + } + +} diff --git a/apps/dav/lib/CalDAV/EventReaderRDate.php b/apps/dav/lib/CalDAV/EventReaderRDate.php new file mode 100644 index 00000000000..20234d06c00 --- /dev/null +++ b/apps/dav/lib/CalDAV/EventReaderRDate.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTime; + +class EventReaderRDate extends \Sabre\VObject\Recur\RDateIterator { + + public function concludes(): ?DateTime { + return $this->concludesOn(); + } + + public function concludesAfter(): ?int { + return !empty($this->dates) ? count($this->dates) : null; + } + + public function concludesOn(): ?DateTime { + if (count($this->dates) > 0) { + return new DateTime( + $this->dates[array_key_last($this->dates)], + $this->startDate->getTimezone() + ); + } + + return null; + } + +} diff --git a/apps/dav/lib/CalDAV/EventReaderRRule.php b/apps/dav/lib/CalDAV/EventReaderRRule.php new file mode 100644 index 00000000000..d2b4968c479 --- /dev/null +++ b/apps/dav/lib/CalDAV/EventReaderRRule.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTime; +use DateTimeInterface; + +class EventReaderRRule extends \Sabre\VObject\Recur\RRuleIterator { + + public function precision(): string { + return $this->frequency; + } + + public function interval(): int { + return $this->interval; + } + + public function concludes(): ?DateTime { + // evaluate if until value is a date + if ($this->until instanceof DateTimeInterface) { + return DateTime::createFromInterface($this->until); + } + // evaluate if count value is higher than 0 + if ($this->count > 0) { + // temporarily store current recurrence date and counter + $currentReccuranceDate = $this->currentDate; + $currentCounter = $this->counter; + // iterate over occurrences until last one (subtract 2 from count for start and end occurrence) + while ($this->counter <= ($this->count - 2)) { + $this->next(); + } + // temporarly store last reccurance date + $lastReccuranceDate = $this->currentDate; + // restore current recurrence date and counter + $this->currentDate = $currentReccuranceDate; + $this->counter = $currentCounter; + // return last recurrence date + return DateTime::createFromInterface($lastReccuranceDate); + } + + return null; + } + + public function concludesAfter(): ?int { + return !empty($this->count) ? $this->count : null; + } + + public function concludesOn(): ?DateTime { + return isset($this->until) ? DateTime::createFromInterface($this->until) : null; + } + + public function daysOfWeek(): array { + return is_array($this->byDay) ? $this->byDay : []; + } + + public function daysOfMonth(): array { + return is_array($this->byMonthDay) ? $this->byMonthDay : []; + } + + public function daysOfYear(): array { + return is_array($this->byYearDay) ? $this->byYearDay : []; + } + + public function weeksOfYear(): array { + return is_array($this->byWeekNo) ? $this->byWeekNo : []; + } + + public function monthsOfYear(): array { + return is_array($this->byMonth) ? $this->byMonth : []; + } + + public function isRelative(): bool { + return isset($this->bySetPos); + } + + public function relativePosition(): array { + return is_array($this->bySetPos) ? $this->bySetPos : []; + } + +} diff --git a/apps/dav/lib/CalDAV/Export/ExportService.php b/apps/dav/lib/CalDAV/Export/ExportService.php new file mode 100644 index 00000000000..552b9e2b675 --- /dev/null +++ b/apps/dav/lib/CalDAV/Export/ExportService.php @@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Export; + +use Generator; +use OCP\Calendar\CalendarExportOptions; +use OCP\Calendar\ICalendarExport; +use OCP\ServerVersion; +use Sabre\VObject\Component; +use Sabre\VObject\Writer; + +/** + * Calendar Export Service + */ +class ExportService { + + public const FORMATS = ['ical', 'jcal', 'xcal']; + private string $systemVersion; + + public function __construct(ServerVersion $serverVersion) { + $this->systemVersion = $serverVersion->getVersionString(); + } + + /** + * Generates serialized content stream for a calendar and objects based in selected format + * + * @return Generator<string> + */ + public function export(ICalendarExport $calendar, CalendarExportOptions $options): Generator { + // output start of serialized content based on selected format + yield $this->exportStart($options->getFormat()); + // iterate through each returned vCalendar entry + // extract each component except timezones, convert to appropriate format and output + // extract any timezones and save them but do not output + $timezones = []; + foreach ($calendar->export($options) as $entry) { + $consecutive = false; + foreach ($entry->getComponents() as $vComponent) { + if ($vComponent->name === 'VTIMEZONE') { + if (isset($vComponent->TZID) && !isset($timezones[$vComponent->TZID->getValue()])) { + $timezones[$vComponent->TZID->getValue()] = clone $vComponent; + } + } else { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + } + } + // iterate through each saved vTimezone entry, convert to appropriate format and output + foreach ($timezones as $vComponent) { + yield $this->exportObject($vComponent, $options->getFormat(), $consecutive); + $consecutive = true; + } + // output end of serialized content based on selected format + yield $this->exportFinish($options->getFormat()); + } + + /** + * Generates serialized content start based on selected format + */ + private function exportStart(string $format): string { + return match ($format) { + 'jcal' => '["vcalendar",[["version",{},"text","2.0"],["prodid",{},"text","-\/\/IDN nextcloud.com\/\/Calendar Export v' . $this->systemVersion . '\/\/EN"]],[', + 'xcal' => '<?xml version="1.0" encoding="UTF-8"?><icalendar xmlns="urn:ietf:params:xml:ns:icalendar-2.0"><vcalendar><properties><version><text>2.0</text></version><prodid><text>-//IDN nextcloud.com//Calendar Export v' . $this->systemVersion . '//EN</text></prodid></properties><components>', + default => "BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//IDN nextcloud.com//Calendar Export v" . $this->systemVersion . "//EN\n" + }; + } + + /** + * Generates serialized content end based on selected format + */ + private function exportFinish(string $format): string { + return match ($format) { + 'jcal' => ']]', + 'xcal' => '</components></vcalendar></icalendar>', + default => "END:VCALENDAR\n" + }; + } + + /** + * Generates serialized content for a component based on selected format + */ + private function exportObject(Component $vobject, string $format, bool $consecutive): string { + return match ($format) { + 'jcal' => $consecutive ? ',' . Writer::writeJson($vobject) : Writer::writeJson($vobject), + 'xcal' => $this->exportObjectXml($vobject), + default => Writer::write($vobject) + }; + } + + /** + * Generates serialized content for a component in xml format + */ + private function exportObjectXml(Component $vobject): string { + $writer = new \Sabre\Xml\Writer(); + $writer->openMemory(); + $writer->setIndent(false); + $vobject->xmlSerialize($writer); + return $writer->outputMemory(); + } + +} diff --git a/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php b/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php new file mode 100644 index 00000000000..c2c474a90fe --- /dev/null +++ b/apps/dav/lib/CalDAV/FreeBusy/FreeBusyGenerator.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\FreeBusy; + +use Sabre\VObject\Component\VCalendar; + +/** + * @psalm-suppress PropertyNotSetInConstructor + */ +class FreeBusyGenerator extends \Sabre\VObject\FreeBusyGenerator { + + public function __construct() { + parent::__construct(); + } + + public function getVCalendar(): VCalendar { + return new VCalendar(); + } +} diff --git a/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php index ae568720c55..08dc10f7bf4 100644 --- a/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php +++ b/apps/dav/lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php @@ -1,29 +1,13 @@ <?php + /** - * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com> - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ICSExportPlugin; use OCP\IConfig; -use OCP\ILogger; +use Psr\Log\LoggerInterface; use Sabre\HTTP\ResponseInterface; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; @@ -35,24 +19,16 @@ use Sabre\VObject\Property\ICalendar\Duration; * @package OCA\DAV\CalDAV\ICSExportPlugin */ class ICSExportPlugin extends \Sabre\CalDAV\ICSExportPlugin { - - /** @var IConfig */ - private $config; - - /** @var ILogger */ - private $logger; - /** @var string */ private const DEFAULT_REFRESH_INTERVAL = 'PT4H'; /** * ICSExportPlugin constructor. - * - * @param IConfig $config */ - public function __construct(IConfig $config, ILogger $logger) { - $this->config = $config; - $this->logger = $logger; + public function __construct( + private IConfig $config, + private LoggerInterface $logger, + ) { } /** diff --git a/apps/dav/lib/CalDAV/IRestorable.php b/apps/dav/lib/CalDAV/IRestorable.php index fab73c43d3a..5850e0a5645 100644 --- a/apps/dav/lib/CalDAV/IRestorable.php +++ b/apps/dav/lib/CalDAV/IRestorable.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV; diff --git a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php index 9c801a08a26..acf81638679 100644 --- a/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php +++ b/apps/dav/lib/CalDAV/Integration/ExternalCalendar.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright 2020, Georg Ehrke <oc.list@georgehrke.com> - * - * @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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Integration; @@ -49,21 +33,16 @@ abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties { */ private const DELIMITER = '--'; - /** @var string */ - private $appId; - - /** @var string */ - private $calendarUri; - /** * ExternalCalendar constructor. * * @param string $appId * @param string $calendarUri */ - public function __construct(string $appId, string $calendarUri) { - $this->appId = $appId; - $this->calendarUri = $calendarUri; + public function __construct( + private string $appId, + private string $calendarUri, + ) { } /** @@ -98,7 +77,7 @@ abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties { * @return bool */ public static function isAppGeneratedCalendar(string $calendarUri):bool { - return strpos($calendarUri, self::PREFIX) === 0 && substr_count($calendarUri, self::DELIMITER) >= 2; + return str_starts_with($calendarUri, self::PREFIX) && substr_count($calendarUri, self::DELIMITER) >= 2; } /** @@ -126,6 +105,6 @@ abstract class ExternalCalendar implements CalDAV\ICalendar, DAV\IProperties { * @return bool */ public static function doesViolateReservedName(string $calendarUri):bool { - return strpos($calendarUri, self::PREFIX) === 0; + return str_starts_with($calendarUri, self::PREFIX); } } diff --git a/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php index c72112f06ba..40a8860dcb4 100644 --- a/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php +++ b/apps/dav/lib/CalDAV/Integration/ICalendarProvider.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright 2020, Georg Ehrke <oc.list@georgehrke.com> - * - * @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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Integration; diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php index 5317dc1b169..c8a7109abde 100644 --- a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php @@ -1,44 +1,35 @@ <?php + /** - * @copyright Copyright (c) 2018, Georg Ehrke. - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\InvitationResponse; use OCA\DAV\AppInfo\PluginManager; use OCA\DAV\CalDAV\Auth\CustomPrincipalPlugin; use OCA\DAV\CalDAV\Auth\PublicPrincipalPlugin; +use OCA\DAV\CalDAV\DefaultCalendarValidator; +use OCA\DAV\CalDAV\Publishing\PublishPlugin; use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin; use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; use OCA\DAV\Connector\Sabre\CachingTree; use OCA\DAV\Connector\Sabre\DavAclPlugin; +use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin; +use OCA\DAV\Connector\Sabre\LockPlugin; +use OCA\DAV\Connector\Sabre\MaintenancePlugin; use OCA\DAV\Events\SabrePluginAuthInitEvent; use OCA\DAV\RootCollection; +use OCA\Theming\ThemingDefaults; +use OCP\App\IAppManager; use OCP\EventDispatcher\IEventDispatcher; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\Server; +use Psr\Log\LoggerInterface; use Sabre\VObject\ITip\Message; class InvitationResponseServer { - /** @var \OCA\DAV\Connector\Sabre\Server */ public $server; @@ -47,21 +38,23 @@ class InvitationResponseServer { */ public function __construct(bool $public = true) { $baseUri = \OC::$WEBROOT . '/remote.php/dav/'; - $logger = \OC::$server->getLogger(); - /** @var IEventDispatcher $dispatcher */ - $dispatcher = \OC::$server->query(IEventDispatcher::class); + $logger = Server::get(LoggerInterface::class); + $dispatcher = Server::get(IEventDispatcher::class); $root = new RootCollection(); $this->server = new \OCA\DAV\Connector\Sabre\Server(new CachingTree($root)); // Add maintenance plugin - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\MaintenancePlugin(\OC::$server->getConfig(), \OC::$server->getL10N('dav'))); + $this->server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), \OC::$server->getL10N('dav'))); // Set URL explicitly due to reverse-proxy situations $this->server->httpRequest->setUrl($baseUri); $this->server->setBaseUri($baseUri); - $this->server->addPlugin(new BlockLegacyClientPlugin(\OC::$server->getConfig())); + $this->server->addPlugin(new BlockLegacyClientPlugin( + Server::get(IConfig::class), + Server::get(ThemingDefaults::class), + )); $this->server->addPlugin(new AnonymousOptionsPlugin()); // allow custom principal uri option @@ -75,8 +68,8 @@ class InvitationResponseServer { $event = new SabrePluginAuthInitEvent($this->server); $dispatcher->dispatchTyped($event); - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin('webdav', $logger)); - $this->server->addPlugin(new \OCA\DAV\Connector\Sabre\LockPlugin()); + $this->server->addPlugin(new ExceptionLoggerPlugin('webdav', $logger)); + $this->server->addPlugin(new LockPlugin()); $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin()); // acl @@ -89,21 +82,21 @@ class InvitationResponseServer { // calendar plugins $this->server->addPlugin(new \OCA\DAV\CalDAV\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\ICSExportPlugin()); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(\OC::$server->getConfig())); + $this->server->addPlugin(new \OCA\DAV\CalDAV\Schedule\Plugin(Server::get(IConfig::class), Server::get(LoggerInterface::class), Server::get(DefaultCalendarValidator::class))); $this->server->addPlugin(new \Sabre\CalDAV\Subscriptions\Plugin()); $this->server->addPlugin(new \Sabre\CalDAV\Notifications\Plugin()); //$this->server->addPlugin(new \OCA\DAV\DAV\Sharing\Plugin($authBackend, \OC::$server->getRequest())); - $this->server->addPlugin(new \OCA\DAV\CalDAV\Publishing\PublishPlugin( - \OC::$server->getConfig(), - \OC::$server->getURLGenerator() + $this->server->addPlugin(new PublishPlugin( + Server::get(IConfig::class), + Server::get(IURLGenerator::class) )); // wait with registering these until auth is handled and the filesystem is setup - $this->server->on('beforeMethod:*', function () use ($root) { + $this->server->on('beforeMethod:*', function () use ($root): void { // register plugins from apps $pluginManager = new PluginManager( \OC::$server, - \OC::$server->getAppManager() + Server::get(IAppManager::class) ); foreach ($pluginManager->getAppPlugins() as $appPlugin) { $this->server->addPlugin($appPlugin); @@ -126,7 +119,11 @@ class InvitationResponseServer { public function isExternalAttendee(string $principalUri): bool { /** @var \Sabre\DAVACL\Plugin $aclPlugin */ - $aclPlugin = $this->server->getPlugin('acl'); + $aclPlugin = $this->getServer()->getPlugin('acl'); return $aclPlugin->getPrincipalByUri($principalUri) === null; } + + public function getServer(): \OCA\DAV\Connector\Sabre\Server { + return $this->server; + } } diff --git a/apps/dav/lib/CalDAV/Outbox.php b/apps/dav/lib/CalDAV/Outbox.php index eebb48e1294..608114d8093 100644 --- a/apps/dav/lib/CalDAV/Outbox.php +++ b/apps/dav/lib/CalDAV/Outbox.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018, Georg Ehrke - * - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV; @@ -33,9 +16,6 @@ use Sabre\CalDAV\Plugin as CalDAVPlugin; */ class Outbox extends \Sabre\CalDAV\Schedule\Outbox { - /** @var IConfig */ - private $config; - /** @var null|bool */ private $disableFreeBusy = null; @@ -45,9 +25,11 @@ class Outbox extends \Sabre\CalDAV\Schedule\Outbox { * @param IConfig $config * @param string $principalUri */ - public function __construct(IConfig $config, string $principalUri) { + public function __construct( + private IConfig $config, + string $principalUri, + ) { parent::__construct($principalUri); - $this->config = $config; } /** diff --git a/apps/dav/lib/CalDAV/Plugin.php b/apps/dav/lib/CalDAV/Plugin.php index 5b367c51053..24448ae71ab 100644 --- a/apps/dav/lib/CalDAV/Plugin.php +++ b/apps/dav/lib/CalDAV/Plugin.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; diff --git a/apps/dav/lib/CalDAV/Principal/Collection.php b/apps/dav/lib/CalDAV/Principal/Collection.php index 27997741609..b76fde66464 100644 --- a/apps/dav/lib/CalDAV/Principal/Collection.php +++ b/apps/dav/lib/CalDAV/Principal/Collection.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de> - * - * @author Christoph Seitz <christoph.seitz@posteo.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV\Principal; diff --git a/apps/dav/lib/CalDAV/Principal/User.php b/apps/dav/lib/CalDAV/Principal/User.php index 904ecc32e89..047d83827ed 100644 --- a/apps/dav/lib/CalDAV/Principal/User.php +++ b/apps/dav/lib/CalDAV/Principal/User.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Christoph Seitz <christoph.seitz@posteo.de> - * - * @author Christoph Seitz <christoph.seitz@posteo.de> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV\Principal; diff --git a/apps/dav/lib/CalDAV/Proxy/Proxy.php b/apps/dav/lib/CalDAV/Proxy/Proxy.php index 8bafe8cc3b3..ef1ad8c634f 100644 --- a/apps/dav/lib/CalDAV/Proxy/Proxy.php +++ b/apps/dav/lib/CalDAV/Proxy/Proxy.php @@ -3,29 +3,13 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Proxy; use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; /** * @method string getOwnerId() @@ -45,8 +29,8 @@ class Proxy extends Entity { protected $permissions; public function __construct() { - $this->addType('ownerId', 'string'); - $this->addType('proxyId', 'string'); - $this->addType('permissions', 'int'); + $this->addType('ownerId', Types::STRING); + $this->addType('proxyId', Types::STRING); + $this->addType('permissions', Types::INTEGER); } } diff --git a/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php b/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php index 19c72ffa0e9..3b9b9c3d9eb 100644 --- a/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php +++ b/apps/dav/lib/CalDAV/Proxy/ProxyMapper.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Proxy; @@ -34,6 +15,8 @@ use OCP\IDBConnection; * Class ProxyMapper * * @package OCA\DAV\CalDAV\Proxy + * + * @template-extends QBMapper<Proxy> */ class ProxyMapper extends QBMapper { public const PERMISSION_READ = 1; diff --git a/apps/dav/lib/CalDAV/PublicCalendar.php b/apps/dav/lib/CalDAV/PublicCalendar.php index 4a29c8d237a..9af6e544165 100644 --- a/apps/dav/lib/CalDAV/PublicCalendar.php +++ b/apps/dav/lib/CalDAV/PublicCalendar.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Georg Ehrke - * - * @author Gary Kim <gary@garykim.dev> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV; @@ -84,7 +66,7 @@ class PublicCalendar extends Calendar { * public calendars are always shared * @return bool */ - protected function isShared() { + public function isShared() { return true; } } diff --git a/apps/dav/lib/CalDAV/PublicCalendarObject.php b/apps/dav/lib/CalDAV/PublicCalendarObject.php index 69a5583d8f5..2ab40b94347 100644 --- a/apps/dav/lib/CalDAV/PublicCalendarObject.php +++ b/apps/dav/lib/CalDAV/PublicCalendarObject.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV; diff --git a/apps/dav/lib/CalDAV/PublicCalendarRoot.php b/apps/dav/lib/CalDAV/PublicCalendarRoot.php index 4f7dfea2682..edfb9f8dccc 100644 --- a/apps/dav/lib/CalDAV/PublicCalendarRoot.php +++ b/apps/dav/lib/CalDAV/PublicCalendarRoot.php @@ -1,27 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV; @@ -32,18 +14,6 @@ use Sabre\DAV\Collection; class PublicCalendarRoot extends Collection { - /** @var CalDavBackend */ - protected $caldavBackend; - - /** @var \OCP\IL10N */ - protected $l10n; - - /** @var \OCP\IConfig */ - protected $config; - - /** @var LoggerInterface */ - private $logger; - /** * PublicCalendarRoot constructor. * @@ -51,12 +21,12 @@ class PublicCalendarRoot extends Collection { * @param IL10N $l10n * @param IConfig $config */ - public function __construct(CalDavBackend $caldavBackend, IL10N $l10n, - IConfig $config, LoggerInterface $logger) { - $this->caldavBackend = $caldavBackend; - $this->l10n = $l10n; - $this->config = $config; - $this->logger = $logger; + public function __construct( + protected CalDavBackend $caldavBackend, + protected IL10N $l10n, + protected IConfig $config, + private LoggerInterface $logger, + ) { } /** diff --git a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php index 97e942f9da2..76378e7a1c5 100644 --- a/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php +++ b/apps/dav/lib/CalDAV/Publishing/PublishPlugin.php @@ -1,34 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @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\Publishing; use OCA\DAV\CalDAV\Calendar; use OCA\DAV\CalDAV\Publishing\Xml\Publisher; +use OCP\AppFramework\Http; use OCP\IConfig; use OCP\IURLGenerator; use Sabre\CalDAV\Xml\Property\AllowedSharingModes; @@ -51,28 +31,21 @@ class PublishPlugin extends ServerPlugin { protected $server; /** - * Config instance to get instance secret. - * - * @var IConfig - */ - protected $config; - - /** - * URL Generator for absolute URLs. - * - * @var IURLGenerator - */ - protected $urlGenerator; - - /** * PublishPlugin constructor. * * @param IConfig $config * @param IURLGenerator $urlGenerator */ - public function __construct(IConfig $config, IURLGenerator $urlGenerator) { - $this->config = $config; - $this->urlGenerator = $urlGenerator; + public function __construct( + /** + * Config instance to get instance secret. + */ + protected IConfig $config, + /** + * URL Generator for absolute URLs. + */ + protected IURLGenerator $urlGenerator, + ) { } /** @@ -114,28 +87,28 @@ class PublishPlugin extends ServerPlugin { $this->server = $server; $this->server->on('method:POST', [$this, 'httpPost']); - $this->server->on('propFind', [$this, 'propFind']); + $this->server->on('propFind', [$this, 'propFind']); } public function propFind(PropFind $propFind, INode $node) { if ($node instanceof Calendar) { - $propFind->handle('{'.self::NS_CALENDARSERVER.'}publish-url', function () use ($node) { + $propFind->handle('{' . self::NS_CALENDARSERVER . '}publish-url', function () use ($node) { if ($node->getPublishStatus()) { // We return the publish-url only if the calendar is published. $token = $node->getPublishStatus(); - $publishUrl = $this->urlGenerator->getAbsoluteURL($this->server->getBaseUri().'public-calendars/').$token; + $publishUrl = $this->urlGenerator->getAbsoluteURL($this->server->getBaseUri() . 'public-calendars/') . $token; return new Publisher($publishUrl, true); } }); - $propFind->handle('{'.self::NS_CALENDARSERVER.'}allowed-sharing-modes', function () use ($node) { + $propFind->handle('{' . self::NS_CALENDARSERVER . '}allowed-sharing-modes', function () use ($node) { $canShare = (!$node->isSubscription() && $node->canWrite()); $canPublish = (!$node->isSubscription() && $node->canWrite()); if ($this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes') { - $canShare &= ($node->getOwner() === $node->getPrincipalURI()); - $canPublish &= ($node->getOwner() === $node->getPrincipalURI()); + $canShare = $canShare && ($node->getOwner() === $node->getPrincipalURI()); + $canPublish = $canPublish && ($node->getOwner() === $node->getPrincipalURI()); } return new AllowedSharingModes($canShare, $canPublish); @@ -155,8 +128,8 @@ class PublishPlugin extends ServerPlugin { $path = $request->getPath(); // Only handling xml - $contentType = $request->getHeader('Content-Type'); - if (strpos($contentType, 'application/xml') === false && strpos($contentType, 'text/xml') === false) { + $contentType = (string)$request->getHeader('Content-Type'); + if (!str_contains($contentType, 'application/xml') && !str_contains($contentType, 'text/xml')) { return; } @@ -182,74 +155,74 @@ class PublishPlugin extends ServerPlugin { switch ($documentType) { - case '{'.self::NS_CALENDARSERVER.'}publish-calendar': + case '{' . self::NS_CALENDARSERVER . '}publish-calendar': - // We can only deal with IShareableCalendar objects - if (!$node instanceof Calendar) { - return; - } - $this->server->transactionType = 'post-publish-calendar'; + // We can only deal with IShareableCalendar objects + if (!$node instanceof Calendar) { + return; + } + $this->server->transactionType = 'post-publish-calendar'; - // Getting ACL info - $acl = $this->server->getPlugin('acl'); + // Getting ACL info + $acl = $this->server->getPlugin('acl'); - // If there's no ACL support, we allow everything - if ($acl) { - /** @var \Sabre\DAVACL\Plugin $acl */ - $acl->checkPrivileges($path, '{DAV:}write'); + // If there's no ACL support, we allow everything + if ($acl) { + /** @var \Sabre\DAVACL\Plugin $acl */ + $acl->checkPrivileges($path, '{DAV:}write'); - $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; - $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); - if ($limitSharingToOwner && !$isOwner) { - return; + $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; + $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); + if ($limitSharingToOwner && !$isOwner) { + return; + } } - } - $node->setPublishStatus(true); + $node->setPublishStatus(true); - // iCloud sends back the 202, so we will too. - $response->setStatus(202); + // iCloud sends back the 202, so we will too. + $response->setStatus(Http::STATUS_ACCEPTED); - // Adding this because sending a response body may cause issues, - // and I wanted some type of indicator the response was handled. - $response->setHeader('X-Sabre-Status', 'everything-went-well'); + // Adding this because sending a response body may cause issues, + // and I wanted some type of indicator the response was handled. + $response->setHeader('X-Sabre-Status', 'everything-went-well'); - // Breaking the event chain - return false; + // Breaking the event chain + return false; - case '{'.self::NS_CALENDARSERVER.'}unpublish-calendar': + case '{' . self::NS_CALENDARSERVER . '}unpublish-calendar': - // We can only deal with IShareableCalendar objects - if (!$node instanceof Calendar) { - return; - } - $this->server->transactionType = 'post-unpublish-calendar'; + // We can only deal with IShareableCalendar objects + if (!$node instanceof Calendar) { + return; + } + $this->server->transactionType = 'post-unpublish-calendar'; - // Getting ACL info - $acl = $this->server->getPlugin('acl'); + // Getting ACL info + $acl = $this->server->getPlugin('acl'); - // If there's no ACL support, we allow everything - if ($acl) { - /** @var \Sabre\DAVACL\Plugin $acl */ - $acl->checkPrivileges($path, '{DAV:}write'); + // If there's no ACL support, we allow everything + if ($acl) { + /** @var \Sabre\DAVACL\Plugin $acl */ + $acl->checkPrivileges($path, '{DAV:}write'); - $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; - $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); - if ($limitSharingToOwner && !$isOwner) { - return; + $limitSharingToOwner = $this->config->getAppValue('dav', 'limitAddressBookAndCalendarSharingToOwner', 'no') === 'yes'; + $isOwner = $acl->getCurrentUserPrincipal() === $node->getOwner(); + if ($limitSharingToOwner && !$isOwner) { + return; + } } - } - $node->setPublishStatus(false); + $node->setPublishStatus(false); - $response->setStatus(200); + $response->setStatus(Http::STATUS_OK); - // Adding this because sending a response body may cause issues, - // and I wanted some type of indicator the response was handled. - $response->setHeader('X-Sabre-Status', 'everything-went-well'); + // Adding this because sending a response body may cause issues, + // and I wanted some type of indicator the response was handled. + $response->setHeader('X-Sabre-Status', 'everything-went-well'); - // Breaking the event chain - return false; + // Breaking the event chain + return false; } } diff --git a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php index 35bce872bf8..fb9b7298f9b 100644 --- a/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php +++ b/apps/dav/lib/CalDAV/Publishing/Xml/Publisher.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2016 Thomas Citharel <tcit@tcit.fr> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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\Publishing\Xml; @@ -29,22 +12,13 @@ use Sabre\Xml\XmlSerializable; class Publisher implements XmlSerializable { /** - * @var string $publishUrl - */ - protected $publishUrl; - - /** - * @var boolean $isPublished - */ - protected $isPublished; - - /** * @param string $publishUrl * @param boolean $isPublished */ - public function __construct($publishUrl, $isPublished) { - $this->publishUrl = $publishUrl; - $this->isPublished = $isPublished; + public function __construct( + protected $publishUrl, + protected $isPublished, + ) { } /** @@ -55,7 +29,7 @@ class Publisher implements XmlSerializable { } /** - * The xmlSerialize metod is called during xml writing. + * The xmlSerialize method is called during xml writing. * * Use the $writer argument to write its own xml serialization. * diff --git a/apps/dav/lib/CalDAV/Reminder/Backend.php b/apps/dav/lib/CalDAV/Reminder/Backend.php index b0476e9594c..329af3a2f56 100644 --- a/apps/dav/lib/CalDAV/Reminder/Backend.php +++ b/apps/dav/lib/CalDAV/Reminder/Backend.php @@ -3,28 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; @@ -38,22 +18,16 @@ use OCP\IDBConnection; */ class Backend { - /** @var IDBConnection */ - protected $db; - - /** @var ITimeFactory */ - private $timeFactory; - /** * Backend constructor. * * @param IDBConnection $db * @param ITimeFactory $timeFactory */ - public function __construct(IDBConnection $db, - ITimeFactory $timeFactory) { - $this->db = $db; - $this->timeFactory = $timeFactory; + public function __construct( + protected IDBConnection $db, + protected ITimeFactory $timeFactory, + ) { } /** @@ -64,12 +38,13 @@ class Backend { */ public function getRemindersToProcess():array { $query = $this->db->getQueryBuilder(); - $query->select(['cr.*', 'co.calendardata', 'c.displayname', 'c.principaluri']) + $query->select(['cr.id', 'cr.calendar_id','cr.object_id','cr.is_recurring','cr.uid','cr.recurrence_id','cr.is_recurrence_exception','cr.event_hash','cr.alarm_hash','cr.type','cr.is_relative','cr.notification_date','cr.is_repeat_based','co.calendardata', 'c.displayname', 'c.principaluri']) ->from('calendar_reminders', 'cr') ->where($query->expr()->lte('cr.notification_date', $query->createNamedParameter($this->timeFactory->getTime()))) ->join('cr', 'calendarobjects', 'co', $query->expr()->eq('cr.object_id', 'co.id')) - ->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id')); - $stmt = $query->execute(); + ->join('cr', 'calendars', 'c', $query->expr()->eq('cr.calendar_id', 'c.id')) + ->groupBy('cr.event_hash', 'cr.notification_date', 'cr.type', 'cr.id', 'cr.calendar_id', 'cr.object_id', 'cr.is_recurring', 'cr.uid', 'cr.recurrence_id', 'cr.is_recurrence_exception', 'cr.alarm_hash', 'cr.is_relative', 'cr.is_repeat_based', 'co.calendardata', 'c.displayname', 'c.principaluri'); + $stmt = $query->executeQuery(); return array_map( [$this, 'fixRowTyping'], @@ -88,7 +63,7 @@ class Backend { $query->select('*') ->from('calendar_reminders') ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId))); - $stmt = $query->execute(); + $stmt = $query->executeQuery(); return array_map( [$this, 'fixRowTyping'], @@ -114,17 +89,17 @@ class Backend { * @return int The insert id */ public function insertReminder(int $calendarId, - int $objectId, - string $uid, - bool $isRecurring, - int $recurrenceId, - bool $isRecurrenceException, - string $eventHash, - string $alarmHash, - string $type, - bool $isRelative, - int $notificationDate, - bool $isRepeatBased):int { + int $objectId, + string $uid, + bool $isRecurring, + int $recurrenceId, + bool $isRecurrenceException, + string $eventHash, + string $alarmHash, + string $type, + bool $isRelative, + int $notificationDate, + bool $isRepeatBased):int { $query = $this->db->getQueryBuilder(); $query->insert('calendar_reminders') ->values([ @@ -141,7 +116,7 @@ class Backend { 'notification_date' => $query->createNamedParameter($notificationDate), 'is_repeat_based' => $query->createNamedParameter($isRepeatBased ? 1 : 0), ]) - ->execute(); + ->executeStatement(); return $query->getLastInsertId(); } @@ -153,12 +128,12 @@ class Backend { * @param int $newNotificationDate */ public function updateReminder(int $reminderId, - int $newNotificationDate):void { + int $newNotificationDate):void { $query = $this->db->getQueryBuilder(); $query->update('calendar_reminders') ->set('notification_date', $query->createNamedParameter($newNotificationDate)) ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId))) - ->execute(); + ->executeStatement(); } /** @@ -172,7 +147,7 @@ class Backend { $query->delete('calendar_reminders') ->where($query->expr()->eq('id', $query->createNamedParameter($reminderId))) - ->execute(); + ->executeStatement(); } /** @@ -185,7 +160,7 @@ class Backend { $query->delete('calendar_reminders') ->where($query->expr()->eq('object_id', $query->createNamedParameter($objectId))) - ->execute(); + ->executeStatement(); } /** @@ -199,7 +174,7 @@ class Backend { $query->delete('calendar_reminders') ->where($query->expr()->eq('calendar_id', $query->createNamedParameter($calendarId))) - ->execute(); + ->executeStatement(); } /** @@ -207,15 +182,15 @@ class Backend { * @return array */ private function fixRowTyping(array $row): array { - $row['id'] = (int) $row['id']; - $row['calendar_id'] = (int) $row['calendar_id']; - $row['object_id'] = (int) $row['object_id']; - $row['is_recurring'] = (bool) $row['is_recurring']; - $row['recurrence_id'] = (int) $row['recurrence_id']; - $row['is_recurrence_exception'] = (bool) $row['is_recurrence_exception']; - $row['is_relative'] = (bool) $row['is_relative']; - $row['notification_date'] = (int) $row['notification_date']; - $row['is_repeat_based'] = (bool) $row['is_repeat_based']; + $row['id'] = (int)$row['id']; + $row['calendar_id'] = (int)$row['calendar_id']; + $row['object_id'] = (int)$row['object_id']; + $row['is_recurring'] = (bool)$row['is_recurring']; + $row['recurrence_id'] = (int)$row['recurrence_id']; + $row['is_recurrence_exception'] = (bool)$row['is_recurrence_exception']; + $row['is_relative'] = (bool)$row['is_relative']; + $row['notification_date'] = (int)$row['notification_date']; + $row['is_repeat_based'] = (bool)$row['is_repeat_based']; return $row; } diff --git a/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php index a6b439c0b4f..31d60f1531d 100644 --- a/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/INotificationProvider.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; @@ -41,11 +22,13 @@ interface INotificationProvider { * Send notification * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses All email addresses associated to the principal owning the calendar object * @param IUser[] $users * @return void */ public function send(VEvent $vevent, - string $calendarDisplayName, - array $users = []): void; + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []): void; } diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php index 044e5fac4e2..94edff98e52 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AbstractProvider.php @@ -3,39 +3,18 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; use OCA\DAV\CalDAV\Reminder\INotificationProvider; use OCP\IConfig; use OCP\IL10N; -use OCP\ILogger; use OCP\IURLGenerator; use OCP\IUser; use OCP\L10N\IFactory as L10NFactory; +use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VEvent; use Sabre\VObject\DateTimeParser; use Sabre\VObject\Property; @@ -50,51 +29,33 @@ abstract class AbstractProvider implements INotificationProvider { /** @var string */ public const NOTIFICATION_TYPE = ''; - /** @var ILogger */ - protected $logger; - - /** @var L10NFactory */ - protected $l10nFactory; - /** @var IL10N[] */ private $l10ns; /** @var string */ private $fallbackLanguage; - /** @var IURLGenerator */ - protected $urlGenerator; - - /** @var IConfig */ - protected $config; - - /** - * @param ILogger $logger - * @param L10NFactory $l10nFactory - * @param IConfig $config - * @param IUrlGenerator $urlGenerator - */ - public function __construct(ILogger $logger, - L10NFactory $l10nFactory, - IURLGenerator $urlGenerator, - IConfig $config) { - $this->logger = $logger; - $this->l10nFactory = $l10nFactory; - $this->urlGenerator = $urlGenerator; - $this->config = $config; + public function __construct( + protected LoggerInterface $logger, + protected L10NFactory $l10nFactory, + protected IURLGenerator $urlGenerator, + protected IConfig $config, + ) { } /** * Send notification * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses * @param IUser[] $users * @return void */ abstract public function send(VEvent $vevent, - string $calendarDisplayName, - array $users = []): void; + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []): void; /** * @return string @@ -139,7 +100,7 @@ abstract class AbstractProvider implements INotificationProvider { */ private function getStatusOfEvent(VEvent $vevent):string { if ($vevent->STATUS) { - return (string) $vevent->STATUS; + return (string)$vevent->STATUS; } // Doesn't say so in the standard, @@ -189,4 +150,8 @@ abstract class AbstractProvider implements INotificationProvider { return clone $vevent->DTSTART; } + + protected function getCalendarDisplayNameFallback(string $lang): string { + return $this->getL10NForLang($lang)->t('Untitled calendar'); + } } diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php index 4b369b34dc0..01d51489a3b 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/AudioProvider.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php index 456b9f8b42d..0fd39a9e459 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/EmailProvider.php @@ -3,42 +3,22 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Richard Steinmetz <richard@steinmetz.cloud> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; use DateTime; use OCP\IConfig; use OCP\IL10N; -use OCP\ILogger; use OCP\IURLGenerator; +use OCP\IUser; use OCP\L10N\IFactory as L10NFactory; +use OCP\Mail\Headers\AutoSubmitted; use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; +use OCP\Util; +use Psr\Log\LoggerInterface; use Sabre\VObject; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Parameter; @@ -50,44 +30,46 @@ use Sabre\VObject\Property; * @package OCA\DAV\CalDAV\Reminder\NotificationProvider */ class EmailProvider extends AbstractProvider { - /** @var string */ public const NOTIFICATION_TYPE = 'EMAIL'; - /** @var IMailer */ - private $mailer; - - /** - * @param IConfig $config - * @param IMailer $mailer - * @param ILogger $logger - * @param L10NFactory $l10nFactory - * @param IUrlGenerator $urlGenerator - */ - public function __construct(IConfig $config, - IMailer $mailer, - ILogger $logger, - L10NFactory $l10nFactory, - IURLGenerator $urlGenerator) { + public function __construct( + IConfig $config, + private IMailer $mailer, + LoggerInterface $logger, + L10NFactory $l10nFactory, + IURLGenerator $urlGenerator, + ) { parent::__construct($logger, $l10nFactory, $urlGenerator, $config); - $this->mailer = $mailer; } /** * Send out notification via email * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses * @param array $users * @throws \Exception */ public function send(VEvent $vevent, - string $calendarDisplayName, - array $users = []):void { + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []):void { $fallbackLanguage = $this->getFallbackLanguage(); + $organizerEmailAddress = null; + if (isset($vevent->ORGANIZER)) { + $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); + } + $emailAddressesOfSharees = $this->getEMailAddressesOfAllUsersWithWriteAccessToCalendar($users); - $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent); + $emailAddressesOfAttendees = []; + if (count($principalEmailAddresses) === 0 + || ($organizerEmailAddress && in_array($organizerEmailAddress, $principalEmailAddresses, true)) + ) { + $emailAddressesOfAttendees = $this->getAllEMailAddressesFromEvent($vevent); + } // Quote from php.net: // If the input arrays have the same string keys, then the later value for that key will overwrite the previous one. @@ -105,12 +87,12 @@ class EmailProvider extends AbstractProvider { $lang = $fallbackLanguage; } $l10n = $this->getL10NForLang($lang); - $fromEMail = \OCP\Util::getDefaultEmailAddress('reminders-noreply'); + $fromEMail = Util::getDefaultEmailAddress('reminders-noreply'); $template = $this->mailer->createEMailTemplate('dav.calendarReminder'); $template->addHeader(); $this->addSubjectAndHeading($template, $l10n, $vevent); - $this->addBulletList($template, $l10n, $calendarDisplayName, $vevent); + $this->addBulletList($template, $l10n, $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($lang), $vevent); $template->addFooter(); foreach ($emailAddresses as $emailAddress) { @@ -126,6 +108,7 @@ class EmailProvider extends AbstractProvider { } $message->setTo([$emailAddress]); $message->useTemplate($template); + $message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED); try { $failed = $this->mailer->send($message); @@ -133,7 +116,7 @@ class EmailProvider extends AbstractProvider { $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); } } catch (\Exception $ex) { - $this->logger->logException($ex, ['app' => 'dav']); + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); } } } @@ -156,9 +139,9 @@ class EmailProvider extends AbstractProvider { * @param array $eventData */ private function addBulletList(IEMailTemplate $template, - IL10N $l10n, - string $calendarDisplayName, - VEvent $vevent):void { + IL10N $l10n, + string $calendarDisplayName, + VEvent $vevent):void { $template->addBodyListItem($calendarDisplayName, $l10n->t('Calendar:'), $this->getAbsoluteImagePath('actions/info.png')); @@ -166,19 +149,15 @@ class EmailProvider extends AbstractProvider { $this->getAbsoluteImagePath('places/calendar.png')); if (isset($vevent->LOCATION)) { - $template->addBodyListItem((string) $vevent->LOCATION, $l10n->t('Where:'), + $template->addBodyListItem((string)$vevent->LOCATION, $l10n->t('Where:'), $this->getAbsoluteImagePath('actions/address.png')); } if (isset($vevent->DESCRIPTION)) { - $template->addBodyListItem((string) $vevent->DESCRIPTION, $l10n->t('Description:'), + $template->addBodyListItem((string)$vevent->DESCRIPTION, $l10n->t('Description:'), $this->getAbsoluteImagePath('actions/more.png')); } } - /** - * @param string $path - * @return string - */ private function getAbsoluteImagePath(string $path):string { return $this->urlGenerator->getAbsoluteURL( $this->urlGenerator->imagePath('core', $path) @@ -201,7 +180,7 @@ class EmailProvider extends AbstractProvider { $organizerEMail = substr($organizer->getValue(), 7); - if ($organizerEMail === false || !$this->mailer->validateMailAddress($organizerEMail)) { + if (!$this->mailer->validateMailAddress($organizerEMail)) { return null; } @@ -214,12 +193,11 @@ class EmailProvider extends AbstractProvider { } /** - * @param array $emails - * @param string $defaultLanguage - * @return array + * @param array<string, array{LANG?: string}> $emails + * @return array<string, string[]> */ private function sortEMailAddressesByLanguage(array $emails, - string $defaultLanguage):array { + string $defaultLanguage):array { $sortedByLanguage = []; foreach ($emails as $emailAddress => $parameters) { @@ -241,7 +219,7 @@ class EmailProvider extends AbstractProvider { /** * @param VEvent $vevent - * @return array + * @return array<string, array{LANG?: string}> */ private function getAllEMailAddressesFromEvent(VEvent $vevent):array { $emailAddresses = []; @@ -272,7 +250,10 @@ class EmailProvider extends AbstractProvider { $emailAddressesOfDelegates = $delegates->getParts(); foreach ($emailAddressesOfDelegates as $addressesOfDelegate) { if (strcasecmp($addressesOfDelegate, 'mailto:') === 0) { - $emailAddresses[substr($addressesOfDelegate, 7)] = []; + $delegateEmail = substr($addressesOfDelegate, 7); + if ($this->mailer->validateMailAddress($delegateEmail)) { + $emailAddresses[$delegateEmail] = []; + } } } @@ -284,7 +265,7 @@ class EmailProvider extends AbstractProvider { $properties = []; $langProp = $attendee->offsetGet('LANG'); - if ($langProp instanceof VObject\Parameter) { + if ($langProp instanceof VObject\Parameter && $langProp->getValue() !== null) { $properties['LANG'] = $langProp->getValue(); } @@ -294,18 +275,15 @@ class EmailProvider extends AbstractProvider { } if (isset($vevent->ORGANIZER) && $this->hasAttendeeMailURI($vevent->ORGANIZER)) { - $emailAddresses[$this->getEMailAddressOfAttendee($vevent->ORGANIZER)] = []; + $organizerEmailAddress = $this->getEMailAddressOfAttendee($vevent->ORGANIZER); + if ($organizerEmailAddress !== null) { + $emailAddresses[$organizerEmailAddress] = []; + } } return $emailAddresses; } - - - /** - * @param VObject\Property $attendee - * @return string - */ private function getCUTypeOfAttendee(VObject\Property $attendee):string { $cuType = $attendee->offsetGet('CUTYPE'); if ($cuType instanceof VObject\Parameter) { @@ -315,10 +293,6 @@ class EmailProvider extends AbstractProvider { return 'INDIVIDUAL'; } - /** - * @param VObject\Property $attendee - * @return string - */ private function getPartstatOfAttendee(VObject\Property $attendee):string { $partstat = $attendee->offsetGet('PARTSTAT'); if ($partstat instanceof VObject\Parameter) { @@ -328,29 +302,25 @@ class EmailProvider extends AbstractProvider { return 'NEEDS-ACTION'; } - /** - * @param VObject\Property $attendee - * @return bool - */ - private function hasAttendeeMailURI(VObject\Property $attendee):bool { + private function hasAttendeeMailURI(VObject\Property $attendee): bool { return stripos($attendee->getValue(), 'mailto:') === 0; } - /** - * @param VObject\Property $attendee - * @return string|null - */ - private function getEMailAddressOfAttendee(VObject\Property $attendee):?string { + private function getEMailAddressOfAttendee(VObject\Property $attendee): ?string { if (!$this->hasAttendeeMailURI($attendee)) { return null; } + $attendeeEMail = substr($attendee->getValue(), 7); + if (!$this->mailer->validateMailAddress($attendeeEMail)) { + return null; + } - return substr($attendee->getValue(), 7); + return $attendeeEMail; } /** - * @param array $users - * @return array + * @param IUser[] $users + * @return array<string, array{LANG?: string}> */ private function getEMailAddressesOfAllUsersWithWriteAccessToCalendar(array $users):array { $emailAddresses = []; @@ -373,12 +343,9 @@ class EmailProvider extends AbstractProvider { } /** - * @param IL10N $l10n - * @param VEvent $vevent - * @return string * @throws \Exception */ - private function generateDateString(IL10N $l10n, VEvent $vevent):string { + private function generateDateString(IL10N $l10n, VEvent $vevent): string { $isAllDay = $vevent->DTSTART instanceof Property\ICalendar\Date; /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ @@ -444,57 +411,27 @@ class EmailProvider extends AbstractProvider { . ' (' . $startTimezone . ')'; } - /** - * @param DateTime $dtStart - * @param DateTime $dtEnd - * @return bool - */ private function isDayEqual(DateTime $dtStart, - DateTime $dtEnd):bool { + DateTime $dtEnd):bool { return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getWeekDayName(IL10N $l10n, DateTime $dt):string { - return $l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); + return (string)$l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getDateString(IL10N $l10n, DateTime $dt):string { - return $l10n->l('date', $dt, ['width' => 'medium']); + return (string)$l10n->l('date', $dt, ['width' => 'medium']); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getDateTimeString(IL10N $l10n, DateTime $dt):string { - return $l10n->l('datetime', $dt, ['width' => 'medium|short']); + return (string)$l10n->l('datetime', $dt, ['width' => 'medium|short']); } - /** - * @param IL10N $l10n - * @param DateTime $dt - * @return string - */ private function getTimeString(IL10N $l10n, DateTime $dt):string { - return $l10n->l('time', $dt, ['width' => 'short']); + return (string)$l10n->l('time', $dt, ['width' => 'short']); } - /** - * @param VEvent $vevent - * @param IL10N $l10n - * @return string - */ private function getTitleFromVEvent(VEvent $vevent, IL10N $l10n):string { if (isset($vevent->SUMMARY)) { return (string)$vevent->SUMMARY; diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php index 2e4f9a38493..15994bacf49 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/ProviderNotAvailableException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Thomas Citharel <tcit@tcit.fr> - * - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php index fb123960df8..a3f0cce547a 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProvider/PushProvider.php @@ -3,41 +3,20 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder\NotificationProvider; use OCA\DAV\AppInfo\Application; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; -use OCP\ILogger; use OCP\IURLGenerator; use OCP\IUser; use OCP\L10N\IFactory as L10NFactory; use OCP\Notification\IManager; use OCP\Notification\INotification; +use Psr\Log\LoggerInterface; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Property; @@ -51,55 +30,44 @@ class PushProvider extends AbstractProvider { /** @var string */ public const NOTIFICATION_TYPE = 'DISPLAY'; - /** @var IManager */ - private $manager; - - /** @var ITimeFactory */ - private $timeFactory; - - /** - * @param IConfig $config - * @param IManager $manager - * @param ILogger $logger - * @param L10NFactory $l10nFactory - * @param IUrlGenerator $urlGenerator - * @param ITimeFactory $timeFactory - */ - public function __construct(IConfig $config, - IManager $manager, - ILogger $logger, - L10NFactory $l10nFactory, - IURLGenerator $urlGenerator, - ITimeFactory $timeFactory) { + public function __construct( + IConfig $config, + private IManager $manager, + LoggerInterface $logger, + L10NFactory $l10nFactory, + IURLGenerator $urlGenerator, + private ITimeFactory $timeFactory, + ) { parent::__construct($logger, $l10nFactory, $urlGenerator, $config); - $this->manager = $manager; - $this->timeFactory = $timeFactory; } /** * Send push notification to all users. * * @param VEvent $vevent - * @param string $calendarDisplayName + * @param string|null $calendarDisplayName + * @param string[] $principalEmailAddresses * @param IUser[] $users * @throws \Exception */ public function send(VEvent $vevent, - string $calendarDisplayName = null, - array $users = []):void { - if ($this->config->getAppValue('dav', 'sendEventRemindersPush', 'no') !== 'yes') { + ?string $calendarDisplayName, + array $principalEmailAddresses, + array $users = []):void { + if ($this->config->getAppValue('dav', 'sendEventRemindersPush', 'yes') !== 'yes') { return; } $eventDetails = $this->extractEventDetails($vevent); - $eventDetails['calendar_displayname'] = $calendarDisplayName; - $eventUUID = (string) $vevent->UID; + $eventUUID = (string)$vevent->UID; if (!$eventUUID) { return; }; $eventUUIDHash = hash('sha256', $eventUUID, false); foreach ($users as $user) { + $eventDetails['calendar_displayname'] = $calendarDisplayName ?? $this->getCalendarDisplayNameFallback($this->l10nFactory->getUserLanguage($user)); + /** @var INotification $notification */ $notification = $this->manager->createNotification(); $notification->setApp(Application::APP_ID) @@ -117,8 +85,6 @@ class PushProvider extends AbstractProvider { } /** - * @var VEvent $vevent - * @return array * @throws \Exception */ protected function extractEventDetails(VEvent $vevent):array { @@ -128,13 +94,13 @@ class PushProvider extends AbstractProvider { return [ 'title' => isset($vevent->SUMMARY) - ? ((string) $vevent->SUMMARY) + ? ((string)$vevent->SUMMARY) : null, 'description' => isset($vevent->DESCRIPTION) - ? ((string) $vevent->DESCRIPTION) + ? ((string)$vevent->DESCRIPTION) : null, 'location' => isset($vevent->LOCATION) - ? ((string) $vevent->LOCATION) + ? ((string)$vevent->LOCATION) : null, 'all_day' => $start instanceof Property\ICalendar\Date, 'start_atom' => $start->getDateTime()->format(\DateTimeInterface::ATOM), diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php index cd8030a1177..265db09b061 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationProviderManager.php @@ -3,30 +3,15 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Georg Ehrke <oc.list@georgehrke.com> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; +use OCA\DAV\CalDAV\Reminder\NotificationProvider\ProviderNotAvailableException; +use OCP\AppFramework\QueryException; +use OCP\Server; + /** * Class NotificationProviderManager * @@ -61,7 +46,7 @@ class NotificationProviderManager { if (isset($this->providers[$type])) { return $this->providers[$type]; } - throw new NotificationProvider\ProviderNotAvailableException($type); + throw new ProviderNotAvailableException($type); } throw new NotificationTypeDoesNotExistException($type); } @@ -70,10 +55,10 @@ class NotificationProviderManager { * Registers a new provider * * @param string $providerClassName - * @throws \OCP\AppFramework\QueryException + * @throws QueryException */ public function registerProvider(string $providerClassName):void { - $provider = \OC::$server->query($providerClassName); + $provider = Server::get($providerClassName); if (!$provider instanceof INotificationProvider) { throw new \InvalidArgumentException('Invalid notification provider registered'); diff --git a/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php index 16fb858bc3a..6fd2a29ede5 100644 --- a/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php +++ b/apps/dav/lib/CalDAV/Reminder/NotificationTypeDoesNotExistException.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; diff --git a/apps/dav/lib/CalDAV/Reminder/Notifier.php b/apps/dav/lib/CalDAV/Reminder/Notifier.php index 8535c55054a..137fb509f56 100644 --- a/apps/dav/lib/CalDAV/Reminder/Notifier.php +++ b/apps/dav/lib/CalDAV/Reminder/Notifier.php @@ -3,29 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; @@ -38,6 +17,7 @@ use OCP\L10N\IFactory; use OCP\Notification\AlreadyProcessedException; use OCP\Notification\INotification; use OCP\Notification\INotifier; +use OCP\Notification\UnknownNotificationException; /** * Class Notifier @@ -46,31 +26,21 @@ use OCP\Notification\INotifier; */ class Notifier implements INotifier { - /** @var IFactory */ - private $l10nFactory; - - /** @var IURLGenerator */ - private $urlGenerator; - /** @var IL10N */ private $l10n; - /** @var ITimeFactory */ - private $timeFactory; - /** * Notifier constructor. * - * @param IFactory $factory + * @param IFactory $l10nFactory * @param IURLGenerator $urlGenerator * @param ITimeFactory $timeFactory */ - public function __construct(IFactory $factory, - IURLGenerator $urlGenerator, - ITimeFactory $timeFactory) { - $this->l10nFactory = $factory; - $this->urlGenerator = $urlGenerator; - $this->timeFactory = $timeFactory; + public function __construct( + private IFactory $l10nFactory, + private IURLGenerator $urlGenerator, + private ITimeFactory $timeFactory, + ) { } /** @@ -99,12 +69,12 @@ class Notifier implements INotifier { * @param INotification $notification * @param string $languageCode The code of the language that should be used to prepare the notification * @return INotification - * @throws \Exception + * @throws UnknownNotificationException */ public function prepare(INotification $notification, - string $languageCode):INotification { + string $languageCode):INotification { if ($notification->getApp() !== Application::APP_ID) { - throw new \InvalidArgumentException('Notification not from this app'); + throw new UnknownNotificationException('Notification not from this app'); } // Read the language from the notification @@ -116,7 +86,7 @@ class Notifier implements INotifier { return $this->prepareReminderNotification($notification); default: - throw new \InvalidArgumentException('Unknown subject'); + throw new UnknownNotificationException('Unknown subject'); } } @@ -170,21 +140,35 @@ class Notifier implements INotifier { $components[] = $this->l10n->n('%n minute', '%n minutes', $diff->i); } - // Limiting to the first three components to prevent - // the string from getting too long - $firstThreeComponents = array_slice($components, 0, 2); - $diffLabel = implode(', ', $firstThreeComponents); + if (count($components) > 0 && !$this->hasPhpDatetimeDiffBug()) { + // Limiting to the first three components to prevent + // the string from getting too long + $firstThreeComponents = array_slice($components, 0, 2); + $diffLabel = implode(', ', $firstThreeComponents); - if ($diff->invert) { - $title = $this->l10n->t('%s (in %s)', [$title, $diffLabel]); - } else { - $title = $this->l10n->t('%s (%s ago)', [$title, $diffLabel]); + if ($diff->invert) { + $title = $this->l10n->t('%s (in %s)', [$title, $diffLabel]); + } else { + $title = $this->l10n->t('%s (%s ago)', [$title, $diffLabel]); + } } $notification->setParsedSubject($title); } /** + * @see https://github.com/nextcloud/server/issues/41615 + * @see https://github.com/php/php-src/issues/9699 + */ + private function hasPhpDatetimeDiffBug(): bool { + $d1 = DateTime::createFromFormat(\DateTimeInterface::ATOM, '2023-11-22T11:52:00+01:00'); + $d2 = new DateTime('2023-11-22T10:52:03', new \DateTimeZone('UTC')); + + // The difference is 3 seconds, not -1year+11months+… + return $d1->diff($d2)->y < 0; + } + + /** * Sets the notification message based on the parameters set in PushProvider * * @param INotification $notification @@ -289,7 +273,7 @@ class Notifier implements INotifier { * @return bool */ private function isDayEqual(DateTime $dtStart, - DateTime $dtEnd):bool { + DateTime $dtEnd):bool { return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); } @@ -298,7 +282,7 @@ class Notifier implements INotifier { * @return string */ private function getWeekDayName(DateTime $dt):string { - return $this->l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); + return (string)$this->l10n->l('weekdayName', $dt, ['width' => 'abbreviated']); } /** @@ -306,7 +290,7 @@ class Notifier implements INotifier { * @return string */ private function getDateString(DateTime $dt):string { - return $this->l10n->l('date', $dt, ['width' => 'medium']); + return (string)$this->l10n->l('date', $dt, ['width' => 'medium']); } /** @@ -314,7 +298,7 @@ class Notifier implements INotifier { * @return string */ private function getDateTimeString(DateTime $dt):string { - return $this->l10n->l('datetime', $dt, ['width' => 'medium|short']); + return (string)$this->l10n->l('datetime', $dt, ['width' => 'medium|short']); } /** @@ -322,6 +306,6 @@ class Notifier implements INotifier { * @return string */ private function getTimeString(DateTime $dt):string { - return $this->l10n->l('time', $dt, ['width' => 'short']); + return (string)$this->l10n->l('time', $dt, ['width' => 'short']); } } diff --git a/apps/dav/lib/CalDAV/Reminder/ReminderService.php b/apps/dav/lib/CalDAV/Reminder/ReminderService.php index d6901cc4fb0..c75090e1560 100644 --- a/apps/dav/lib/CalDAV/Reminder/ReminderService.php +++ b/apps/dav/lib/CalDAV/Reminder/ReminderService.php @@ -3,73 +3,35 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Thomas Citharel - * @copyright Copyright (c) 2019, Georg Ehrke - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\Reminder; use DateTimeImmutable; +use DateTimeZone; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Connector\Sabre\Principal; use OCP\AppFramework\Utility\ITimeFactory; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; use OCP\IUserManager; +use Psr\Log\LoggerInterface; use Sabre\VObject; use Sabre\VObject\Component\VAlarm; use Sabre\VObject\Component\VEvent; use Sabre\VObject\InvalidDataException; use Sabre\VObject\ParseException; use Sabre\VObject\Recur\EventIterator; +use Sabre\VObject\Recur\MaxInstancesExceededException; use Sabre\VObject\Recur\NoInstancesException; +use function count; use function strcasecmp; class ReminderService { - /** @var Backend */ - private $backend; - - /** @var NotificationProviderManager */ - private $notificationProviderManager; - - /** @var IUserManager */ - private $userManager; - - /** @var IGroupManager */ - private $groupManager; - - /** @var CalDavBackend */ - private $caldavBackend; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var IConfig */ - private $config; - public const REMINDER_TYPE_EMAIL = 'EMAIL'; public const REMINDER_TYPE_DISPLAY = 'DISPLAY'; public const REMINDER_TYPE_AUDIO = 'AUDIO'; @@ -85,31 +47,17 @@ class ReminderService { self::REMINDER_TYPE_AUDIO ]; - /** - * ReminderService constructor. - * - * @param Backend $backend - * @param NotificationProviderManager $notificationProviderManager - * @param IUserManager $userManager - * @param IGroupManager $groupManager - * @param CalDavBackend $caldavBackend - * @param ITimeFactory $timeFactory - * @param IConfig $config - */ - public function __construct(Backend $backend, - NotificationProviderManager $notificationProviderManager, - IUserManager $userManager, - IGroupManager $groupManager, - CalDavBackend $caldavBackend, - ITimeFactory $timeFactory, - IConfig $config) { - $this->backend = $backend; - $this->notificationProviderManager = $notificationProviderManager; - $this->userManager = $userManager; - $this->groupManager = $groupManager; - $this->caldavBackend = $caldavBackend; - $this->timeFactory = $timeFactory; - $this->config = $config; + public function __construct( + private Backend $backend, + private NotificationProviderManager $notificationProviderManager, + private IUserManager $userManager, + private IGroupManager $groupManager, + private CalDavBackend $caldavBackend, + private ITimeFactory $timeFactory, + private IConfig $config, + private LoggerInterface $logger, + private Principal $principalConnector, + ) { } /** @@ -118,8 +66,11 @@ class ReminderService { * @throws NotificationProvider\ProviderNotAvailableException * @throws NotificationTypeDoesNotExistException */ - public function processReminders():void { + public function processReminders() :void { $reminders = $this->backend->getRemindersToProcess(); + $this->logger->debug('{numReminders} reminders to process', [ + 'numReminders' => count($reminders), + ]); foreach ($reminders as $reminder) { $calendarData = is_resource($reminder['calendardata']) @@ -132,27 +83,46 @@ class ReminderService { $vcalendar = $this->parseCalendarData($calendarData); if (!$vcalendar) { + $this->logger->debug('Reminder {id} does not belong to a valid calendar', [ + 'id' => $reminder['id'], + ]); + $this->backend->removeReminder($reminder['id']); + continue; + } + + try { + $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']); + } catch (MaxInstancesExceededException $e) { + $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]); $this->backend->removeReminder($reminder['id']); continue; } - $vevent = $this->getVEventByRecurrenceId($vcalendar, $reminder['recurrence_id'], $reminder['is_recurrence_exception']); if (!$vevent) { + $this->logger->debug('Reminder {id} does not belong to a valid event', [ + 'id' => $reminder['id'], + ]); $this->backend->removeReminder($reminder['id']); continue; } if ($this->wasEventCancelled($vevent)) { + $this->logger->debug('Reminder {id} belongs to a cancelled event', [ + 'id' => $reminder['id'], + ]); $this->deleteOrProcessNext($reminder, $vevent); continue; } if (!$this->notificationProviderManager->hasProvider($reminder['type'])) { + $this->logger->debug('Reminder {id} does not belong to a valid notification provider', [ + 'id' => $reminder['id'], + ]); $this->deleteOrProcessNext($reminder, $vevent); continue; } - if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedGroupMembers', 'yes') === 'no') { + if ($this->config->getAppValue('dav', 'sendEventRemindersToSharedUsers', 'yes') === 'no') { $users = $this->getAllUsersWithWriteAccessToCalendar($reminder['calendar_id']); } else { $users = []; @@ -163,8 +133,18 @@ class ReminderService { $users[] = $user; } + $userPrincipalEmailAddresses = []; + $userPrincipal = $this->principalConnector->getPrincipalByPath($reminder['principaluri']); + if ($userPrincipal) { + $userPrincipalEmailAddresses = $this->principalConnector->getEmailAddressesOfPrincipal($userPrincipal); + } + + $this->logger->debug('Reminder {id} will be sent to {numUsers} users', [ + 'id' => $reminder['id'], + 'numUsers' => count($users), + ]); $notificationProvider = $this->notificationProviderManager->getProvider($reminder['type']); - $notificationProvider->send($vevent, $reminder['displayname'], $users); + $notificationProvider->send($vevent, $reminder['displayname'], $userPrincipalEmailAddresses, $users); $this->deleteOrProcessNext($reminder, $vevent); } @@ -188,18 +168,18 @@ class ReminderService { return; } - /** @var VObject\Component\VCalendar $vcalendar */ $vcalendar = $this->parseCalendarData($calendarData); if (!$vcalendar) { return; } + $calendarTimeZone = $this->getCalendarTimeZone((int)$objectData['calendarid']); $vevents = $this->getAllVEventsFromVCalendar($vcalendar); if (count($vevents) === 0) { return; } - $uid = (string) $vevents[0]->UID; + $uid = (string)$vevents[0]->UID; $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $masterItem = $this->getMasterItemFromListOfVEvents($vevents); $now = $this->timeFactory->getDateTime(); @@ -221,7 +201,7 @@ class ReminderService { continue; } - $alarms = $this->getRemindersForVAlarm($valarm, $objectData, + $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $eventHash, $alarmHash, true, true); $this->writeRemindersToDatabase($alarms); } @@ -247,6 +227,10 @@ class ReminderService { // instance. We are skipping this event from the output // entirely. return; + } catch (MaxInstancesExceededException $e) { + // The event has more than 3500 recurring-instances + // so we can ignore it + return; } while ($iterator->valid() && count($processedAlarms) < count($masterAlarms)) { @@ -265,7 +249,7 @@ class ReminderService { continue; } - if (!\in_array((string) $valarm->ACTION, self::REMINDER_TYPES, true)) { + if (!\in_array((string)$valarm->ACTION, self::REMINDER_TYPES, true)) { // Action allows x-name, we don't insert reminders // into the database if they are not standard $processedAlarms[] = $alarmHash; @@ -274,6 +258,16 @@ class ReminderService { try { $triggerTime = $valarm->getEffectiveTriggerTime(); + /** + * @psalm-suppress DocblockTypeContradiction + * https://github.com/vimeo/psalm/issues/9244 + */ + if ($triggerTime->getTimezone() === false || $triggerTime->getTimezone()->getName() === 'UTC') { + $triggerTime = new DateTimeImmutable( + $triggerTime->format('Y-m-d H:i:s'), + $calendarTimeZone + ); + } } catch (InvalidDataException $e) { continue; } @@ -292,7 +286,7 @@ class ReminderService { continue; } - $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $masterHash, $alarmHash, $isRecurring, false); + $alarms = $this->getRemindersForVAlarm($valarm, $objectData, $calendarTimeZone, $masterHash, $alarmHash, $isRecurring, false); $this->writeRemindersToDatabase($alarms); $processedAlarms[] = $alarmHash; } @@ -325,12 +319,13 @@ class ReminderService { return; } - $this->backend->cleanRemindersForEvent((int) $objectData['id']); + $this->backend->cleanRemindersForEvent((int)$objectData['id']); } /** * @param VAlarm $valarm * @param array $objectData + * @param DateTimeZone $calendarTimeZone * @param string|null $eventHash * @param string|null $alarmHash * @param bool $isRecurring @@ -338,11 +333,12 @@ class ReminderService { * @return array */ private function getRemindersForVAlarm(VAlarm $valarm, - array $objectData, - string $eventHash = null, - string $alarmHash = null, - bool $isRecurring = false, - bool $isRecurrenceException = false):array { + array $objectData, + DateTimeZone $calendarTimeZone, + ?string $eventHash = null, + ?string $alarmHash = null, + bool $isRecurring = false, + bool $isRecurrenceException = false):array { if ($eventHash === null) { $eventHash = $this->getEventHash($valarm->parent); } @@ -354,6 +350,16 @@ class ReminderService { $isRelative = $this->isAlarmRelative($valarm); /** @var DateTimeImmutable $notificationDate */ $notificationDate = $valarm->getEffectiveTriggerTime(); + /** + * @psalm-suppress DocblockTypeContradiction + * https://github.com/vimeo/psalm/issues/9244 + */ + if ($notificationDate->getTimezone() === false || $notificationDate->getTimezone()->getName() === 'UTC') { + $notificationDate = new DateTimeImmutable( + $notificationDate->format('Y-m-d H:i:s'), + $calendarTimeZone + ); + } $clonedNotificationDate = new \DateTime('now', $notificationDate->getTimezone()); $clonedNotificationDate->setTimestamp($notificationDate->getTimestamp()); @@ -362,19 +368,19 @@ class ReminderService { $alarms[] = [ 'calendar_id' => $objectData['calendarid'], 'object_id' => $objectData['id'], - 'uid' => (string) $valarm->parent->UID, + 'uid' => (string)$valarm->parent->UID, 'is_recurring' => $isRecurring, 'recurrence_id' => $recurrenceId, 'is_recurrence_exception' => $isRecurrenceException, 'event_hash' => $eventHash, 'alarm_hash' => $alarmHash, - 'type' => (string) $valarm->ACTION, + 'type' => (string)$valarm->ACTION, 'is_relative' => $isRelative, 'notification_date' => $notificationDate->getTimestamp(), 'is_repeat_based' => false, ]; - $repeat = isset($valarm->REPEAT) ? (int) $valarm->REPEAT->getValue() : 0; + $repeat = isset($valarm->REPEAT) ? (int)$valarm->REPEAT->getValue() : 0; for ($i = 0; $i < $repeat; $i++) { if ($valarm->DURATION === null) { continue; @@ -384,13 +390,13 @@ class ReminderService { $alarms[] = [ 'calendar_id' => $objectData['calendarid'], 'object_id' => $objectData['id'], - 'uid' => (string) $valarm->parent->UID, + 'uid' => (string)$valarm->parent->UID, 'is_recurring' => $isRecurring, 'recurrence_id' => $recurrenceId, 'is_recurrence_exception' => $isRecurrenceException, 'event_hash' => $eventHash, 'alarm_hash' => $alarmHash, - 'type' => (string) $valarm->ACTION, + 'type' => (string)$valarm->ACTION, 'is_relative' => $isRelative, 'notification_date' => $clonedNotificationDate->getTimestamp(), 'is_repeat_based' => true, @@ -404,19 +410,26 @@ class ReminderService { * @param array $reminders */ private function writeRemindersToDatabase(array $reminders): void { + $uniqueReminders = []; foreach ($reminders as $reminder) { + $key = $reminder['notification_date'] . $reminder['event_hash'] . $reminder['type']; + if (!isset($uniqueReminders[$key])) { + $uniqueReminders[$key] = $reminder; + } + } + foreach (array_values($uniqueReminders) as $reminder) { $this->backend->insertReminder( - (int) $reminder['calendar_id'], - (int) $reminder['object_id'], + (int)$reminder['calendar_id'], + (int)$reminder['object_id'], $reminder['uid'], $reminder['is_recurring'], - (int) $reminder['recurrence_id'], + (int)$reminder['recurrence_id'], $reminder['is_recurrence_exception'], $reminder['event_hash'], $reminder['alarm_hash'], $reminder['type'], $reminder['is_relative'], - (int) $reminder['notification_date'], + (int)$reminder['notification_date'], $reminder['is_repeat_based'] ); } @@ -427,11 +440,11 @@ class ReminderService { * @param VEvent $vevent */ private function deleteOrProcessNext(array $reminder, - VObject\Component\VEvent $vevent):void { - if ($reminder['is_repeat_based'] || - !$reminder['is_recurring'] || - !$reminder['is_relative'] || - $reminder['is_recurrence_exception']) { + VObject\Component\VEvent $vevent):void { + if ($reminder['is_repeat_based'] + || !$reminder['is_recurring'] + || !$reminder['is_relative'] + || $reminder['is_recurrence_exception']) { $this->backend->removeReminder($reminder['id']); return; } @@ -439,6 +452,7 @@ class ReminderService { $vevents = $this->getAllVEventsFromVCalendar($vevent->parent); $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $now = $this->timeFactory->getDateTime(); + $calendarTimeZone = $this->getCalendarTimeZone((int)$reminder['calendar_id']); try { $iterator = new EventIterator($vevents, $reminder['uid']); @@ -449,49 +463,54 @@ class ReminderService { return; } - while ($iterator->valid()) { - $event = $iterator->getEventObject(); - - // Recurrence-exceptions are handled separately, so just ignore them here - if (\in_array($event, $recurrenceExceptions, true)) { - $iterator->next(); - continue; - } - - $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event); - if ($reminder['recurrence_id'] >= $recurrenceId) { - $iterator->next(); - continue; - } + try { + while ($iterator->valid()) { + $event = $iterator->getEventObject(); - foreach ($event->VALARM as $valarm) { - /** @var VAlarm $valarm */ - $alarmHash = $this->getAlarmHash($valarm); - if ($alarmHash !== $reminder['alarm_hash']) { + // Recurrence-exceptions are handled separately, so just ignore them here + if (\in_array($event, $recurrenceExceptions, true)) { + $iterator->next(); continue; } - $triggerTime = $valarm->getEffectiveTriggerTime(); - - // If effective trigger time is in the past - // just skip and generate for next event - $diff = $now->diff($triggerTime); - if ($diff->invert === 1) { + $recurrenceId = $this->getEffectiveRecurrenceIdOfVEvent($event); + if ($reminder['recurrence_id'] >= $recurrenceId) { + $iterator->next(); continue; } - $this->backend->removeReminder($reminder['id']); - $alarms = $this->getRemindersForVAlarm($valarm, [ - 'calendarid' => $reminder['calendar_id'], - 'id' => $reminder['object_id'], - ], $reminder['event_hash'], $alarmHash, true, false); - $this->writeRemindersToDatabase($alarms); + foreach ($event->VALARM as $valarm) { + /** @var VAlarm $valarm */ + $alarmHash = $this->getAlarmHash($valarm); + if ($alarmHash !== $reminder['alarm_hash']) { + continue; + } - // Abort generating reminders after creating one successfully - return; - } + $triggerTime = $valarm->getEffectiveTriggerTime(); + + // If effective trigger time is in the past + // just skip and generate for next event + $diff = $now->diff($triggerTime); + if ($diff->invert === 1) { + continue; + } + + $this->backend->removeReminder($reminder['id']); + $alarms = $this->getRemindersForVAlarm($valarm, [ + 'calendarid' => $reminder['calendar_id'], + 'id' => $reminder['object_id'], + ], $calendarTimeZone, $reminder['event_hash'], $alarmHash, true, false); + $this->writeRemindersToDatabase($alarms); + + // Abort generating reminders after creating one successfully + return; + } - $iterator->next(); + $iterator->next(); + } + } catch (MaxInstancesExceededException $e) { + // Using debug logger as this isn't really an error + $this->logger->debug('Recurrence with too many instances detected, skipping VEVENT', ['exception' => $e]); } $this->backend->removeReminder($reminder['id']); @@ -549,26 +568,26 @@ class ReminderService { */ private function getEventHash(VEvent $vevent):string { $properties = [ - (string) $vevent->DTSTART->serialize(), + (string)$vevent->DTSTART->serialize(), ]; if ($vevent->DTEND) { - $properties[] = (string) $vevent->DTEND->serialize(); + $properties[] = (string)$vevent->DTEND->serialize(); } if ($vevent->DURATION) { - $properties[] = (string) $vevent->DURATION->serialize(); + $properties[] = (string)$vevent->DURATION->serialize(); } if ($vevent->{'RECURRENCE-ID'}) { - $properties[] = (string) $vevent->{'RECURRENCE-ID'}->serialize(); + $properties[] = (string)$vevent->{'RECURRENCE-ID'}->serialize(); } if ($vevent->RRULE) { - $properties[] = (string) $vevent->RRULE->serialize(); + $properties[] = (string)$vevent->RRULE->serialize(); } if ($vevent->EXDATE) { - $properties[] = (string) $vevent->EXDATE->serialize(); + $properties[] = (string)$vevent->EXDATE->serialize(); } if ($vevent->RDATE) { - $properties[] = (string) $vevent->RDATE->serialize(); + $properties[] = (string)$vevent->RDATE->serialize(); } return md5(implode('::', $properties)); @@ -583,15 +602,15 @@ class ReminderService { */ private function getAlarmHash(VAlarm $valarm):string { $properties = [ - (string) $valarm->ACTION->serialize(), - (string) $valarm->TRIGGER->serialize(), + (string)$valarm->ACTION->serialize(), + (string)$valarm->TRIGGER->serialize(), ]; if ($valarm->DURATION) { - $properties[] = (string) $valarm->DURATION->serialize(); + $properties[] = (string)$valarm->DURATION->serialize(); } if ($valarm->REPEAT) { - $properties[] = (string) $valarm->REPEAT->serialize(); + $properties[] = (string)$valarm->REPEAT->serialize(); } return md5(implode('::', $properties)); @@ -604,14 +623,14 @@ class ReminderService { * @return VEvent|null */ private function getVEventByRecurrenceId(VObject\Component\VCalendar $vcalendar, - int $recurrenceId, - bool $isRecurrenceException):?VEvent { + int $recurrenceId, + bool $isRecurrenceException):?VEvent { $vevents = $this->getAllVEventsFromVCalendar($vcalendar); if (count($vevents) === 0) { return null; } - $uid = (string) $vevents[0]->UID; + $uid = (string)$vevents[0]->UID; $recurrenceExceptions = $this->getRecurrenceExceptionFromListOfVEvents($vevents); $masterItem = $this->getMasterItemFromListOfVEvents($vevents); @@ -662,7 +681,7 @@ class ReminderService { */ private function getStatusOfEvent(VEvent $vevent):string { if ($vevent->STATUS) { - return (string) $vevent->STATUS; + return (string)$vevent->STATUS; } // Doesn't say so in the standard, @@ -724,6 +743,10 @@ class ReminderService { if ($child->name !== 'VEVENT') { continue; } + // Ignore invalid events with no DTSTART + if ($child->DTSTART === null) { + continue; + } $vevents[] = $child; } @@ -788,4 +811,26 @@ class ReminderService { private function isRecurring(VEvent $vevent):bool { return isset($vevent->RRULE) || isset($vevent->RDATE); } + + /** + * @param int $calendarid + * + * @return DateTimeZone + */ + private function getCalendarTimeZone(int $calendarid): DateTimeZone { + $calendarInfo = $this->caldavBackend->getCalendarById($calendarid); + $tzProp = '{urn:ietf:params:xml:ns:caldav}calendar-timezone'; + if (empty($calendarInfo[$tzProp])) { + // Defaulting to UTC + return new DateTimeZone('UTC'); + } + // This property contains a VCALENDAR with a single VTIMEZONE + /** @var string $timezoneProp */ + $timezoneProp = $calendarInfo[$tzProp]; + /** @var VObject\Component\VCalendar $vtimezoneObj */ + $vtimezoneObj = VObject\Reader::read($timezoneProp); + /** @var VObject\Component\VTimeZone $vtimezone */ + $vtimezone = $vtimezoneObj->VTIMEZONE; + return $vtimezone->getTimeZone(); + } } diff --git a/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php index aebb5a24f0e..68bb3373346 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/AbstractPrincipalBackend.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright 2019, Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ResourceBooking; @@ -33,8 +14,8 @@ use OCP\DB\Exception; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\ILogger; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Sabre\DAV\PropPatch; use Sabre\DAVACL\PrincipalBackend\BackendInterface; use function array_intersect; @@ -45,24 +26,6 @@ use function array_values; abstract class AbstractPrincipalBackend implements BackendInterface { - /** @var IDBConnection */ - private $db; - - /** @var IUserSession */ - private $userSession; - - /** @var IGroupManager */ - private $groupManager; - - /** @var ILogger */ - private $logger; - - /** @var ProxyMapper */ - private $proxyMapper; - - /** @var string */ - private $principalPrefix; - /** @var string */ private $dbTableName; @@ -72,36 +35,19 @@ abstract class AbstractPrincipalBackend implements BackendInterface { /** @var string */ private $dbForeignKeyName; - /** @var string */ - private $cuType; - - /** - * @param IDBConnection $dbConnection - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param ILogger $logger - * @param string $principalPrefix - * @param string $dbPrefix - * @param string $cuType - */ - public function __construct(IDBConnection $dbConnection, - IUserSession $userSession, - IGroupManager $groupManager, - ILogger $logger, - ProxyMapper $proxyMapper, - string $principalPrefix, - string $dbPrefix, - string $cuType) { - $this->db = $dbConnection; - $this->userSession = $userSession; - $this->groupManager = $groupManager; - $this->logger = $logger; - $this->proxyMapper = $proxyMapper; - $this->principalPrefix = $principalPrefix; + public function __construct( + private IDBConnection $db, + private IUserSession $userSession, + private IGroupManager $groupManager, + private LoggerInterface $logger, + private ProxyMapper $proxyMapper, + private string $principalPrefix, + string $dbPrefix, + private string $cuType, + ) { $this->dbTableName = 'calendar_' . $dbPrefix . 's'; $this->dbMetaDataTableName = $this->dbTableName . '_md'; $this->dbForeignKeyName = $dbPrefix . '_id'; - $this->cuType = $cuType; } use PrincipalProxyTrait; @@ -140,8 +86,8 @@ abstract class AbstractPrincipalBackend implements BackendInterface { $metaDataById[$metaDataRow[$this->dbForeignKeyName]] = []; } - $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] = - $metaDataRow['value']; + $metaDataById[$metaDataRow[$this->dbForeignKeyName]][$metaDataRow['key']] + = $metaDataRow['value']; } while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { @@ -170,12 +116,12 @@ abstract class AbstractPrincipalBackend implements BackendInterface { * @return array */ public function getPrincipalByPath($path) { - if (strpos($path, $this->principalPrefix) !== 0) { + if (!str_starts_with($path, $this->principalPrefix)) { return null; } [, $name] = \Sabre\Uri\split($path); - [$backendId, $resourceId] = explode('-', $name, 2); + [$backendId, $resourceId] = explode('-', $name, 2); $query = $this->db->getQueryBuilder(); $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname']) @@ -319,7 +265,7 @@ abstract class AbstractPrincipalBackend implements BackendInterface { case IRoomMetadata::CAPACITY: case IResourceMetadata::VEHICLE_SEATING_CAPACITY: - $results[] = $this->searchPrincipalsByCapacity($prop,$value); + $results[] = $this->searchPrincipalsByCapacity($prop, $value); break; default: @@ -416,7 +362,7 @@ abstract class AbstractPrincipalBackend implements BackendInterface { try { $stmt = $query->executeQuery(); } catch (Exception $e) { - $this->logger->error("Could not search resources: " . $e->getMessage(), ['exception' => $e]); + $this->logger->error('Could not search resources: ' . $e->getMessage(), ['exception' => $e]); } $rows = []; @@ -453,7 +399,7 @@ abstract class AbstractPrincipalBackend implements BackendInterface { } $usersGroups = $this->groupManager->getUserGroupIds($user); - if (strpos($uri, 'mailto:') === 0) { + if (str_starts_with($uri, 'mailto:')) { $email = substr($uri, 7); $query = $this->db->getQueryBuilder(); $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions']) @@ -473,14 +419,14 @@ abstract class AbstractPrincipalBackend implements BackendInterface { return $this->rowToPrincipal($row)['uri']; } - if (strpos($uri, 'principal:') === 0) { + if (str_starts_with($uri, 'principal:')) { $path = substr($uri, 10); - if (strpos($path, $this->principalPrefix) !== 0) { + if (!str_starts_with($path, $this->principalPrefix)) { return null; } [, $name] = \Sabre\Uri\split($path); - [$backendId, $resourceId] = explode('-', $name, 2); + [$backendId, $resourceId] = explode('-', $name, 2); $query = $this->db->getQueryBuilder(); $query->select(['id', 'backend_id', 'resource_id', 'email', 'displayname', 'group_restrictions']) @@ -525,14 +471,14 @@ abstract class AbstractPrincipalBackend implements BackendInterface { * @return bool */ private function isAllowedToAccessResource(array $row, array $userGroups): bool { - if (!isset($row['group_restrictions']) || - $row['group_restrictions'] === null || - $row['group_restrictions'] === '') { + if (!isset($row['group_restrictions']) + || $row['group_restrictions'] === null + || $row['group_restrictions'] === '') { return true; } // group restrictions contains something, but not parsable, deny access and log warning - $json = json_decode($row['group_restrictions']); + $json = json_decode($row['group_restrictions'], null, 512, JSON_THROW_ON_ERROR); if (!\is_array($json)) { $this->logger->info('group_restrictions field could not be parsed for ' . $this->dbTableName . '::' . $row['id'] . ', denying access to resource'); return false; diff --git a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php index 65203e24da5..c70d93daf52 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/ResourcePrincipalBackend.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ResourceBooking; use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\ILogger; use OCP\IUserSession; +use Psr\Log\LoggerInterface; /** * Class ResourcePrincipalBackend @@ -37,18 +21,12 @@ class ResourcePrincipalBackend extends AbstractPrincipalBackend { /** * ResourcePrincipalBackend constructor. - * - * @param IDBConnection $dbConnection - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param ILogger $logger - * @param ProxyMapper $proxyMapper */ public function __construct(IDBConnection $dbConnection, - IUserSession $userSession, - IGroupManager $groupManager, - ILogger $logger, - ProxyMapper $proxyMapper) { + IUserSession $userSession, + IGroupManager $groupManager, + LoggerInterface $logger, + ProxyMapper $proxyMapper) { parent::__construct($dbConnection, $userSession, $groupManager, $logger, $proxyMapper, 'principals/calendar-resources', 'resource', 'RESOURCE'); } diff --git a/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php index ca78ebd4bc4..5704b23ae14 100644 --- a/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php +++ b/apps/dav/lib/CalDAV/ResourceBooking/RoomPrincipalBackend.php @@ -1,32 +1,16 @@ <?php + /** - * @copyright 2018, Georg Ehrke <oc.list@georgehrke.com> - * - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\ResourceBooking; use OCA\DAV\CalDAV\Proxy\ProxyMapper; use OCP\IDBConnection; use OCP\IGroupManager; -use OCP\ILogger; use OCP\IUserSession; +use Psr\Log\LoggerInterface; /** * Class RoomPrincipalBackend @@ -37,18 +21,12 @@ class RoomPrincipalBackend extends AbstractPrincipalBackend { /** * RoomPrincipalBackend constructor. - * - * @param IDBConnection $dbConnection - * @param IUserSession $userSession - * @param IGroupManager $groupManager - * @param ILogger $logger - * @param ProxyMapper $proxyMapper */ public function __construct(IDBConnection $dbConnection, - IUserSession $userSession, - IGroupManager $groupManager, - ILogger $logger, - ProxyMapper $proxyMapper) { + IUserSession $userSession, + IGroupManager $groupManager, + LoggerInterface $logger, + ProxyMapper $proxyMapper) { parent::__construct($dbConnection, $userSession, $groupManager, $logger, $proxyMapper, 'principals/calendar-rooms', 'room', 'ROOM'); } diff --git a/apps/dav/lib/CalDAV/RetentionService.php b/apps/dav/lib/CalDAV/RetentionService.php index 1d92d847641..399d1a46639 100644 --- a/apps/dav/lib/CalDAV/RetentionService.php +++ b/apps/dav/lib/CalDAV/RetentionService.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV; @@ -34,29 +17,19 @@ class RetentionService { public const RETENTION_CONFIG_KEY = 'calendarRetentionObligation'; private const DEFAULT_RETENTION_SECONDS = 30 * 24 * 60 * 60; - /** @var IConfig */ - private $config; - - /** @var ITimeFactory */ - private $time; - - /** @var CalDavBackend */ - private $calDavBackend; - - public function __construct(IConfig $config, - ITimeFactory $time, - CalDavBackend $calDavBackend) { - $this->config = $config; - $this->time = $time; - $this->calDavBackend = $calDavBackend; + public function __construct( + private IConfig $config, + private ITimeFactory $time, + private CalDavBackend $calDavBackend, + ) { } public function getDuration(): int { return max( - (int) $this->config->getAppValue( + (int)$this->config->getAppValue( Application::APP_ID, self::RETENTION_CONFIG_KEY, - (string) self::DEFAULT_RETENTION_SECONDS + (string)self::DEFAULT_RETENTION_SECONDS ), 0 // Just making sure we don't delete things in the future when a negative number is passed ); diff --git a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php index 8aacc33bb46..2af6b162d8d 100644 --- a/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php +++ b/apps/dav/lib/CalDAV/Schedule/IMipPlugin.php @@ -1,60 +1,34 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @copyright Copyright (c) 2017, Georg Ehrke - * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). - * @copyright Copyright (C) 2007-2015 fruux GmbH (https://fruux.com/). - * - * @author brad2014 <brad2014@users.noreply.github.com> - * @author Brad Rubenstein <brad@wbr.tech> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Leon Klingele <leon@struktur.de> - * @author Nick Sweeting <git@sweeting.me> - * @author rakekniven <mark.ziegler@rakekniven.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Citharel <nextcloud@tcit.fr> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * 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, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-FileCopyrightText: 2007-2015 fruux GmbH (https://fruux.com/) + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\DAV\CalDAV\Schedule; +use OCA\DAV\CalDAV\CalendarObject; +use OCA\DAV\CalDAV\EventComparisonService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Defaults; -use OCP\IConfig; -use OCP\IDBConnection; -use OCP\IL10N; -use OCP\ILogger; -use OCP\IURLGenerator; -use OCP\IUserManager; -use OCP\L10N\IFactory as L10NFactory; -use OCP\Mail\IEMailTemplate; +use OCP\IAppConfig; +use OCP\IUserSession; use OCP\Mail\IMailer; -use OCP\Security\ISecureRandom; +use OCP\Mail\Provider\Address; +use OCP\Mail\Provider\Attachment; +use OCP\Mail\Provider\IManager as IMailManager; +use OCP\Mail\Provider\IMessageSend; use OCP\Util; +use Psr\Log\LoggerInterface; use Sabre\CalDAV\Schedule\IMipPlugin as SabreIMipPlugin; +use Sabre\DAV; +use Sabre\DAV\INode; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VEvent; -use Sabre\VObject\DateTimeParser; use Sabre\VObject\ITip\Message; use Sabre\VObject\Parameter; -use Sabre\VObject\Property; -use Sabre\VObject\Recur\EventIterator; +use Sabre\VObject\Reader; /** * iMIP handler. @@ -72,75 +46,47 @@ use Sabre\VObject\Recur\EventIterator; */ class IMipPlugin extends SabreIMipPlugin { - /** @var string */ - private $userId; - - /** @var IConfig */ - private $config; - - /** @var IMailer */ - private $mailer; - - /** @var ILogger */ - private $logger; - - /** @var ITimeFactory */ - private $timeFactory; - - /** @var L10NFactory */ - private $l10nFactory; - - /** @var IURLGenerator */ - private $urlGenerator; - - /** @var ISecureRandom */ - private $random; - - /** @var IDBConnection */ - private $db; - - /** @var Defaults */ - private $defaults; - - /** @var IUserManager */ - private $userManager; - + private ?VCalendar $vCalendar = null; public const MAX_DATE = '2038-01-01'; - public const METHOD_REQUEST = 'request'; public const METHOD_REPLY = 'reply'; public const METHOD_CANCEL = 'cancel'; - public const IMIP_INDENT = 15; // Enough for the length of all body bullet items, in all languages + public const IMIP_INDENT = 15; + + public function __construct( + private IAppConfig $config, + private IMailer $mailer, + private LoggerInterface $logger, + private ITimeFactory $timeFactory, + private Defaults $defaults, + private IUserSession $userSession, + private IMipService $imipService, + private EventComparisonService $eventComparisonService, + private IMailManager $mailManager, + ) { + parent::__construct(''); + } + + public function initialize(DAV\Server $server): void { + parent::initialize($server); + $server->on('beforeWriteContent', [$this, 'beforeWriteContent'], 10); + } /** - * @param IConfig $config - * @param IMailer $mailer - * @param ILogger $logger - * @param ITimeFactory $timeFactory - * @param L10NFactory $l10nFactory - * @param IUrlGenerator $urlGenerator - * @param Defaults $defaults - * @param ISecureRandom $random - * @param IDBConnection $db - * @param string $userId + * Check quota before writing content + * + * @param string $uri target file URI + * @param INode $node Sabre Node + * @param resource $data data + * @param bool $modified modified */ - public function __construct(IConfig $config, IMailer $mailer, ILogger $logger, - ITimeFactory $timeFactory, L10NFactory $l10nFactory, - IURLGenerator $urlGenerator, Defaults $defaults, - ISecureRandom $random, IDBConnection $db, IUserManager $userManager, - $userId) { - parent::__construct(''); - $this->userId = $userId; - $this->config = $config; - $this->mailer = $mailer; - $this->logger = $logger; - $this->timeFactory = $timeFactory; - $this->l10nFactory = $l10nFactory; - $this->urlGenerator = $urlGenerator; - $this->random = $random; - $this->db = $db; - $this->defaults = $defaults; - $this->userManager = $userManager; + public function beforeWriteContent($uri, INode $node, $data, $modified): void { + if (!$node instanceof CalendarObject) { + return; + } + /** @var VCalendar $vCalendar */ + $vCalendar = Reader::read($node->get()); + $this->setVCalendar($vCalendar); } /** @@ -151,8 +97,7 @@ class IMipPlugin extends SabreIMipPlugin { */ public function schedule(Message $iTipMessage) { - // Not sending any emails if the system considers the update - // insignificant. + // Not sending any emails if the system considers the update insignificant if (!$iTipMessage->significantChange) { if (!$iTipMessage->scheduleStatus) { $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; @@ -160,103 +105,114 @@ class IMipPlugin extends SabreIMipPlugin { return; } - $summary = $iTipMessage->message->VEVENT->SUMMARY; - - if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto') { - return; - } - - if (parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { + if (parse_url($iTipMessage->sender, PHP_URL_SCHEME) !== 'mailto' + || parse_url($iTipMessage->recipient, PHP_URL_SCHEME) !== 'mailto') { return; } // don't send out mails for events that already took place - $lastOccurrence = $this->getLastOccurrence($iTipMessage->message); + $lastOccurrence = $this->imipService->getLastOccurrence($iTipMessage->message); $currentTime = $this->timeFactory->getTime(); if ($lastOccurrence < $currentTime) { return; } // Strip off mailto: - $sender = substr($iTipMessage->sender, 7); $recipient = substr($iTipMessage->recipient, 7); - if ($recipient === false || !$this->mailer->validateMailAddress($recipient)) { + if (!$this->mailer->validateMailAddress($recipient)) { // Nothing to send if the recipient doesn't have a valid email address $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; return; } - - $senderName = $iTipMessage->senderName ?: null; - $recipientName = $iTipMessage->recipientName ?: null; - - if ($senderName === null || empty(trim($senderName))) { - $user = $this->userManager->get($this->userId); - if ($user) { - // getDisplayName automatically uses the uid - // if no display-name is set - $senderName = $user->getDisplayName(); - } + $recipientName = $iTipMessage->recipientName ? (string)$iTipMessage->recipientName : null; + + $newEvents = $iTipMessage->message; + $oldEvents = $this->getVCalendar(); + + $modified = $this->eventComparisonService->findModified($newEvents, $oldEvents); + /** @var VEvent $vEvent */ + $vEvent = array_pop($modified['new']); + /** @var VEvent $oldVevent */ + $oldVevent = !empty($modified['old']) && is_array($modified['old']) ? array_pop($modified['old']) : null; + $isModified = isset($oldVevent); + + // No changed events after all - this shouldn't happen if there is significant change yet here we are + // The scheduling status is debatable + if (empty($vEvent)) { + $this->logger->warning('iTip message said the change was significant but comparison did not detect any updated VEvents'); + $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; + return; } - /** @var VEvent $vevent */ - $vevent = $iTipMessage->message->VEVENT; - - $attendee = $this->getCurrentAttendee($iTipMessage); - $defaultLang = $this->l10nFactory->findGenericLanguage(); - $lang = $this->getAttendeeLangOrDefault($defaultLang, $attendee); - $l10n = $this->l10nFactory->get('dav', $lang); - - $meetingAttendeeName = $recipientName ?: $recipient; - $meetingInviteeName = $senderName ?: $sender; - - $meetingTitle = $vevent->SUMMARY; - $meetingDescription = $vevent->DESCRIPTION; - - - $meetingUrl = $vevent->URL; - $meetingLocation = $vevent->LOCATION; + // we (should) have one event component left + // as the ITip\Broker creates one iTip message per change + // and triggers the "schedule" event once per message + // we also might not have an old event as this could be a new + // invitation, or a new recurrence exception + $attendee = $this->imipService->getCurrentAttendee($iTipMessage); + if ($attendee === null) { + $uid = $vEvent->UID ?? 'no UID found'; + $this->logger->debug('Could not find recipient ' . $recipient . ' as attendee for event with UID ' . $uid); + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; + return; + } + // Don't send emails to rooms, resources and circles + if ($this->imipService->isRoomOrResource($attendee) + || $this->imipService->isCircle($attendee)) { + $this->logger->debug('No invitation sent as recipient is room, resource or circle', [ + 'attendee' => $recipient, + ]); + $iTipMessage->scheduleStatus = '1.0;We got the message, but it\'s not significant enough to warrant an email'; + return; + } + $this->imipService->setL10n($attendee); + + // Build the sender name. + // Due to a bug in sabre, the senderName property for an iTIP message can actually also be a VObject Property + // If the iTIP message senderName is null or empty use the user session name as the senderName + if (($iTipMessage->senderName instanceof Parameter) && !empty(trim($iTipMessage->senderName->getValue()))) { + $senderName = trim($iTipMessage->senderName->getValue()); + } elseif (is_string($iTipMessage->senderName) && !empty(trim($iTipMessage->senderName))) { + $senderName = trim($iTipMessage->senderName); + } elseif ($this->userSession->getUser() !== null) { + $senderName = trim($this->userSession->getUser()->getDisplayName()); + } else { + $senderName = ''; + } - $defaultVal = '--'; + $sender = substr($iTipMessage->sender, 7); - $method = self::METHOD_REQUEST; + $replyingAttendee = null; switch (strtolower($iTipMessage->method)) { case self::METHOD_REPLY: $method = self::METHOD_REPLY; + $data = $this->imipService->buildReplyBodyData($vEvent); + $replyingAttendee = $this->imipService->getReplyingAttendee($iTipMessage); break; case self::METHOD_CANCEL: $method = self::METHOD_CANCEL; + $data = $this->imipService->buildCancelledBodyData($vEvent); + break; + default: + $method = self::METHOD_REQUEST; + $data = $this->imipService->buildBodyData($vEvent, $oldVevent); break; } - $data = [ - 'attendee_name' => (string)$meetingAttendeeName ?: $defaultVal, - 'invitee_name' => (string)$meetingInviteeName ?: $defaultVal, - 'meeting_title' => (string)$meetingTitle ?: $defaultVal, - 'meeting_description' => (string)$meetingDescription ?: $defaultVal, - 'meeting_url' => (string)$meetingUrl ?: $defaultVal, - ]; + $data['attendee_name'] = ($recipientName ?: $recipient); + $data['invitee_name'] = ($senderName ?: $sender); $fromEMail = Util::getDefaultEmailAddress('invitations-noreply'); - $fromName = $l10n->t('%1$s via %2$s', [$senderName, $this->defaults->getName()]); - - $message = $this->mailer->createMessage() - ->setFrom([$fromEMail => $fromName]) - ->setTo([$recipient => $recipientName]); - - if ($sender !== false) { - $message->setReplyTo([$sender => $senderName]); - } + $fromName = $this->imipService->getFrom($senderName, $this->defaults->getName()); $template = $this->mailer->createEMailTemplate('dav.calendarInvite.' . $method, $data); $template->addHeader(); - $summary = ((string) $summary !== '') ? (string) $summary : $l10n->t('Untitled event'); - - $this->addSubjectAndHeading($template, $l10n, $method, $summary); - $this->addBulletList($template, $l10n, $vevent); + $this->imipService->addSubjectAndHeading($template, $method, $data['invitee_name'], $data['meeting_title'], $isModified, $replyingAttendee); + $this->imipService->addBulletList($template, $vEvent, $data); // Only add response buttons to invitation requests: Fix Issue #11230 - if (($method == self::METHOD_REQUEST) && $this->getAttendeeRsvpOrReqForParticipant($attendee)) { + if (strcasecmp($method, self::METHOD_REQUEST) === 0 && $this->imipService->getAttendeeRsvpOrReqForParticipant($attendee)) { /* ** Only offer invitation accept/reject buttons, which link back to the @@ -277,453 +233,106 @@ class IMipPlugin extends SabreIMipPlugin { ** To suppress URLs entirely, set invitation_link_recipients to boolean "no". */ - $recipientDomain = substr(strrchr($recipient, "@"), 1); - $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getAppValue('dav', 'invitation_link_recipients', 'yes')))); + $recipientDomain = substr(strrchr($recipient, '@'), 1); + $invitationLinkRecipients = explode(',', preg_replace('/\s+/', '', strtolower($this->config->getValueString('dav', 'invitation_link_recipients', 'yes')))); if (strcmp('yes', $invitationLinkRecipients[0]) === 0 - || in_array(strtolower($recipient), $invitationLinkRecipients) - || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { - $this->addResponseButtons($template, $l10n, $iTipMessage, $lastOccurrence); + || in_array(strtolower($recipient), $invitationLinkRecipients) + || in_array(strtolower($recipientDomain), $invitationLinkRecipients)) { + $token = $this->imipService->createInvitationToken($iTipMessage, $vEvent, $lastOccurrence); + $this->imipService->addResponseButtons($template, $token); + $this->imipService->addMoreOptionsButton($template, $token); } } $template->addFooter(); + // convert iTip Message to string + $itip_msg = $iTipMessage->message->serialize(); - $message->useTemplate($template); - - $attachment = $this->mailer->createAttachment( - $iTipMessage->message->serialize(), - 'event.ics',// TODO(leon): Make file name unique, e.g. add event id - 'text/calendar; method=' . $iTipMessage->method - ); - $message->attach($attachment); + $mailService = null; try { - $failed = $this->mailer->send($message); - $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip'; - if ($failed) { - $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); - $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; - } - } catch (\Exception $ex) { - $this->logger->logException($ex, ['app' => 'dav']); - $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; - } - } - - /** - * check if event took place in the past already - * @param VCalendar $vObject - * @return int - */ - private function getLastOccurrence(VCalendar $vObject) { - /** @var VEvent $component */ - $component = $vObject->VEVENT; - - $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); - // Finding the last occurrence is a bit harder - if (!isset($component->RRULE)) { - if (isset($component->DTEND)) { - $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); - } elseif (isset($component->DURATION)) { - /** @var \DateTime $endDate */ - $endDate = clone $component->DTSTART->getDateTime(); - // $component->DTEND->getDateTime() returns DateTimeImmutable - $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); - $lastOccurrence = $endDate->getTimestamp(); - } elseif (!$component->DTSTART->hasTime()) { - /** @var \DateTime $endDate */ - $endDate = clone $component->DTSTART->getDateTime(); - // $component->DTSTART->getDateTime() returns DateTimeImmutable - $endDate = $endDate->modify('+1 day'); - $lastOccurrence = $endDate->getTimestamp(); - } else { - $lastOccurrence = $firstOccurrence; - } - } else { - $it = new EventIterator($vObject, (string)$component->UID); - $maxDate = new \DateTime(self::MAX_DATE); - if ($it->isInfinite()) { - $lastOccurrence = $maxDate->getTimestamp(); - } else { - $end = $it->getDtEnd(); - while ($it->valid() && $end < $maxDate) { - $end = $it->getDtEnd(); - $it->next(); + if ($this->config->getValueBool('core', 'mail_providers_enabled', true)) { + // retrieve user object + $user = $this->userSession->getUser(); + if ($user !== null) { + // retrieve appropriate service with the same address as sender + $mailService = $this->mailManager->findServiceByAddress($user->getUID(), $sender); } - $lastOccurrence = $end->getTimestamp(); } - } - - return $lastOccurrence; - } - /** - * @param Message $iTipMessage - * @return null|Property - */ - private function getCurrentAttendee(Message $iTipMessage) { - /** @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 string $default - * @param Property|null $attendee - * @return string - */ - private function getAttendeeLangOrDefault($default, Property $attendee = null) { - if ($attendee !== null) { - $lang = $attendee->offsetGet('LANGUAGE'); - if ($lang instanceof Parameter) { - return $lang->getValue(); - } - } - return $default; - } - - /** - * @param Property|null $attendee - * @return bool - */ - private function getAttendeeRsvpOrReqForParticipant(Property $attendee = null) { - if ($attendee !== null) { - $rsvp = $attendee->offsetGet('RSVP'); - if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { - return true; - } - $role = $attendee->offsetGet('ROLE'); - // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 - // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set - if ($role === null - || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) - || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) - ) { - return true; - } - } - // RFC 5545 3.2.17: default RSVP is false - return false; - } - - /** - * @param IL10N $l10n - * @param VEvent $vevent - */ - private function generateWhenString(IL10N $l10n, VEvent $vevent) { - $dtstart = $vevent->DTSTART; - if (isset($vevent->DTEND)) { - $dtend = $vevent->DTEND; - } elseif (isset($vevent->DURATION)) { - $isFloating = $vevent->DTSTART->isFloating(); - $dtend = clone $vevent->DTSTART; - $endDateTime = $dtend->getDateTime(); - $endDateTime = $endDateTime->add(DateTimeParser::parse($vevent->DURATION->getValue())); - $dtend->setDateTime($endDateTime, $isFloating); - } elseif (!$vevent->DTSTART->hasTime()) { - $isFloating = $vevent->DTSTART->isFloating(); - $dtend = clone $vevent->DTSTART; - $endDateTime = $dtend->getDateTime(); - $endDateTime = $endDateTime->modify('+1 day'); - $dtend->setDateTime($endDateTime, $isFloating); - } else { - $dtend = clone $vevent->DTSTART; - } - - $isAllDay = $dtstart instanceof Property\ICalendar\Date; - - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtstart */ - /** @var Property\ICalendar\Date | Property\ICalendar\DateTime $dtend */ - /** @var \DateTimeImmutable $dtstartDt */ - $dtstartDt = $dtstart->getDateTime(); - /** @var \DateTimeImmutable $dtendDt */ - $dtendDt = $dtend->getDateTime(); - - $diff = $dtstartDt->diff($dtendDt); - - $dtstartDt = new \DateTime($dtstartDt->format(\DateTimeInterface::ATOM)); - $dtendDt = new \DateTime($dtendDt->format(\DateTimeInterface::ATOM)); - - if ($isAllDay) { - // One day event - if ($diff->days === 1) { - return $l10n->l('date', $dtstartDt, ['width' => 'medium']); - } - - // DTEND is exclusive, so if the ics data says 2020-01-01 to 2020-01-05, - // the email should show 2020-01-01 to 2020-01-04. - $dtendDt->modify('-1 day'); - - //event that spans over multiple days - $localeStart = $l10n->l('date', $dtstartDt, ['width' => 'medium']); - $localeEnd = $l10n->l('date', $dtendDt, ['width' => 'medium']); - - return $localeStart . ' - ' . $localeEnd; - } - - /** @var Property\ICalendar\DateTime $dtstart */ - /** @var Property\ICalendar\DateTime $dtend */ - $isFloating = $dtstart->isFloating(); - $startTimezone = $endTimezone = null; - if (!$isFloating) { - $prop = $dtstart->offsetGet('TZID'); - if ($prop instanceof Parameter) { - $startTimezone = $prop->getValue(); - } - - $prop = $dtend->offsetGet('TZID'); - if ($prop instanceof Parameter) { - $endTimezone = $prop->getValue(); - } - } - - $localeStart = $l10n->l('weekdayName', $dtstartDt, ['width' => 'abbreviated']) . ', ' . - $l10n->l('datetime', $dtstartDt, ['width' => 'medium|short']); - - // always show full date with timezone if timezones are different - if ($startTimezone !== $endTimezone) { - $localeEnd = $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); - - return $localeStart . ' (' . $startTimezone . ') - ' . - $localeEnd . ' (' . $endTimezone . ')'; - } - - // show only end time if date is the same - if ($this->isDayEqual($dtstartDt, $dtendDt)) { - $localeEnd = $l10n->l('time', $dtendDt, ['width' => 'short']); - } else { - $localeEnd = $l10n->l('weekdayName', $dtendDt, ['width' => 'abbreviated']) . ', ' . - $l10n->l('datetime', $dtendDt, ['width' => 'medium|short']); - } - - return $localeStart . ' - ' . $localeEnd . ' (' . $startTimezone . ')'; - } - - /** - * @param \DateTime $dtStart - * @param \DateTime $dtEnd - * @return bool - */ - private function isDayEqual(\DateTime $dtStart, \DateTime $dtEnd) { - return $dtStart->format('Y-m-d') === $dtEnd->format('Y-m-d'); - } - - /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param string $method - * @param string $summary - */ - private function addSubjectAndHeading(IEMailTemplate $template, IL10N $l10n, - $method, $summary) { - if ($method === self::METHOD_CANCEL) { - // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" - $template->setSubject($l10n->t('Cancelled: %1$s', [$summary])); - $template->addHeading($l10n->t('Invitation canceled')); - } elseif ($method === self::METHOD_REPLY) { - // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Re: {{Event Name}}" - $template->setSubject($l10n->t('Re: %1$s', [$summary])); - $template->addHeading($l10n->t('Invitation updated')); - } else { - // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" - $template->setSubject($l10n->t('Invitation: %1$s', [$summary])); - $template->addHeading($l10n->t('Invitation')); - } - } - - /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param VEVENT $vevent - */ - private function addBulletList(IEMailTemplate $template, IL10N $l10n, $vevent) { - if ($vevent->SUMMARY) { - $template->addBodyListItem($vevent->SUMMARY, $l10n->t('Title:'), - $this->getAbsoluteImagePath('caldav/title.png'),'','',self::IMIP_INDENT); - } - $meetingWhen = $this->generateWhenString($l10n, $vevent); - if ($meetingWhen) { - $template->addBodyListItem($meetingWhen, $l10n->t('Time:'), - $this->getAbsoluteImagePath('caldav/time.png'),'','',self::IMIP_INDENT); - } - if ($vevent->LOCATION) { - $template->addBodyListItem($vevent->LOCATION, $l10n->t('Location:'), - $this->getAbsoluteImagePath('caldav/location.png'),'','',self::IMIP_INDENT); - } - if ($vevent->URL) { - $url = $vevent->URL->getValue(); - $template->addBodyListItem(sprintf('<a href="%s">%s</a>', - htmlspecialchars($url), - htmlspecialchars($url)), - $l10n->t('Link:'), - $this->getAbsoluteImagePath('caldav/link.png'), - $url,'',self::IMIP_INDENT); - } - - $this->addAttendees($template, $l10n, $vevent); - - /* Put description last, like an email body, since it can be arbitrarily long */ - if ($vevent->DESCRIPTION) { - $template->addBodyListItem($vevent->DESCRIPTION->getValue(), $l10n->t('Description:'), - $this->getAbsoluteImagePath('caldav/description.png'),'','',self::IMIP_INDENT); - } - } - - /** - * addAttendees: add organizer and attendee names/emails to iMip mail. - * - * Enable with DAV setting: invitation_list_attendees (default: no) - * - * The default is 'no', which matches old behavior, and is privacy preserving. - * - * To enable including attendees in invitation emails: - * % php occ config:app:set dav invitation_list_attendees --value yes - * - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param Message $iTipMessage - * @param int $lastOccurrence - * @author brad2014 on github.com - */ - - private function addAttendees(IEMailTemplate $template, IL10N $l10n, VEvent $vevent) { - if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { - return; - } - - if (isset($vevent->ORGANIZER)) { - /** @var Property\ICalendar\CalAddress $organizer */ - $organizer = $vevent->ORGANIZER; - $organizerURI = $organizer->getNormalizedValue(); - [$scheme,$organizerEmail] = explode(':',$organizerURI,2); # strip off scheme mailto: - /** @var string|null $organizerName */ - $organizerName = isset($organizer['CN']) ? $organizer['CN'] : null; - $organizerHTML = sprintf('<a href="%s">%s</a>', - htmlspecialchars($organizerURI), - htmlspecialchars($organizerName ?: $organizerEmail)); - $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); - if (isset($organizer['PARTSTAT'])) { - /** @var Parameter $partstat */ - $partstat = $organizer['PARTSTAT']; - if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { - $organizerHTML .= ' ✔︎'; - $organizerText .= ' ✔︎'; - } + // The display name in Nextcloud can use utf-8. + // As the default charset for text/* is us-ascii, it's important to explicitly define it. + // See https://www.rfc-editor.org/rfc/rfc6047.html#section-2.4. + $contentType = 'text/calendar; method=' . $iTipMessage->method . '; charset="utf-8"'; + + // evaluate if a mail service was found and has sending capabilities + if ($mailService instanceof IMessageSend) { + // construct mail message and set required parameters + $message = $mailService->initiateMessage(); + $message->setFrom( + (new Address($sender, $fromName)) + ); + $message->setTo( + (new Address($recipient, $recipientName)) + ); + $message->setSubject($template->renderSubject()); + $message->setBodyPlain($template->renderText()); + $message->setBodyHtml($template->renderHtml()); + // Adding name=event.ics is a trick to make the invitation also appear + // as a file attachment in mail clients like Thunderbird or Evolution. + $message->setAttachments((new Attachment( + $itip_msg, + null, + $contentType . '; name=event.ics', + true + ))); + // send message + $mailService->sendMessage($message); + } else { + // construct symfony mailer message and set required parameters + $message = $this->mailer->createMessage(); + $message->setFrom([$fromEMail => $fromName]); + $message->setTo( + (($recipientName !== null) ? [$recipient => $recipientName] : [$recipient]) + ); + $message->setReplyTo( + (($senderName !== null) ? [$sender => $senderName] : [$sender]) + ); + $message->useTemplate($template); + // Using a different content type because Symfony Mailer/Mime will append the name to + // the content type header and attachInline does not allow null. + $message->attachInline( + $itip_msg, + 'event.ics', + $contentType, + ); + $failed = $this->mailer->send($message); } - $template->addBodyListItem($organizerHTML, $l10n->t('Organizer:'), - $this->getAbsoluteImagePath('caldav/organizer.png'), - $organizerText,'',self::IMIP_INDENT); - } - $attendees = $vevent->select('ATTENDEE'); - if (count($attendees) === 0) { - return; - } - - $attendeesHTML = []; - $attendeesText = []; - foreach ($attendees as $attendee) { - $attendeeURI = $attendee->getNormalizedValue(); - [$scheme,$attendeeEmail] = explode(':',$attendeeURI,2); # strip off scheme mailto: - $attendeeName = isset($attendee['CN']) ? $attendee['CN'] : null; - $attendeeHTML = sprintf('<a href="%s">%s</a>', - htmlspecialchars($attendeeURI), - htmlspecialchars($attendeeName ?: $attendeeEmail)); - $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); - if (isset($attendee['PARTSTAT']) - && strcasecmp($attendee['PARTSTAT'], 'ACCEPTED') === 0) { - $attendeeHTML .= ' ✔︎'; - $attendeeText .= ' ✔︎'; + $iTipMessage->scheduleStatus = '1.1; Scheduling message is sent via iMip'; + if (!empty($failed)) { + $this->logger->error('Unable to deliver message to {failed}', ['app' => 'dav', 'failed' => implode(', ', $failed)]); + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; } - array_push($attendeesHTML, $attendeeHTML); - array_push($attendeesText, $attendeeText); + } catch (\Exception $ex) { + $this->logger->error($ex->getMessage(), ['app' => 'dav', 'exception' => $ex]); + $iTipMessage->scheduleStatus = '5.0; EMail delivery failed'; } - - $template->addBodyListItem(implode('<br/>',$attendeesHTML), $l10n->t('Attendees:'), - $this->getAbsoluteImagePath('caldav/attendees.png'), - implode("\n",$attendeesText),'',self::IMIP_INDENT); } /** - * @param IEMailTemplate $template - * @param IL10N $l10n - * @param Message $iTipMessage - * @param int $lastOccurrence + * @return ?VCalendar */ - private function addResponseButtons(IEMailTemplate $template, IL10N $l10n, - Message $iTipMessage, $lastOccurrence) { - $token = $this->createInvitationToken($iTipMessage, $lastOccurrence); - - $template->addBodyButtonGroup( - $l10n->t('Accept'), - $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ - 'token' => $token, - ]), - $l10n->t('Decline'), - $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ - 'token' => $token, - ]) - ); - - $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ - 'token' => $token, - ]); - $html = vsprintf('<small><a href="%s">%s</a></small>', [ - $moreOptionsURL, $l10n->t('More options …') - ]); - $text = $l10n->t('More options at %s', [$moreOptionsURL]); - - $template->addBodyText($html, $text); + public function getVCalendar(): ?VCalendar { + return $this->vCalendar; } /** - * @param string $path - * @return string + * @param ?VCalendar $vCalendar */ - private function getAbsoluteImagePath($path) { - return $this->urlGenerator->getAbsoluteURL( - $this->urlGenerator->imagePath('core', $path) - ); + public function setVCalendar(?VCalendar $vCalendar): void { + $this->vCalendar = $vCalendar; } - /** - * @param Message $iTipMessage - * @param int $lastOccurrence - * @return string - */ - private function createInvitationToken(Message $iTipMessage, $lastOccurrence):string { - $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); - - /** @var VEvent $vevent */ - $vevent = $iTipMessage->message->VEVENT; - $attendee = $iTipMessage->recipient; - $organizer = $iTipMessage->sender; - $sequence = $iTipMessage->sequence; - $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? - $vevent->{'RECURRENCE-ID'}->serialize() : null; - $uid = $vevent->{'UID'}; - - $query = $this->db->getQueryBuilder(); - $query->insert('calendar_invitations') - ->values([ - 'token' => $query->createNamedParameter($token), - 'attendee' => $query->createNamedParameter($attendee), - 'organizer' => $query->createNamedParameter($organizer), - 'sequence' => $query->createNamedParameter($sequence), - 'recurrenceid' => $query->createNamedParameter($recurrenceId), - 'expiration' => $query->createNamedParameter($lastOccurrence), - 'uid' => $query->createNamedParameter($uid) - ]) - ->execute(); - - return $token; - } } diff --git a/apps/dav/lib/CalDAV/Schedule/IMipService.php b/apps/dav/lib/CalDAV/Schedule/IMipService.php new file mode 100644 index 00000000000..54c0bc31849 --- /dev/null +++ b/apps/dav/lib/CalDAV/Schedule/IMipService.php @@ -0,0 +1,1294 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Schedule; + +use OC\URLGenerator; +use OCA\DAV\CalDAV\EventReader; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\L10N\IFactory as L10NFactory; +use OCP\Mail\IEMailTemplate; +use OCP\Security\ISecureRandom; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VEvent; +use Sabre\VObject\DateTimeParser; +use Sabre\VObject\ITip\Message; +use Sabre\VObject\Parameter; +use Sabre\VObject\Property; +use Sabre\VObject\Recur\EventIterator; + +class IMipService { + + private IL10N $l10n; + + /** @var string[] */ + private const STRING_DIFF = [ + 'meeting_title' => 'SUMMARY', + 'meeting_description' => 'DESCRIPTION', + 'meeting_url' => 'URL', + 'meeting_location' => 'LOCATION' + ]; + + public function __construct( + private URLGenerator $urlGenerator, + private IConfig $config, + private IDBConnection $db, + private ISecureRandom $random, + private L10NFactory $l10nFactory, + private ITimeFactory $timeFactory, + ) { + $language = $this->l10nFactory->findGenericLanguage(); + $locale = $this->l10nFactory->findLocale($language); + $this->l10n = $this->l10nFactory->get('dav', $language, $locale); + } + + /** + * @param string|null $senderName + * @param string $default + * @return string + */ + public function getFrom(?string $senderName, string $default): string { + if ($senderName === null) { + return $default; + } + + return $this->l10n->t('%1$s via %2$s', [$senderName, $default]); + } + + public static function readPropertyWithDefault(VEvent $vevent, string $property, string $default) { + if (isset($vevent->$property)) { + $value = $vevent->$property->getValue(); + if (!empty($value)) { + return $value; + } + } + return $default; + } + + private function generateDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string { + $strikethrough = "<span style='text-decoration: line-through'>%s</span><br />%s"; + if (!isset($vevent->$property)) { + return $default; + } + $newstring = $vevent->$property->getValue(); + if (isset($oldVEvent->$property) && $oldVEvent->$property->getValue() !== $newstring) { + $oldstring = $oldVEvent->$property->getValue(); + return sprintf($strikethrough, $oldstring, $newstring); + } + return $newstring; + } + + /** + * Like generateDiffString() but linkifies the property values if they are urls. + */ + private function generateLinkifiedDiffString(VEvent $vevent, VEvent $oldVEvent, string $property, string $default): ?string { + if (!isset($vevent->$property)) { + return $default; + } + /** @var string|null $newString */ + $newString = $vevent->$property->getValue(); + $oldString = isset($oldVEvent->$property) ? $oldVEvent->$property->getValue() : null; + if ($oldString !== $newString) { + return sprintf( + "<span style='text-decoration: line-through'>%s</span><br />%s", + $this->linkify($oldString) ?? $oldString ?? '', + $this->linkify($newString) ?? $newString ?? '' + ); + } + return $this->linkify($newString) ?? $newString; + } + + /** + * Convert a given url to a html link element or return null otherwise. + */ + private function linkify(?string $url): ?string { + if ($url === null) { + return null; + } + if (!str_starts_with($url, 'http://') && !str_starts_with($url, 'https://')) { + return null; + } + + return sprintf('<a href="%1$s">%1$s</a>', htmlspecialchars($url)); + } + + /** + * @param VEvent $vEvent + * @param VEvent|null $oldVEvent + * @return array + */ + public function buildBodyData(VEvent $vEvent, ?VEvent $oldVEvent): array { + + // construct event reader + $eventReaderCurrent = new EventReader($vEvent); + $eventReaderPrevious = !empty($oldVEvent) ? new EventReader($oldVEvent) : null; + $defaultVal = ''; + $data = []; + $data['meeting_when'] = $this->generateWhenString($eventReaderCurrent); + + foreach (self::STRING_DIFF as $key => $property) { + $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal); + } + + $data['meeting_url_html'] = self::readPropertyWithDefault($vEvent, 'URL', $defaultVal); + + if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) { + $data['meeting_location_html'] = $locationHtml; + } + + if (!empty($oldVEvent)) { + $oldMeetingWhen = $this->generateWhenString($eventReaderPrevious); + $data['meeting_title_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'SUMMARY', $data['meeting_title']); + $data['meeting_description_html'] = $this->generateDiffString($vEvent, $oldVEvent, 'DESCRIPTION', $data['meeting_description']); + $data['meeting_location_html'] = $this->generateLinkifiedDiffString($vEvent, $oldVEvent, 'LOCATION', $data['meeting_location']); + + $oldUrl = self::readPropertyWithDefault($oldVEvent, 'URL', $defaultVal); + $data['meeting_url_html'] = !empty($oldUrl) && $oldUrl !== $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $oldUrl) : $data['meeting_url']; + + $data['meeting_when_html'] = $oldMeetingWhen !== $data['meeting_when'] ? sprintf("<span style='text-decoration: line-through'>%s</span><br />%s", $oldMeetingWhen, $data['meeting_when']) : $data['meeting_when']; + } + // generate occurring next string + if ($eventReaderCurrent->recurs()) { + $data['meeting_occurring'] = $this->generateOccurringString($eventReaderCurrent); + } + return $data; + } + + /** + * @param VEvent $vEvent + * @return array + */ + public function buildReplyBodyData(VEvent $vEvent): array { + // construct event reader + $eventReader = new EventReader($vEvent); + $defaultVal = ''; + $data = []; + $data['meeting_when'] = $this->generateWhenString($eventReader); + + foreach (self::STRING_DIFF as $key => $property) { + $data[$key] = self::readPropertyWithDefault($vEvent, $property, $defaultVal); + } + + if (($locationHtml = $this->linkify($data['meeting_location'])) !== null) { + $data['meeting_location_html'] = $locationHtml; + } + + $data['meeting_url_html'] = $data['meeting_url'] ? sprintf('<a href="%1$s">%1$s</a>', $data['meeting_url']) : ''; + + // generate occurring next string + if ($eventReader->recurs()) { + $data['meeting_occurring'] = $this->generateOccurringString($eventReader); + } + + return $data; + } + + /** + * generates a when string based on if a event has an recurrence or not + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenString(EventReader $er): string { + return match ($er->recurs()) { + true => $this->generateWhenStringRecurring($er), + false => $this->generateWhenStringSingular($er) + }; + } + + /** + * generates a when string for a non recurring event + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringSingular(EventReader $er): string { + // initialize + $startTime = null; + $endTime = null; + // calculate time difference from now to start of event + $occurring = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate())); + // extract start date + $startDate = $this->l10n->l('date', $er->startDateTime(), ['width' => 'full']); + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // In a minute/hour/day/week/month/year on July 1, 2024 for the entire day + // In a minute/hour/day/week/month/year on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto) + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 for the entire day + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 between 8:00 AM - 9:00 AM (America/Toronto) + return match ([$occurring['scale'], $endTime !== null]) { + ['past', false] => $this->l10n->t( + 'In the past on %1$s for the entire day', + [$startDate] + ), + ['minute', false] => $this->l10n->n( + 'In a minute on %1$s for the entire day', + 'In %n minutes on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['hour', false] => $this->l10n->n( + 'In a hour on %1$s for the entire day', + 'In %n hours on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['day', false] => $this->l10n->n( + 'In a day on %1$s for the entire day', + 'In %n days on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['week', false] => $this->l10n->n( + 'In a week on %1$s for the entire day', + 'In %n weeks on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['month', false] => $this->l10n->n( + 'In a month on %1$s for the entire day', + 'In %n months on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['year', false] => $this->l10n->n( + 'In a year on %1$s for the entire day', + 'In %n years on %1$s for the entire day', + $occurring['interval'], + [$startDate] + ), + ['past', true] => $this->l10n->t( + 'In the past on %1$s between %2$s - %3$s', + [$startDate, $startTime, $endTime] + ), + ['minute', true] => $this->l10n->n( + 'In a minute on %1$s between %2$s - %3$s', + 'In %n minutes on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['hour', true] => $this->l10n->n( + 'In a hour on %1$s between %2$s - %3$s', + 'In %n hours on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['day', true] => $this->l10n->n( + 'In a day on %1$s between %2$s - %3$s', + 'In %n days on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['week', true] => $this->l10n->n( + 'In a week on %1$s between %2$s - %3$s', + 'In %n weeks on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['month', true] => $this->l10n->n( + 'In a month on %1$s between %2$s - %3$s', + 'In %n months on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + ['year', true] => $this->l10n->n( + 'In a year on %1$s between %2$s - %3$s', + 'In %n years on %1$s between %2$s - %3$s', + $occurring['interval'], + [$startDate, $startTime, $endTime] + ), + default => $this->l10n->t('Could not generate when statement') + }; + } + + /** + * generates a when string based on recurrence precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurring(EventReader $er): string { + return match ($er->recurringPrecision()) { + 'daily' => $this->generateWhenStringRecurringDaily($er), + 'weekly' => $this->generateWhenStringRecurringWeekly($er), + 'monthly' => $this->generateWhenStringRecurringMonthly($er), + 'yearly' => $this->generateWhenStringRecurringYearly($er), + 'fixed' => $this->generateWhenStringRecurringFixed($er), + }; + } + + /** + * generates a when string for a daily precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringDaily(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // Every Day for the entire day + // Every Day for the entire day until July 13, 2024 + // Every Day between 8:00 AM - 9:00 AM (America/Toronto) + // Every Day between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + // Every 3 Days for the entire day + // Every 3 Days for the entire day until July 13, 2024 + // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) + // Every 3 Days between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Day for the entire day'), + [false, false, true] => $this->l10n->t('Every Day for the entire day until %1$s', [$conclusion]), + [false, true, false] => $this->l10n->t('Every Day between %1$s - %2$s', [$startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Day between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Days for the entire day', [$interval]), + [true, false, true] => $this->l10n->t('Every %1$d Days for the entire day until %2$s', [$interval, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Days between %2$s - %3$s', [$interval, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Days between %2$s - %3$s until %4$s', [$interval, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + + } + + /** + * generates a when string for a weekly precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringWeekly(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // days of the week + $days = implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // Every Week on Monday, Wednesday, Friday for the entire day + // Every Week on Monday, Wednesday, Friday for the entire day until July 13, 2024 + // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) + // Every Week on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + // Every 2 Weeks on Monday, Wednesday, Friday for the entire day + // Every 2 Weeks on Monday, Wednesday, Friday for the entire day until July 13, 2024 + // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) + // Every 2 Weeks on Monday, Wednesday, Friday between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Week on %1$s for the entire day', [$days]), + [false, false, true] => $this->l10n->t('Every Week on %1$s for the entire day until %2$s', [$days, $conclusion]), + [false, true, false] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s', [$days, $startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Week on %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day', [$interval, $days]), + [true, false, true] => $this->l10n->t('Every %1$d Weeks on %2$s for the entire day until %3$s', [$interval, $days, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Weeks on %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + + } + + /** + * generates a when string for a monthly precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringMonthly(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // days of month + if ($er->recurringPattern() === 'R') { + $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' + . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + } else { + $days = implode(', ', $er->recurringDaysOfMonth()); + } + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order, output varies depending on if the event is absolute or releative: + // Absolute: Every Month on the 1, 8 for the entire day + // Relative: Every Month on the First Sunday, Saturday for the entire day + // Absolute: Every Month on the 1, 8 for the entire day until December 31, 2024 + // Relative: Every Month on the First Sunday, Saturday for the entire day until December 31, 2024 + // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every Month on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + // Relative: Every Month on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + // Absolute: Every 2 Months on the 1, 8 for the entire day + // Relative: Every 2 Months on the First Sunday, Saturday for the entire day + // Absolute: Every 2 Months on the 1, 8 for the entire day until December 31, 2024 + // Relative: Every 2 Months on the First Sunday, Saturday for the entire day until December 31, 2024 + // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every 2 Months on the 1, 8 between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + // Relative: Every 2 Months on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until December 31, 2024 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Month on the %1$s for the entire day', [$days]), + [false, false, true] => $this->l10n->t('Every Month on the %1$s for the entire day until %2$s', [$days, $conclusion]), + [false, true, false] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s', [$days, $startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Month on the %1$s between %2$s - %3$s until %4$s', [$days, $startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day', [$interval, $days]), + [true, false, true] => $this->l10n->t('Every %1$d Months on the %2$s for the entire day until %3$s', [$interval, $days, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s', [$interval, $days, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Months on the %2$s between %3$s - %4$s until %5$s', [$interval, $days, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + } + + /** + * generates a when string for a yearly precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringYearly(EventReader $er): string { + + // initialize + $interval = (int)$er->recurringInterval(); + $startTime = null; + $conclusion = null; + // months of year + $months = implode(', ', array_map(function ($value) { return $this->localizeMonthName($value); }, $er->recurringMonthsOfYearNamed())); + // days of month + if ($er->recurringPattern() === 'R') { + $days = implode(', ', array_map(function ($value) { return $this->localizeRelativePositionName($value); }, $er->recurringRelativePositionNamed())) . ' ' + . implode(', ', array_map(function ($value) { return $this->localizeDayName($value); }, $er->recurringDaysOfWeekNamed())); + } else { + $days = $er->startDateTime()->format('jS'); + } + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + if ($er->recurringConcludes()) { + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order, output varies depending on if the event is absolute or releative: + // Absolute: Every Year in July on the 1st for the entire day + // Relative: Every Year in July on the First Sunday, Saturday for the entire day + // Absolute: Every Year in July on the 1st for the entire day until July 31, 2026 + // Relative: Every Year in July on the First Sunday, Saturday for the entire day until July 31, 2026 + // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every Year in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + // Relative: Every Year in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + // Absolute: Every 2 Years in July on the 1st for the entire day + // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day + // Absolute: Every 2 Years in July on the 1st for the entire day until July 31, 2026 + // Relative: Every 2 Years in July on the First Sunday, Saturday for the entire day until July 31, 2026 + // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) + // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) + // Absolute: Every 2 Years in July on the 1st between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + // Relative: Every 2 Years in July on the First Sunday, Saturday between 8:00 AM - 9:00 AM (America/Toronto) until July 31, 2026 + return match ([($interval > 1), $startTime !== null, $conclusion !== null]) { + [false, false, false] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day', [$months, $days]), + [false, false, true] => $this->l10n->t('Every Year in %1$s on the %2$s for the entire day until %3$s', [$months, $days, $conclusion]), + [false, true, false] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s', [$months, $days, $startTime, $endTime]), + [false, true, true] => $this->l10n->t('Every Year in %1$s on the %2$s between %3$s - %4$s until %5$s', [$months, $days, $startTime, $endTime, $conclusion]), + [true, false, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day', [$interval, $months, $days]), + [true, false, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s for the entire day until %4$s', [$interval, $months, $days, $conclusion]), + [true, true, false] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s', [$interval, $months, $days, $startTime, $endTime]), + [true, true, true] => $this->l10n->t('Every %1$d Years in %2$s on the %3$s between %4$s - %5$s until %6$s', [$interval, $months, $days, $startTime, $endTime, $conclusion]), + default => $this->l10n->t('Could not generate event recurrence statement') + }; + } + + /** + * generates a when string for a fixed precision/frequency + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateWhenStringRecurringFixed(EventReader $er): string { + // initialize + $startTime = null; + $conclusion = null; + // time of the day + if (!$er->entireDay()) { + $startTime = $this->l10n->l('time', $er->startDateTime(), ['width' => 'short']); + $startTime .= $er->startTimeZone() != $er->endTimeZone() ? ' (' . $er->startTimeZone()->getName() . ')' : ''; + $endTime = $this->l10n->l('time', $er->endDateTime(), ['width' => 'short']) . ' (' . $er->endTimeZone()->getName() . ')'; + } + // conclusion + $conclusion = $this->l10n->l('date', $er->recurringConcludesOn(), ['width' => 'long']); + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // On specific dates for the entire day until July 13, 2024 + // On specific dates between 8:00 AM - 9:00 AM (America/Toronto) until July 13, 2024 + return match ($startTime !== null) { + false => $this->l10n->t('On specific dates for the entire day until %1$s', [$conclusion]), + true => $this->l10n->t('On specific dates between %1$s - %2$s until %3$s', [$startTime, $endTime, $conclusion]), + }; + } + + /** + * generates a occurring next string for a recurring event + * + * @since 30.0.0 + * + * @param EventReader $er + * + * @return string + */ + public function generateOccurringString(EventReader $er): string { + + // initialize + $occurrence = null; + $occurrence2 = null; + $occurrence3 = null; + // reset to initial occurrence + $er->recurrenceRewind(); + // forward to current date + $er->recurrenceAdvanceTo($this->timeFactory->getDateTime()); + // calculate time difference from now to start of next event occurrence and minimize it + $occurrenceIn = $this->minimizeInterval($this->timeFactory->getDateTime()->diff($er->recurrenceDate())); + // store next occurrence value + $occurrence = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']); + // forward one occurrence + $er->recurrenceAdvance(); + // evaluate if occurrence is valid + if ($er->recurrenceDate() !== null) { + // store following occurrence value + $occurrence2 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']); + // forward one occurrence + $er->recurrenceAdvance(); + // evaluate if occurrence is valid + if ($er->recurrenceDate()) { + // store following occurrence value + $occurrence3 = $this->l10n->l('date', $er->recurrenceDate(), ['width' => 'long']); + } + } + // generate localized when string + // TRANSLATORS + // Indicates when a calendar event will happen, shown on invitation emails + // Output produced in order: + // In a minute/hour/day/week/month/year on July 1, 2024 + // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 + // In a minute/hour/day/week/month/year on July 1, 2024 then on July 3, 2024 and July 5, 2024 + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 + // In 2 minutes/hours/days/weeks/months/years on July 1, 2024 then on July 3, 2024 and July 5, 2024 + return match ([$occurrenceIn['scale'], $occurrence2 !== null, $occurrence3 !== null]) { + ['past', false, false] => $this->l10n->t( + 'In the past on %1$s', + [$occurrence] + ), + ['minute', false, false] => $this->l10n->n( + 'In a minute on %1$s', + 'In %n minutes on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['hour', false, false] => $this->l10n->n( + 'In a hour on %1$s', + 'In %n hours on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['day', false, false] => $this->l10n->n( + 'In a day on %1$s', + 'In %n days on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['week', false, false] => $this->l10n->n( + 'In a week on %1$s', + 'In %n weeks on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['month', false, false] => $this->l10n->n( + 'In a month on %1$s', + 'In %n months on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['year', false, false] => $this->l10n->n( + 'In a year on %1$s', + 'In %n years on %1$s', + $occurrenceIn['interval'], + [$occurrence] + ), + ['past', true, false] => $this->l10n->t( + 'In the past on %1$s then on %2$s', + [$occurrence, $occurrence2] + ), + ['minute', true, false] => $this->l10n->n( + 'In a minute on %1$s then on %2$s', + 'In %n minutes on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['hour', true, false] => $this->l10n->n( + 'In a hour on %1$s then on %2$s', + 'In %n hours on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['day', true, false] => $this->l10n->n( + 'In a day on %1$s then on %2$s', + 'In %n days on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['week', true, false] => $this->l10n->n( + 'In a week on %1$s then on %2$s', + 'In %n weeks on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['month', true, false] => $this->l10n->n( + 'In a month on %1$s then on %2$s', + 'In %n months on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['year', true, false] => $this->l10n->n( + 'In a year on %1$s then on %2$s', + 'In %n years on %1$s then on %2$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2] + ), + ['past', true, true] => $this->l10n->t( + 'In the past on %1$s then on %2$s and %3$s', + [$occurrence, $occurrence2, $occurrence3] + ), + ['minute', true, true] => $this->l10n->n( + 'In a minute on %1$s then on %2$s and %3$s', + 'In %n minutes on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['hour', true, true] => $this->l10n->n( + 'In a hour on %1$s then on %2$s and %3$s', + 'In %n hours on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['day', true, true] => $this->l10n->n( + 'In a day on %1$s then on %2$s and %3$s', + 'In %n days on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['week', true, true] => $this->l10n->n( + 'In a week on %1$s then on %2$s and %3$s', + 'In %n weeks on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['month', true, true] => $this->l10n->n( + 'In a month on %1$s then on %2$s and %3$s', + 'In %n months on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + ['year', true, true] => $this->l10n->n( + 'In a year on %1$s then on %2$s and %3$s', + 'In %n years on %1$s then on %2$s and %3$s', + $occurrenceIn['interval'], + [$occurrence, $occurrence2, $occurrence3] + ), + default => $this->l10n->t('Could not generate next recurrence statement') + }; + + } + + /** + * @param VEvent $vEvent + * @return array + */ + public function buildCancelledBodyData(VEvent $vEvent): array { + // construct event reader + $eventReaderCurrent = new EventReader($vEvent); + $defaultVal = ''; + $strikethrough = "<span style='text-decoration: line-through'>%s</span>"; + + $newMeetingWhen = $this->generateWhenString($eventReaderCurrent); + $newSummary = isset($vEvent->SUMMARY) && (string)$vEvent->SUMMARY !== '' ? (string)$vEvent->SUMMARY : $this->l10n->t('Untitled event'); + $newDescription = isset($vEvent->DESCRIPTION) && (string)$vEvent->DESCRIPTION !== '' ? (string)$vEvent->DESCRIPTION : $defaultVal; + $newUrl = isset($vEvent->URL) && (string)$vEvent->URL !== '' ? sprintf('<a href="%1$s">%1$s</a>', $vEvent->URL) : $defaultVal; + $newLocation = isset($vEvent->LOCATION) && (string)$vEvent->LOCATION !== '' ? (string)$vEvent->LOCATION : $defaultVal; + $newLocationHtml = $this->linkify($newLocation) ?? $newLocation; + + $data = []; + $data['meeting_when_html'] = $newMeetingWhen === '' ?: sprintf($strikethrough, $newMeetingWhen); + $data['meeting_when'] = $newMeetingWhen; + $data['meeting_title_html'] = sprintf($strikethrough, $newSummary); + $data['meeting_title'] = $newSummary !== '' ? $newSummary: $this->l10n->t('Untitled event'); + $data['meeting_description_html'] = $newDescription !== '' ? sprintf($strikethrough, $newDescription) : ''; + $data['meeting_description'] = $newDescription; + $data['meeting_url_html'] = $newUrl !== '' ? sprintf($strikethrough, $newUrl) : ''; + $data['meeting_url'] = isset($vEvent->URL) ? (string)$vEvent->URL : ''; + $data['meeting_location_html'] = $newLocationHtml !== '' ? sprintf($strikethrough, $newLocationHtml) : ''; + $data['meeting_location'] = $newLocation; + return $data; + } + + /** + * Check if event took place in the past + * + * @param VCalendar $vObject + * @return int + */ + public function getLastOccurrence(VCalendar $vObject) { + /** @var VEvent $component */ + $component = $vObject->VEVENT; + + if (isset($component->RRULE)) { + $it = new EventIterator($vObject, (string)$component->UID); + $maxDate = new \DateTime(IMipPlugin::MAX_DATE); + if ($it->isInfinite()) { + return $maxDate->getTimestamp(); + } + + $end = $it->getDtEnd(); + while ($it->valid() && $end < $maxDate) { + $end = $it->getDtEnd(); + $it->next(); + } + return $end->getTimestamp(); + } + + /** @var Property\ICalendar\DateTime $dtStart */ + $dtStart = $component->DTSTART; + + if (isset($component->DTEND)) { + /** @var Property\ICalendar\DateTime $dtEnd */ + $dtEnd = $component->DTEND; + return $dtEnd->getDateTime()->getTimeStamp(); + } + + if (isset($component->DURATION)) { + /** @var \DateTime $endDate */ + $endDate = clone $dtStart->getDateTime(); + // $component->DTEND->getDateTime() returns DateTimeImmutable + $endDate = $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); + return $endDate->getTimestamp(); + } + + if (!$dtStart->hasTime()) { + /** @var \DateTime $endDate */ + // $component->DTSTART->getDateTime() returns DateTimeImmutable + $endDate = clone $dtStart->getDateTime(); + $endDate = $endDate->modify('+1 day'); + return $endDate->getTimestamp(); + } + + // No computation of end time possible - return start date + return $dtStart->getDateTime()->getTimeStamp(); + } + + /** + * @param Property|null $attendee + */ + public function setL10n(?Property $attendee = null) { + if ($attendee === null) { + return; + } + + $lang = $attendee->offsetGet('LANGUAGE'); + if ($lang instanceof Parameter) { + $lang = $lang->getValue(); + $this->l10n = $this->l10nFactory->get('dav', $lang); + } + } + + /** + * @param Property|null $attendee + * @return bool + */ + public function getAttendeeRsvpOrReqForParticipant(?Property $attendee = null) { + if ($attendee === null) { + return false; + } + + $rsvp = $attendee->offsetGet('RSVP'); + if (($rsvp instanceof Parameter) && (strcasecmp($rsvp->getValue(), 'TRUE') === 0)) { + return true; + } + $role = $attendee->offsetGet('ROLE'); + // @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.2.16 + // Attendees without a role are assumed required and should receive an invitation link even if they have no RSVP set + if ($role === null + || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'REQ-PARTICIPANT') === 0)) + || (($role instanceof Parameter) && (strcasecmp($role->getValue(), 'OPT-PARTICIPANT') === 0)) + ) { + return true; + } + + // RFC 5545 3.2.17: default RSVP is false + return false; + } + + /** + * @param IEMailTemplate $template + * @param string $method + * @param string $sender + * @param string $summary + * @param string|null $partstat + * @param bool $isModified + */ + public function addSubjectAndHeading(IEMailTemplate $template, + string $method, string $sender, string $summary, bool $isModified, ?Property $replyingAttendee = null): void { + if ($method === IMipPlugin::METHOD_CANCEL) { + // TRANSLATORS Subject for email, when an invitation is cancelled. Ex: "Cancelled: {{Event Name}}" + $template->setSubject($this->l10n->t('Cancelled: %1$s', [$summary])); + $template->addHeading($this->l10n->t('"%1$s" has been canceled', [$summary])); + } elseif ($method === IMipPlugin::METHOD_REPLY) { + // TRANSLATORS Subject for email, when an invitation is replied to. Ex: "Re: {{Event Name}}" + $template->setSubject($this->l10n->t('Re: %1$s', [$summary])); + // Build the strings + $partstat = (isset($replyingAttendee)) ? $replyingAttendee->offsetGet('PARTSTAT') : null; + $partstat = ($partstat instanceof Parameter) ? $partstat->getValue() : null; + switch ($partstat) { + case 'ACCEPTED': + $template->addHeading($this->l10n->t('%1$s has accepted your invitation', [$sender])); + break; + case 'TENTATIVE': + $template->addHeading($this->l10n->t('%1$s has tentatively accepted your invitation', [$sender])); + break; + case 'DECLINED': + $template->addHeading($this->l10n->t('%1$s has declined your invitation', [$sender])); + break; + case null: + default: + $template->addHeading($this->l10n->t('%1$s has responded to your invitation', [$sender])); + break; + } + } elseif ($method === IMipPlugin::METHOD_REQUEST && $isModified) { + // TRANSLATORS Subject for email, when an invitation is updated. Ex: "Invitation updated: {{Event Name}}" + $template->setSubject($this->l10n->t('Invitation updated: %1$s', [$summary])); + $template->addHeading($this->l10n->t('%1$s updated the event "%2$s"', [$sender, $summary])); + } else { + // TRANSLATORS Subject for email, when an invitation is sent. Ex: "Invitation: {{Event Name}}" + $template->setSubject($this->l10n->t('Invitation: %1$s', [$summary])); + $template->addHeading($this->l10n->t('%1$s would like to invite you to "%2$s"', [$sender, $summary])); + } + } + + /** + * @param string $path + * @return string + */ + public function getAbsoluteImagePath($path): string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath('core', $path) + ); + } + + /** + * addAttendees: add organizer and attendee names/emails to iMip mail. + * + * Enable with DAV setting: invitation_list_attendees (default: no) + * + * The default is 'no', which matches old behavior, and is privacy preserving. + * + * To enable including attendees in invitation emails: + * % php occ config:app:set dav invitation_list_attendees --value yes + * + * @param IEMailTemplate $template + * @param IL10N $this->l10n + * @param VEvent $vevent + * @author brad2014 on github.com + */ + public function addAttendees(IEMailTemplate $template, VEvent $vevent) { + if ($this->config->getAppValue('dav', 'invitation_list_attendees', 'no') === 'no') { + return; + } + + if (isset($vevent->ORGANIZER)) { + /** @var Property | Property\ICalendar\CalAddress $organizer */ + $organizer = $vevent->ORGANIZER; + $organizerEmail = substr($organizer->getNormalizedValue(), 7); + /** @var string|null $organizerName */ + $organizerName = isset($organizer->CN) ? $organizer->CN->getValue() : null; + $organizerHTML = sprintf('<a href="%s">%s</a>', + htmlspecialchars($organizer->getNormalizedValue()), + htmlspecialchars($organizerName ?: $organizerEmail)); + $organizerText = sprintf('%s <%s>', $organizerName, $organizerEmail); + if (isset($organizer['PARTSTAT'])) { + /** @var Parameter $partstat */ + $partstat = $organizer['PARTSTAT']; + if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { + $organizerHTML .= ' ✔︎'; + $organizerText .= ' ✔︎'; + } + } + $template->addBodyListItem($organizerHTML, $this->l10n->t('Organizer:'), + $this->getAbsoluteImagePath('caldav/organizer.png'), + $organizerText, '', IMipPlugin::IMIP_INDENT); + } + + $attendees = $vevent->select('ATTENDEE'); + if (count($attendees) === 0) { + return; + } + + $attendeesHTML = []; + $attendeesText = []; + foreach ($attendees as $attendee) { + $attendeeEmail = substr($attendee->getNormalizedValue(), 7); + $attendeeName = isset($attendee['CN']) ? $attendee['CN']->getValue() : null; + $attendeeHTML = sprintf('<a href="%s">%s</a>', + htmlspecialchars($attendee->getNormalizedValue()), + htmlspecialchars($attendeeName ?: $attendeeEmail)); + $attendeeText = sprintf('%s <%s>', $attendeeName, $attendeeEmail); + if (isset($attendee['PARTSTAT'])) { + /** @var Parameter $partstat */ + $partstat = $attendee['PARTSTAT']; + if (strcasecmp($partstat->getValue(), 'ACCEPTED') === 0) { + $attendeeHTML .= ' ✔︎'; + $attendeeText .= ' ✔︎'; + } + } + $attendeesHTML[] = $attendeeHTML; + $attendeesText[] = $attendeeText; + } + + $template->addBodyListItem(implode('<br/>', $attendeesHTML), $this->l10n->t('Attendees:'), + $this->getAbsoluteImagePath('caldav/attendees.png'), + implode("\n", $attendeesText), '', IMipPlugin::IMIP_INDENT); + } + + /** + * @param IEMailTemplate $template + * @param VEVENT $vevent + * @param $data + */ + public function addBulletList(IEMailTemplate $template, VEvent $vevent, $data) { + $template->addBodyListItem( + $data['meeting_title_html'] ?? $data['meeting_title'], $this->l10n->t('Title:'), + $this->getAbsoluteImagePath('caldav/title.png'), $data['meeting_title'], '', IMipPlugin::IMIP_INDENT); + if ($data['meeting_when'] !== '') { + $template->addBodyListItem($data['meeting_when_html'] ?? $data['meeting_when'], $this->l10n->t('When:'), + $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_when'], '', IMipPlugin::IMIP_INDENT); + } + if ($data['meeting_location'] !== '') { + $template->addBodyListItem($data['meeting_location_html'] ?? $data['meeting_location'], $this->l10n->t('Location:'), + $this->getAbsoluteImagePath('caldav/location.png'), $data['meeting_location'], '', IMipPlugin::IMIP_INDENT); + } + if ($data['meeting_url'] !== '') { + $template->addBodyListItem($data['meeting_url_html'] ?? $data['meeting_url'], $this->l10n->t('Link:'), + $this->getAbsoluteImagePath('caldav/link.png'), $data['meeting_url'], '', IMipPlugin::IMIP_INDENT); + } + if (isset($data['meeting_occurring'])) { + $template->addBodyListItem($data['meeting_occurring_html'] ?? $data['meeting_occurring'], $this->l10n->t('Occurring:'), + $this->getAbsoluteImagePath('caldav/time.png'), $data['meeting_occurring'], '', IMipPlugin::IMIP_INDENT); + } + + $this->addAttendees($template, $vevent); + + /* Put description last, like an email body, since it can be arbitrarily long */ + if ($data['meeting_description']) { + $template->addBodyListItem($data['meeting_description_html'] ?? $data['meeting_description'], $this->l10n->t('Description:'), + $this->getAbsoluteImagePath('caldav/description.png'), $data['meeting_description'], '', IMipPlugin::IMIP_INDENT); + } + } + + /** + * @param Message $iTipMessage + * @return null|Property + */ + public function getCurrentAttendee(Message $iTipMessage): ?Property { + /** @var VEvent $vevent */ + $vevent = $iTipMessage->message->VEVENT; + $attendees = $vevent->select('ATTENDEE'); + foreach ($attendees as $attendee) { + if ($iTipMessage->method === 'REPLY' && strcasecmp($attendee->getValue(), $iTipMessage->sender) === 0) { + /** @var Property $attendee */ + return $attendee; + } elseif (strcasecmp($attendee->getValue(), $iTipMessage->recipient) === 0) { + /** @var Property $attendee */ + return $attendee; + } + } + return null; + } + + /** + * @param Message $iTipMessage + * @param VEvent $vevent + * @param int $lastOccurrence + * @return string + */ + public function createInvitationToken(Message $iTipMessage, VEvent $vevent, int $lastOccurrence): string { + $token = $this->random->generate(60, ISecureRandom::CHAR_ALPHANUMERIC); + + $attendee = $iTipMessage->recipient; + $organizer = $iTipMessage->sender; + $sequence = $iTipMessage->sequence; + $recurrenceId = isset($vevent->{'RECURRENCE-ID'}) + ? $vevent->{'RECURRENCE-ID'}->serialize() : null; + $uid = $vevent->{'UID'}?->getValue(); + + $query = $this->db->getQueryBuilder(); + $query->insert('calendar_invitations') + ->values([ + 'token' => $query->createNamedParameter($token), + 'attendee' => $query->createNamedParameter($attendee), + 'organizer' => $query->createNamedParameter($organizer), + 'sequence' => $query->createNamedParameter($sequence), + 'recurrenceid' => $query->createNamedParameter($recurrenceId), + 'expiration' => $query->createNamedParameter($lastOccurrence), + 'uid' => $query->createNamedParameter($uid) + ]) + ->executeStatement(); + + return $token; + } + + /** + * @param IEMailTemplate $template + * @param $token + */ + public function addResponseButtons(IEMailTemplate $template, $token) { + $template->addBodyButtonGroup( + $this->l10n->t('Accept'), + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.accept', [ + 'token' => $token, + ]), + $this->l10n->t('Decline'), + $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.decline', [ + 'token' => $token, + ]) + ); + } + + public function addMoreOptionsButton(IEMailTemplate $template, $token) { + $moreOptionsURL = $this->urlGenerator->linkToRouteAbsolute('dav.invitation_response.options', [ + 'token' => $token, + ]); + $html = vsprintf('<small><a href="%s">%s</a></small>', [ + $moreOptionsURL, $this->l10n->t('More options …') + ]); + $text = $this->l10n->t('More options at %s', [$moreOptionsURL]); + + $template->addBodyText($html, $text); + } + + public function getReplyingAttendee(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->sender) === 0) { + return $attendee; + } + } + return null; + } + + public function isRoomOrResource(Property $attendee): bool { + $cuType = $attendee->offsetGet('CUTYPE'); + if (!$cuType instanceof Parameter) { + return false; + } + $type = $cuType->getValue() ?? 'INDIVIDUAL'; + if (\in_array(strtoupper($type), ['RESOURCE', 'ROOM'], true)) { + // Don't send emails to things + return true; + } + return false; + } + + public function isCircle(Property $attendee): bool { + $cuType = $attendee->offsetGet('CUTYPE'); + if (!$cuType instanceof Parameter) { + return false; + } + + $uri = $attendee->getValue(); + if (!$uri) { + return false; + } + + $cuTypeValue = $cuType->getValue(); + return $cuTypeValue === 'GROUP' && str_starts_with($uri, 'mailto:circle+'); + } + + public function minimizeInterval(\DateInterval $dateInterval): array { + // evaluate if time interval is in the past + if ($dateInterval->invert == 1) { + return ['interval' => 1, 'scale' => 'past']; + } + // evaluate interval parts and return smallest time period + if ($dateInterval->y > 0) { + $interval = $dateInterval->y; + $scale = 'year'; + } elseif ($dateInterval->m > 0) { + $interval = $dateInterval->m; + $scale = 'month'; + } elseif ($dateInterval->d >= 7) { + $interval = (int)($dateInterval->d / 7); + $scale = 'week'; + } elseif ($dateInterval->d > 0) { + $interval = $dateInterval->d; + $scale = 'day'; + } elseif ($dateInterval->h > 0) { + $interval = $dateInterval->h; + $scale = 'hour'; + } else { + $interval = $dateInterval->i; + $scale = 'minute'; + } + + return ['interval' => $interval, 'scale' => $scale]; + } + + /** + * Localizes week day names to another language + * + * @param string $value + * + * @return string + */ + public function localizeDayName(string $value): string { + return match ($value) { + 'Monday' => $this->l10n->t('Monday'), + 'Tuesday' => $this->l10n->t('Tuesday'), + 'Wednesday' => $this->l10n->t('Wednesday'), + 'Thursday' => $this->l10n->t('Thursday'), + 'Friday' => $this->l10n->t('Friday'), + 'Saturday' => $this->l10n->t('Saturday'), + 'Sunday' => $this->l10n->t('Sunday'), + }; + } + + /** + * Localizes month names to another language + * + * @param string $value + * + * @return string + */ + public function localizeMonthName(string $value): string { + return match ($value) { + 'January' => $this->l10n->t('January'), + 'February' => $this->l10n->t('February'), + 'March' => $this->l10n->t('March'), + 'April' => $this->l10n->t('April'), + 'May' => $this->l10n->t('May'), + 'June' => $this->l10n->t('June'), + 'July' => $this->l10n->t('July'), + 'August' => $this->l10n->t('August'), + 'September' => $this->l10n->t('September'), + 'October' => $this->l10n->t('October'), + 'November' => $this->l10n->t('November'), + 'December' => $this->l10n->t('December'), + }; + } + + /** + * Localizes relative position names to another language + * + * @param string $value + * + * @return string + */ + public function localizeRelativePositionName(string $value): string { + return match ($value) { + 'First' => $this->l10n->t('First'), + 'Second' => $this->l10n->t('Second'), + 'Third' => $this->l10n->t('Third'), + 'Fourth' => $this->l10n->t('Fourth'), + 'Fifth' => $this->l10n->t('Fifth'), + 'Last' => $this->l10n->t('Last'), + 'Second Last' => $this->l10n->t('Second Last'), + 'Third Last' => $this->l10n->t('Third Last'), + 'Fourth Last' => $this->l10n->t('Fourth Last'), + 'Fifth Last' => $this->l10n->t('Fifth Last'), + }; + } +} 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; + } + } + } } diff --git a/apps/dav/lib/CalDAV/Search/SearchPlugin.php b/apps/dav/lib/CalDAV/Search/SearchPlugin.php index d08a5749ab2..27e39a76305 100644 --- a/apps/dav/lib/CalDAV/Search/SearchPlugin.php +++ b/apps/dav/lib/CalDAV/Search/SearchPlugin.php @@ -1,33 +1,14 @@ <?php + /** - * @copyright Copyright (c) 2017 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> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search; use OCA\DAV\CalDAV\CalendarHome; use OCA\DAV\CalDAV\Search\Xml\Request\CalendarSearchReport; +use OCP\AppFramework\Http; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; @@ -81,8 +62,8 @@ class SearchPlugin extends ServerPlugin { $server->on('report', [$this, 'report']); - $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] = - CalendarSearchReport::class; + $server->xml->elementMap['{' . self::NS_Nextcloud . '}calendar-search'] + = CalendarSearchReport::class; } /** @@ -129,7 +110,7 @@ class SearchPlugin extends ServerPlugin { * This report is used by clients to request calendar objects based on * complex conditions. * - * @param Xml\Request\CalendarSearchReport $report + * @param CalendarSearchReport $report * @return void */ private function calendarSearch($report) { @@ -154,7 +135,7 @@ class SearchPlugin extends ServerPlugin { $prefer = $this->server->getHTTPPrefer(); - $this->server->httpResponse->setStatus(207); + $this->server->httpResponse->setStatus(Http::STATUS_MULTI_STATUS); $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8'); $this->server->httpResponse->setHeader('Vary', 'Brief,Prefer'); diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php index d5b7c834e36..21a4fff1caf 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/CompFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php index 2c435ba3650..a98b325397b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/LimitFilter.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php index a6f41d09161..ef438aa0258 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/OffsetFilter.php @@ -1,27 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php index c25450a0c94..0c31f32348a 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/ParamFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php index 990b0ebf730..251120e35cc 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/PropFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php index 06fe39a463b..6d6bf958496 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Filter/SearchTermFilter.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search\Xml\Filter; diff --git a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php index 98efe36ee43..6ece88fa87b 100644 --- a/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php +++ b/apps/dav/lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Georg Ehrke <oc.list@georgehrke.com> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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 OCA\DAV\CalDAV\Search\Xml\Request; diff --git a/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php new file mode 100644 index 00000000000..311157994e2 --- /dev/null +++ b/apps/dav/lib/CalDAV/Security/RateLimitingPlugin.php @@ -0,0 +1,87 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Security; + +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\Connector\Sabre\Exception\TooManyRequests; +use OCP\IAppConfig; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use Sabre\DAV; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ServerPlugin; +use function count; +use function explode; + +class RateLimitingPlugin extends ServerPlugin { + + private Limiter $limiter; + + public function __construct( + Limiter $limiter, + private IUserManager $userManager, + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + private IAppConfig $config, + private ?string $userId, + ) { + $this->limiter = $limiter; + } + + public function initialize(DAV\Server $server): void { + $server->on('beforeBind', [$this, 'beforeBind'], 1); + } + + public function beforeBind(string $path): void { + if ($this->userId === null) { + // We only care about authenticated users here + return; + } + $user = $this->userManager->get($this->userId); + if ($user === null) { + // We only care about authenticated users here + return; + } + + $pathParts = explode('/', $path); + if (count($pathParts) === 3 && $pathParts[0] === 'calendars') { + // Path looks like calendars/username/calendarname so a new calendar or subscription is created + try { + $this->limiter->registerUserRequest( + 'caldav-create-calendar', + $this->config->getValueInt('dav', 'rateLimitCalendarCreation', 10), + $this->config->getValueInt('dav', 'rateLimitPeriodCalendarCreation', 3600), + $user + ); + } catch (RateLimitExceededException $e) { + throw new TooManyRequests('Too many calendars created', 0, $e); + } + + $calendarLimit = $this->config->getValueInt('dav', 'maximumCalendarsSubscriptions', 30); + if ($calendarLimit === -1) { + return; + } + $numCalendars = $this->calDavBackend->getCalendarsForUserCount('principals/users/' . $user->getUID()); + $numSubscriptions = $this->calDavBackend->getSubscriptionsForUserCount('principals/users/' . $user->getUID()); + + if (($numCalendars + $numSubscriptions) >= $calendarLimit) { + $this->logger->warning('Maximum number of calendars/subscriptions reached', [ + 'calendars' => $numCalendars, + 'subscription' => $numSubscriptions, + 'limit' => $calendarLimit, + ]); + throw new Forbidden('Calendar limit reached', 0); + } + } + } + +} diff --git a/apps/dav/lib/CalDAV/Sharing/Backend.php b/apps/dav/lib/CalDAV/Sharing/Backend.php new file mode 100644 index 00000000000..fc5d65b5994 --- /dev/null +++ b/apps/dav/lib/CalDAV/Sharing/Backend.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Sharing; + +use OCA\DAV\Connector\Sabre\Principal; +use OCA\DAV\DAV\Sharing\Backend as SharingBackend; +use OCP\ICacheFactory; +use OCP\IGroupManager; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class Backend extends SharingBackend { + + public function __construct( + private IUserManager $userManager, + private IGroupManager $groupManager, + private Principal $principalBackend, + private ICacheFactory $cacheFactory, + private Service $service, + private LoggerInterface $logger, + ) { + parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->cacheFactory, $this->service, $this->logger); + } +} diff --git a/apps/dav/lib/CalDAV/Sharing/Service.php b/apps/dav/lib/CalDAV/Sharing/Service.php new file mode 100644 index 00000000000..4f0554f09bd --- /dev/null +++ b/apps/dav/lib/CalDAV/Sharing/Service.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV\Sharing; + +use OCA\DAV\DAV\Sharing\SharingMapper; +use OCA\DAV\DAV\Sharing\SharingService; + +class Service extends SharingService { + protected string $resourceType = 'calendar'; + public function __construct( + protected SharingMapper $mapper, + ) { + parent::__construct($mapper); + } +} diff --git a/apps/dav/lib/CalDAV/Status/StatusService.php b/apps/dav/lib/CalDAV/Status/StatusService.php new file mode 100644 index 00000000000..9ee0e9bf356 --- /dev/null +++ b/apps/dav/lib/CalDAV/Status/StatusService.php @@ -0,0 +1,186 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Status; + +use DateTimeImmutable; +use OC\Calendar\CalendarQuery; +use OCA\DAV\CalDAV\CalendarImpl; +use OCA\UserStatus\Service\StatusService as UserStatusService; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\IManager; +use OCP\DB\Exception; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IUser as User; +use OCP\IUserManager; +use OCP\User\IAvailabilityCoordinator; +use OCP\UserStatus\IUserStatus; +use Psr\Log\LoggerInterface; +use Sabre\CalDAV\Xml\Property\ScheduleCalendarTransp; + +class StatusService { + private ICache $cache; + public function __construct( + private ITimeFactory $timeFactory, + private IManager $calendarManager, + private IUserManager $userManager, + private UserStatusService $userStatusService, + private IAvailabilityCoordinator $availabilityCoordinator, + private ICacheFactory $cacheFactory, + private LoggerInterface $logger, + ) { + $this->cache = $cacheFactory->createLocal('CalendarStatusService'); + } + + public function processCalendarStatus(string $userId): void { + $user = $this->userManager->get($userId); + if ($user === null) { + return; + } + + $availability = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user); + if ($availability !== null && $this->availabilityCoordinator->isInEffect($availability)) { + $this->logger->debug('An Absence is in effect, skipping calendar status check', ['user' => $userId]); + return; + } + + $calendarEvents = $this->cache->get($userId); + if ($calendarEvents === null) { + $calendarEvents = $this->getCalendarEvents($user); + $this->cache->set($userId, $calendarEvents, 300); + } + + if (empty($calendarEvents)) { + try { + $this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY); + } catch (Exception $e) { + if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + // A different process might have written another status + // update to the DB while we're processing our stuff. + // We cannot safely restore the status as we don't know which one is valid at this point + // So let's silently log this one and exit + $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]); + return; + } + } + $this->logger->debug('No calendar events found for status check', ['user' => $userId]); + return; + } + + try { + $currentStatus = $this->userStatusService->findByUserId($userId); + // Was the status set by anything other than the calendar automation? + $userStatusTimestamp = $currentStatus->getIsUserDefined() && $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY ? $currentStatus->getStatusTimestamp() : null; + } catch (DoesNotExistException) { + $userStatusTimestamp = null; + $currentStatus = null; + } + + if (($currentStatus !== null && $currentStatus->getMessageId() === IUserStatus::MESSAGE_CALL) + || ($currentStatus !== null && $currentStatus->getStatus() === IUserStatus::DND) + || ($currentStatus !== null && $currentStatus->getStatus() === IUserStatus::INVISIBLE)) { + // We don't overwrite the call status, DND status or Invisible status + $this->logger->debug('Higher priority status detected, skipping calendar status change', ['user' => $userId]); + return; + } + + // Filter events to see if we have any that apply to the calendar status + $applicableEvents = array_filter($calendarEvents, static function (array $calendarEvent) use ($userStatusTimestamp): bool { + if (empty($calendarEvent['objects'])) { + return false; + } + $component = $calendarEvent['objects'][0]; + if (isset($component['X-NEXTCLOUD-OUT-OF-OFFICE'])) { + return false; + } + if (isset($component['DTSTART']) && $userStatusTimestamp !== null) { + /** @var DateTimeImmutable $dateTime */ + $dateTime = $component['DTSTART'][0]; + if ($dateTime instanceof DateTimeImmutable && $userStatusTimestamp > $dateTime->getTimestamp()) { + return false; + } + } + // Ignore events that are transparent + if (isset($component['TRANSP']) && strcasecmp($component['TRANSP'][0], 'TRANSPARENT') === 0) { + return false; + } + return true; + }); + + if (empty($applicableEvents)) { + try { + $this->userStatusService->revertUserStatus($userId, IUserStatus::MESSAGE_CALENDAR_BUSY); + } catch (Exception $e) { + if ($e->getReason() === Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) { + // A different process might have written another status + // update to the DB while we're processing our stuff. + // We cannot safely restore the status as we don't know which one is valid at this point + // So let's silently log this one and exit + $this->logger->debug('Unique constraint violation for live user status', ['exception' => $e]); + return; + } + } + $this->logger->debug('No status relevant events found, skipping calendar status change', ['user' => $userId]); + return; + } + + // Only update the status if it's neccesary otherwise we mess up the timestamp + if ($currentStatus === null || $currentStatus->getMessageId() !== IUserStatus::MESSAGE_CALENDAR_BUSY) { + // One event that fulfills all status conditions is enough + // 1. Not an OOO event + // 2. Current user status (that is not a calendar status) was not set after the start of this event + // 3. Event is not set to be transparent + $count = count($applicableEvents); + $this->logger->debug("Found $count applicable event(s), changing user status", ['user' => $userId]); + $this->userStatusService->setUserStatus( + $userId, + IUserStatus::BUSY, + IUserStatus::MESSAGE_CALENDAR_BUSY, + true + ); + } + } + + private function getCalendarEvents(User $user): array { + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $user->getUID()); + if (empty($calendars)) { + return []; + } + + $query = $this->calendarManager->newQuery('principals/users/' . $user->getUID()); + foreach ($calendars as $calendarObject) { + // We can only work with a calendar if it exposes its scheduling information + if (!$calendarObject instanceof CalendarImpl) { + continue; + } + + $sct = $calendarObject->getSchedulingTransparency(); + if ($sct !== null && strtolower($sct->getValue()) == ScheduleCalendarTransp::TRANSPARENT) { + // If a calendar is marked as 'transparent', it means we must + // ignore it for free-busy purposes. + continue; + } + $query->addSearchCalendar($calendarObject->getUri()); + } + + $dtStart = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime()); + $dtEnd = DateTimeImmutable::createFromMutable($this->timeFactory->getDateTime('+5 minutes')); + + // Only query the calendars when there's any to search + if ($query instanceof CalendarQuery && !empty($query->getCalendarUris())) { + // Query the next hour + $query->setTimerangeStart($dtStart); + $query->setTimerangeEnd($dtEnd); + return $this->calendarManager->searchForPrincipal($query); + } + + return []; + } +} diff --git a/apps/dav/lib/CalDAV/TimeZoneFactory.php b/apps/dav/lib/CalDAV/TimeZoneFactory.php new file mode 100644 index 00000000000..36a2c97be82 --- /dev/null +++ b/apps/dav/lib/CalDAV/TimeZoneFactory.php @@ -0,0 +1,213 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use DateTimeZone; + +/** + * Class to generate DateTimeZone object with automated Microsoft and IANA handling + * + * @since 31.0.0 + */ +class TimeZoneFactory { + + /** + * conversion table of Microsoft time zones to IANA time zones + * + * @var array<string,string> MS2IANA + */ + private const MS2IANA = [ + 'AUS Central Standard Time' => 'Australia/Darwin', + 'Aus Central W. Standard Time' => 'Australia/Eucla', + 'AUS Eastern Standard Time' => 'Australia/Sydney', + 'Afghanistan Standard Time' => 'Asia/Kabul', + 'Alaskan Standard Time' => 'America/Anchorage', + 'Aleutian Standard Time' => 'America/Adak', + 'Altai Standard Time' => 'Asia/Barnaul', + 'Arab Standard Time' => 'Asia/Riyadh', + 'Arabian Standard Time' => 'Asia/Dubai', + 'Arabic Standard Time' => 'Asia/Baghdad', + 'Argentina Standard Time' => 'America/Buenos_Aires', + 'Astrakhan Standard Time' => 'Europe/Astrakhan', + 'Atlantic Standard Time' => 'America/Halifax', + 'Azerbaijan Standard Time' => 'Asia/Baku', + 'Azores Standard Time' => 'Atlantic/Azores', + 'Bahia Standard Time' => 'America/Bahia', + 'Bangladesh Standard Time' => 'Asia/Dhaka', + 'Belarus Standard Time' => 'Europe/Minsk', + 'Bougainville Standard Time' => 'Pacific/Bougainville', + 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde', + 'Canada Central Standard Time' => 'America/Regina', + 'Caucasus Standard Time' => 'Asia/Yerevan', + 'Cen. Australia Standard Time' => 'Australia/Adelaide', + 'Central America Standard Time' => 'America/Guatemala', + 'Central Asia Standard Time' => 'Asia/Almaty', + 'Central Brazilian Standard Time' => 'America/Cuiaba', + 'Central Europe Standard Time' => 'Europe/Budapest', + 'Central European Standard Time' => 'Europe/Warsaw', + 'Central Pacific Standard Time' => 'Pacific/Guadalcanal', + 'Central Standard Time' => 'America/Chicago', + 'Central Standard Time (Mexico)' => 'America/Mexico_City', + 'Chatham Islands Standard Time' => 'Pacific/Chatham', + 'China Standard Time' => 'Asia/Shanghai', + 'Coordinated Universal Time' => 'UTC', + 'Cuba Standard Time' => 'America/Havana', + 'Dateline Standard Time' => 'Etc/GMT+12', + 'E. Africa Standard Time' => 'Africa/Nairobi', + 'E. Australia Standard Time' => 'Australia/Brisbane', + 'E. Europe Standard Time' => 'Europe/Chisinau', + 'E. South America Standard Time' => 'America/Sao_Paulo', + 'Easter Island Standard Time' => 'Pacific/Easter', + 'Eastern Standard Time' => 'America/Toronto', + 'Eastern Standard Time (Mexico)' => 'America/Cancun', + 'Egypt Standard Time' => 'Africa/Cairo', + 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg', + 'FLE Standard Time' => 'Europe/Kiev', + 'Fiji Standard Time' => 'Pacific/Fiji', + 'GMT Standard Time' => 'Europe/London', + 'GTB Standard Time' => 'Europe/Bucharest', + 'Georgian Standard Time' => 'Asia/Tbilisi', + 'Greenland Standard Time' => 'America/Godthab', + 'Greenland (Danmarkshavn)' => 'America/Godthab', + 'Greenwich Standard Time' => 'Atlantic/Reykjavik', + 'Haiti Standard Time' => 'America/Port-au-Prince', + 'Hawaiian Standard Time' => 'Pacific/Honolulu', + 'India Standard Time' => 'Asia/Kolkata', + 'Iran Standard Time' => 'Asia/Tehran', + 'Israel Standard Time' => 'Asia/Jerusalem', + 'Jordan Standard Time' => 'Asia/Amman', + 'Kaliningrad Standard Time' => 'Europe/Kaliningrad', + 'Kamchatka Standard Time' => 'Asia/Kamchatka', + 'Korea Standard Time' => 'Asia/Seoul', + 'Libya Standard Time' => 'Africa/Tripoli', + 'Line Islands Standard Time' => 'Pacific/Kiritimati', + 'Lord Howe Standard Time' => 'Australia/Lord_Howe', + 'Magadan Standard Time' => 'Asia/Magadan', + 'Magallanes Standard Time' => 'America/Punta_Arenas', + 'Malaysia Standard Time' => 'Asia/Kuala_Lumpur', + 'Marquesas Standard Time' => 'Pacific/Marquesas', + 'Mauritius Standard Time' => 'Indian/Mauritius', + 'Mid-Atlantic Standard Time' => 'Atlantic/South_Georgia', + 'Middle East Standard Time' => 'Asia/Beirut', + 'Montevideo Standard Time' => 'America/Montevideo', + 'Morocco Standard Time' => 'Africa/Casablanca', + 'Mountain Standard Time' => 'America/Denver', + 'Mountain Standard Time (Mexico)' => 'America/Chihuahua', + 'Myanmar Standard Time' => 'Asia/Rangoon', + 'N. Central Asia Standard Time' => 'Asia/Novosibirsk', + 'Namibia Standard Time' => 'Africa/Windhoek', + 'Nepal Standard Time' => 'Asia/Kathmandu', + 'New Zealand Standard Time' => 'Pacific/Auckland', + 'Newfoundland Standard Time' => 'America/St_Johns', + 'Norfolk Standard Time' => 'Pacific/Norfolk', + 'North Asia East Standard Time' => 'Asia/Irkutsk', + 'North Asia Standard Time' => 'Asia/Krasnoyarsk', + 'North Korea Standard Time' => 'Asia/Pyongyang', + 'Omsk Standard Time' => 'Asia/Omsk', + 'Pacific SA Standard Time' => 'America/Santiago', + 'Pacific Standard Time' => 'America/Los_Angeles', + 'Pacific Standard Time (Mexico)' => 'America/Tijuana', + 'Pakistan Standard Time' => 'Asia/Karachi', + 'Paraguay Standard Time' => 'America/Asuncion', + 'Qyzylorda Standard Time' => 'Asia/Qyzylorda', + 'Romance Standard Time' => 'Europe/Paris', + 'Russian Standard Time' => 'Europe/Moscow', + 'Russia Time Zone 10' => 'Asia/Srednekolymsk', + 'Russia Time Zone 3' => 'Europe/Samara', + 'SA Eastern Standard Time' => 'America/Cayenne', + 'SA Pacific Standard Time' => 'America/Bogota', + 'SA Western Standard Time' => 'America/La_Paz', + 'SE Asia Standard Time' => 'Asia/Bangkok', + 'Saint Pierre Standard Time' => 'America/Miquelon', + 'Sakhalin Standard Time' => 'Asia/Sakhalin', + 'Samoa Standard Time' => 'Pacific/Apia', + 'Sao Tome Standard Time' => 'Africa/Sao_Tome', + 'Saratov Standard Time' => 'Europe/Saratov', + 'Singapore Standard Time' => 'Asia/Singapore', + 'South Africa Standard Time' => 'Africa/Johannesburg', + 'South Sudan Standard Time' => 'Africa/Juba', + 'Sri Lanka Standard Time' => 'Asia/Colombo', + 'Sudan Standard Time' => 'Africa/Khartoum', + 'Syria Standard Time' => 'Asia/Damascus', + 'Taipei Standard Time' => 'Asia/Taipei', + 'Tasmania Standard Time' => 'Australia/Hobart', + 'Tocantins Standard Time' => 'America/Araguaina', + 'Tokyo Standard Time' => 'Asia/Tokyo', + 'Tomsk Standard Time' => 'Asia/Tomsk', + 'Tonga Standard Time' => 'Pacific/Tongatapu', + 'Transbaikal Standard Time' => 'Asia/Chita', + 'Turkey Standard Time' => 'Europe/Istanbul', + 'Turks And Caicos Standard Time' => 'America/Grand_Turk', + 'US Eastern Standard Time' => 'America/Indianapolis', + 'US Mountain Standard Time' => 'America/Phoenix', + 'UTC' => 'Etc/GMT', + 'UTC+13' => 'Etc/GMT-13', + 'UTC+12' => 'Etc/GMT-12', + 'UTC-02' => 'Etc/GMT+2', + 'UTC-09' => 'Etc/GMT+9', + 'UTC-11' => 'Etc/GMT+11', + 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar', + 'Venezuela Standard Time' => 'America/Caracas', + 'Vladivostok Standard Time' => 'Asia/Vladivostok', + 'Volgograd Standard Time' => 'Europe/Volgograd', + 'W. Australia Standard Time' => 'Australia/Perth', + 'W. Central Africa Standard Time' => 'Africa/Lagos', + 'W. Europe Standard Time' => 'Europe/Berlin', + 'W. Mongolia Standard Time' => 'Asia/Hovd', + 'West Asia Standard Time' => 'Asia/Tashkent', + 'West Bank Standard Time' => 'Asia/Hebron', + 'West Pacific Standard Time' => 'Pacific/Port_Moresby', + 'West Samoa Standard Time' => 'Pacific/Apia', + 'Yakutsk Standard Time' => 'Asia/Yakutsk', + 'Yukon Standard Time' => 'America/Whitehorse', + 'Yekaterinburg Standard Time' => 'Asia/Yekaterinburg', + ]; + + /** + * Determines if given time zone name is a Microsoft time zone + * + * @since 31.0.0 + * + * @param string $name time zone name + * + * @return bool + */ + public static function isMS(string $name): bool { + return isset(self::MS2IANA[$name]); + } + + /** + * Converts Microsoft time zone name to IANA time zone name + * + * @since 31.0.0 + * + * @param string $name microsoft time zone + * + * @return string|null valid IANA time zone name on success, or null on failure + */ + public static function toIANA(string $name): ?string { + return isset(self::MS2IANA[$name]) ? self::MS2IANA[$name] : null; + } + + /** + * Generates DateTimeZone object for given time zone name + * + * @since 31.0.0 + * + * @param string $name time zone name + * + * @return DateTimeZone|null + */ + public function fromName(string $name): ?DateTimeZone { + // if zone name is MS convert to IANA, otherwise just assume the zone is IANA + $zone = @timezone_open(self::toIANA($name) ?? $name); + return ($zone instanceof DateTimeZone) ? $zone : null; + } +} diff --git a/apps/dav/lib/CalDAV/TimezoneService.php b/apps/dav/lib/CalDAV/TimezoneService.php new file mode 100644 index 00000000000..a7709bde0f9 --- /dev/null +++ b/apps/dav/lib/CalDAV/TimezoneService.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use OCA\DAV\Db\PropertyMapper; +use OCP\Calendar\ICalendar; +use OCP\Calendar\IManager; +use OCP\IConfig; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\Reader; +use function array_reduce; + +class TimezoneService { + + public function __construct( + private IConfig $config, + private PropertyMapper $propertyMapper, + private IManager $calendarManager, + ) { + } + + public function getUserTimezone(string $userId): ?string { + $fromConfig = $this->config->getUserValue( + $userId, + 'core', + 'timezone', + ); + if ($fromConfig !== '') { + return $fromConfig; + } + + $availabilityPropPath = 'calendars/' . $userId . '/inbox'; + $availabilityProp = '{' . Plugin::NS_CALDAV . '}calendar-availability'; + $availabilities = $this->propertyMapper->findPropertyByPathAndName($userId, $availabilityPropPath, $availabilityProp); + if (!empty($availabilities)) { + $availability = $availabilities[0]->getPropertyvalue(); + /** @var VCalendar $vCalendar */ + $vCalendar = Reader::read($availability); + /** @var VTimeZone $vTimezone */ + $vTimezone = $vCalendar->VTIMEZONE; + // Sabre has a fallback to date_default_timezone_get + return $vTimezone->getTimeZone()->getName(); + } + + $principal = 'principals/users/' . $userId; + $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI); + $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); + + /** @var ?VTimeZone $personalCalendarTimezone */ + $personalCalendarTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) use ($uri) { + if ($acc !== null) { + return $acc; + } + if ($calendar->getUri() === $uri && !$calendar->isDeleted() && $calendar instanceof CalendarImpl) { + return $calendar->getSchedulingTimezone(); + } + return null; + }); + if ($personalCalendarTimezone !== null) { + return $personalCalendarTimezone->getTimeZone()->getName(); + } + + // No timezone in the personalCalendarTimezone calendar or no personalCalendarTimezone calendar + // Loop through all calendars until we find a timezone. + /** @var ?VTimeZone $firstTimezone */ + $firstTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) { + if ($acc !== null) { + return $acc; + } + if (!$calendar->isDeleted() && $calendar instanceof CalendarImpl) { + return $calendar->getSchedulingTimezone(); + } + return null; + }); + if ($firstTimezone !== null) { + return $firstTimezone->getTimeZone()->getName(); + } + return null; + } + + public function getDefaultTimezone(): string { + return $this->config->getSystemValueString('default_timezone', 'UTC'); + } + +} diff --git a/apps/dav/lib/CalDAV/TipBroker.php b/apps/dav/lib/CalDAV/TipBroker.php new file mode 100644 index 00000000000..16e68fde1f0 --- /dev/null +++ b/apps/dav/lib/CalDAV/TipBroker.php @@ -0,0 +1,187 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\ITip\Broker; +use Sabre\VObject\ITip\Message; + +class TipBroker extends Broker { + + public $significantChangeProperties = [ + 'DTSTART', + 'DTEND', + 'DURATION', + 'DUE', + 'RRULE', + 'RDATE', + 'EXDATE', + 'STATUS', + 'SUMMARY', + 'DESCRIPTION', + 'LOCATION', + + ]; + + /** + * This method is used in cases where an event got updated, and we + * potentially need to send emails to attendees to let them know of updates + * in the events. + * + * We will detect which attendees got added, which got removed and create + * specific messages for these situations. + * + * @return array + */ + protected function parseEventForOrganizer(VCalendar $calendar, array $eventInfo, array $oldEventInfo) { + // Merging attendee lists. + $attendees = []; + foreach ($oldEventInfo['attendees'] as $attendee) { + $attendees[$attendee['href']] = [ + 'href' => $attendee['href'], + 'oldInstances' => $attendee['instances'], + 'newInstances' => [], + 'name' => $attendee['name'], + 'forceSend' => null, + ]; + } + foreach ($eventInfo['attendees'] as $attendee) { + if (isset($attendees[$attendee['href']])) { + $attendees[$attendee['href']]['name'] = $attendee['name']; + $attendees[$attendee['href']]['newInstances'] = $attendee['instances']; + $attendees[$attendee['href']]['forceSend'] = $attendee['forceSend']; + } else { + $attendees[$attendee['href']] = [ + 'href' => $attendee['href'], + 'oldInstances' => [], + 'newInstances' => $attendee['instances'], + 'name' => $attendee['name'], + 'forceSend' => $attendee['forceSend'], + ]; + } + } + + $messages = []; + + foreach ($attendees as $attendee) { + // An organizer can also be an attendee. We should not generate any + // messages for those. + if ($attendee['href'] === $eventInfo['organizer']) { + continue; + } + + $message = new Message(); + $message->uid = $eventInfo['uid']; + $message->component = 'VEVENT'; + $message->sequence = $eventInfo['sequence']; + $message->sender = $eventInfo['organizer']; + $message->senderName = $eventInfo['organizerName']; + $message->recipient = $attendee['href']; + $message->recipientName = $attendee['name']; + + // Creating the new iCalendar body. + $icalMsg = new VCalendar(); + + foreach ($calendar->select('VTIMEZONE') as $timezone) { + $icalMsg->add(clone $timezone); + } + // If there are no instances the attendee is a part of, it means + // the attendee was removed and we need to send them a CANCEL message. + // Also If the meeting STATUS property was changed to CANCELLED + // we need to send the attendee a CANCEL message. + if (!$attendee['newInstances'] || $eventInfo['status'] === 'CANCELLED') { + + $message->method = $icalMsg->METHOD = 'CANCEL'; + $message->significantChange = true; + // clone base event + $event = clone $eventInfo['instances']['master']; + // alter some properties + unset($event->ATTENDEE); + $event->add('ATTENDEE', $attendee['href'], ['CN' => $attendee['name'],]); + $event->DTSTAMP = gmdate('Ymd\\THis\\Z'); + $event->SEQUENCE = $message->sequence; + $icalMsg->add($event); + + } else { + // The attendee gets the updated event body + $message->method = $icalMsg->METHOD = 'REQUEST'; + + // We need to find out that this change is significant. If it's + // not, systems may opt to not send messages. + // + // We do this based on the 'significantChangeHash' which is + // some value that changes if there's a certain set of + // properties changed in the event, or simply if there's a + // difference in instances that the attendee is invited to. + + $oldAttendeeInstances = array_keys($attendee['oldInstances']); + $newAttendeeInstances = array_keys($attendee['newInstances']); + + $message->significantChange + = $attendee['forceSend'] === 'REQUEST' + || count($oldAttendeeInstances) !== count($newAttendeeInstances) + || count(array_diff($oldAttendeeInstances, $newAttendeeInstances)) > 0 + || $oldEventInfo['significantChangeHash'] !== $eventInfo['significantChangeHash']; + + foreach ($attendee['newInstances'] as $instanceId => $instanceInfo) { + $currentEvent = clone $eventInfo['instances'][$instanceId]; + if ($instanceId === 'master') { + // We need to find a list of events that the attendee + // is not a part of to add to the list of exceptions. + $exceptions = []; + foreach ($eventInfo['instances'] as $instanceId => $vevent) { + if (!isset($attendee['newInstances'][$instanceId])) { + $exceptions[] = $instanceId; + } + } + + // If there were exceptions, we need to add it to an + // existing EXDATE property, if it exists. + if ($exceptions) { + if (isset($currentEvent->EXDATE)) { + $currentEvent->EXDATE->setParts(array_merge( + $currentEvent->EXDATE->getParts(), + $exceptions + )); + } else { + $currentEvent->EXDATE = $exceptions; + } + } + + // Cleaning up any scheduling information that + // shouldn't be sent along. + unset($currentEvent->ORGANIZER['SCHEDULE-FORCE-SEND']); + unset($currentEvent->ORGANIZER['SCHEDULE-STATUS']); + + foreach ($currentEvent->ATTENDEE as $attendee) { + unset($attendee['SCHEDULE-FORCE-SEND']); + unset($attendee['SCHEDULE-STATUS']); + + // We're adding PARTSTAT=NEEDS-ACTION to ensure that + // iOS shows an "Inbox Item" + if (!isset($attendee['PARTSTAT'])) { + $attendee['PARTSTAT'] = 'NEEDS-ACTION'; + } + } + } + + $currentEvent->DTSTAMP = gmdate('Ymd\\THis\\Z'); + $icalMsg->add($currentEvent); + } + } + + $message->message = $icalMsg; + $messages[] = $message; + } + + return $messages; + } + +} diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php index 5730b7a1002..d8c429f2056 100644 --- a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV\Trashbin; @@ -35,26 +18,13 @@ use Sabre\DAVACL\IACL; class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { use ACLTrait; - /** @var string */ - private $name; - - /** @var mixed[] */ - private $objectData; - - /** @var string */ - private $principalUri; - - /** @var CalDavBackend */ - private $calDavBackend; - - public function __construct(string $name, - array $objectData, - string $principalUri, - CalDavBackend $calDavBackend) { - $this->name = $name; - $this->objectData = $objectData; - $this->calDavBackend = $calDavBackend; - $this->principalUri = $principalUri; + public function __construct( + private string $name, + /** @var mixed[] */ + private array $objectData, + private string $principalUri, + private CalDavBackend $calDavBackend, + ) { } public function delete() { @@ -89,7 +59,7 @@ class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { public function getContentType() { $mime = 'text/calendar; charset=utf-8'; if (isset($this->objectData['component']) && $this->objectData['component']) { - $mime .= '; component='.$this->objectData['component']; + $mime .= '; component=' . $this->objectData['component']; } return $mime; @@ -100,7 +70,7 @@ class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { } public function getSize() { - return (int) $this->objectData['size']; + return (int)$this->objectData['size']; } public function restore(): void { @@ -108,7 +78,7 @@ class DeletedCalendarObject implements IACL, ICalendarObject, IRestorable { } public function getDeletedAt(): ?int { - return $this->objectData['deleted_at'] ? (int) $this->objectData['deleted_at'] : null; + return $this->objectData['deleted_at'] ? (int)$this->objectData['deleted_at'] : null; } public function getCalendarUri(): string { diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php index 20d05c047b1..f75e19689f1 100644 --- a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV\Trashbin; @@ -31,23 +14,22 @@ use Sabre\DAV\Exception\BadRequest; use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Exception\NotImplemented; +use Sabre\DAVACL\ACLTrait; +use Sabre\DAVACL\IACL; use function array_map; use function implode; use function preg_match; -class DeletedCalendarObjectsCollection implements ICalendarObjectContainer { - public const NAME = 'objects'; - - /** @var CalDavBackend */ - protected $caldavBackend; +class DeletedCalendarObjectsCollection implements ICalendarObjectContainer, IACL { + use ACLTrait; - /** @var mixed[] */ - private $principalInfo; + public const NAME = 'objects'; - public function __construct(CalDavBackend $caldavBackend, - array $principalInfo) { - $this->caldavBackend = $caldavBackend; - $this->principalInfo = $principalInfo; + public function __construct( + protected CalDavBackend $caldavBackend, + /** @var mixed[] */ + private array $principalInfo, + ) { } /** @@ -64,7 +46,7 @@ class DeletedCalendarObjectsCollection implements ICalendarObjectContainer { $data = $this->caldavBackend->getCalendarObjectById( $this->principalInfo['uri'], - (int) $matches[1], + (int)$matches[1], ); // If the object hasn't been deleted yet then we don't want to find it here @@ -129,4 +111,23 @@ class DeletedCalendarObjectsCollection implements ICalendarObjectContainer { [$calendarInfo['id'], 'ics'], ); } + + public function getOwner() { + return $this->principalInfo['uri']; + } + + public function getACL(): array { + return [ + [ + 'privilege' => '{DAV:}read', + 'principal' => $this->getOwner(), + 'protected' => true, + ], + [ + 'privilege' => '{DAV:}unbind', + 'principal' => '{DAV:}owner', + 'protected' => true, + ] + ]; + } } diff --git a/apps/dav/lib/CalDAV/Trashbin/Plugin.php b/apps/dav/lib/CalDAV/Trashbin/Plugin.php index 58ff76beca1..6f58b1f3110 100644 --- a/apps/dav/lib/CalDAV/Trashbin/Plugin.php +++ b/apps/dav/lib/CalDAV/Trashbin/Plugin.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV\Trashbin; @@ -49,16 +32,14 @@ class Plugin extends ServerPlugin { /** @var bool */ private $disableTrashbin; - /** @var RetentionService */ - private $retentionService; - /** @var Server */ private $server; - public function __construct(IRequest $request, - RetentionService $retentionService) { + public function __construct( + IRequest $request, + private RetentionService $retentionService, + ) { $this->disableTrashbin = $request->getHeader('X-NC-CalDAV-No-Trashbin') === '1'; - $this->retentionService = $retentionService; } public function initialize(Server $server): void { diff --git a/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php index 31331957c49..6641148de2b 100644 --- a/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php +++ b/apps/dav/lib/CalDAV/Trashbin/RestoreTarget.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV\Trashbin; diff --git a/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php index 34d11e51eb3..1c76bd2295d 100644 --- a/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php +++ b/apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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 OCA\DAV\CalDAV\Trashbin; @@ -43,16 +26,10 @@ class TrashbinHome implements IACL, ICollection, IProperties { public const NAME = 'trashbin'; - /** @var CalDavBackend */ - private $caldavBackend; - - /** @var array */ - private $principalInfo; - - public function __construct(CalDavBackend $caldavBackend, - array $principalInfo) { - $this->caldavBackend = $caldavBackend; - $this->principalInfo = $principalInfo; + public function __construct( + private CalDavBackend $caldavBackend, + private array $principalInfo, + ) { } public function getOwner(): string { diff --git a/apps/dav/lib/CalDAV/UpcomingEvent.php b/apps/dav/lib/CalDAV/UpcomingEvent.php new file mode 100644 index 00000000000..e8b604f460a --- /dev/null +++ b/apps/dav/lib/CalDAV/UpcomingEvent.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use JsonSerializable; +use OCA\DAV\ResponseDefinitions; + +class UpcomingEvent implements JsonSerializable { + public function __construct( + private string $uri, + private ?int $recurrenceId, + private string $calendarUri, + private ?int $start, + private ?string $summary, + private ?string $location, + private ?string $calendarAppUrl, + ) { + } + + public function getUri(): string { + return $this->uri; + } + + public function getRecurrenceId(): ?int { + return $this->recurrenceId; + } + + public function getCalendarUri(): string { + return $this->calendarUri; + } + + public function getStart(): ?int { + return $this->start; + } + + public function getSummary(): ?string { + return $this->summary; + } + + public function getLocation(): ?string { + return $this->location; + } + + public function getCalendarAppUrl(): ?string { + return $this->calendarAppUrl; + } + + /** + * @see ResponseDefinitions + */ + public function jsonSerialize(): array { + return [ + 'uri' => $this->uri, + 'recurrenceId' => $this->recurrenceId, + 'calendarUri' => $this->calendarUri, + 'start' => $this->start, + 'summary' => $this->summary, + 'location' => $this->location, + 'calendarAppUrl' => $this->calendarAppUrl, + ]; + } +} diff --git a/apps/dav/lib/CalDAV/UpcomingEventsService.php b/apps/dav/lib/CalDAV/UpcomingEventsService.php new file mode 100644 index 00000000000..1a8aed5bd71 --- /dev/null +++ b/apps/dav/lib/CalDAV/UpcomingEventsService.php @@ -0,0 +1,86 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\CalDAV; + +use OCP\App\IAppManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Calendar\IManager; +use OCP\IURLGenerator; +use OCP\IUserManager; +use function array_map; + +class UpcomingEventsService { + public function __construct( + private IManager $calendarManager, + private ITimeFactory $timeFactory, + private IUserManager $userManager, + private IAppManager $appManager, + private IURLGenerator $urlGenerator, + ) { + } + + /** + * @return UpcomingEvent[] + */ + public function getEvents(string $userId, ?string $location = null): array { + $searchQuery = $this->calendarManager->newQuery('principals/users/' . $userId); + if ($location !== null) { + $searchQuery->addSearchProperty('LOCATION'); + $searchQuery->setSearchPattern($location); + } + $searchQuery->addType('VEVENT'); + $searchQuery->setLimit(3); + $now = $this->timeFactory->now(); + $searchQuery->setTimerangeStart($now->modify('-1 minute')); + $searchQuery->setTimerangeEnd($now->modify('+1 month')); + + $events = $this->calendarManager->searchForPrincipal($searchQuery); + $calendarAppEnabled = $this->appManager->isEnabledForUser( + 'calendar', + $this->userManager->get($userId), + ); + + return array_filter(array_map(function (array $event) use ($userId, $calendarAppEnabled) { + $calendarAppUrl = null; + + if ($calendarAppEnabled) { + $arguments = [ + 'objectId' => base64_encode($this->urlGenerator->getWebroot() . '/remote.php/dav/calendars/' . $userId . '/' . $event['calendar-uri'] . '/' . $event['uri']), + ]; + + if (isset($event['RECURRENCE-ID'])) { + $arguments['recurrenceId'] = $event['RECURRENCE-ID'][0]; + } + /** + * TODO: create a named, deep route in calendar (it's a code smell to just assume this route exists, find an abstraction) + * When changing, also adjust for: + * - spreed/lib/Service/CalendarIntegrationService.php#getDashboardEvents + * - spreed/lib/Service/CalendarIntegrationService.php#getMutualEvents + */ + $calendarAppUrl = $this->urlGenerator->linkToRouteAbsolute('calendar.view.indexdirect.edit', $arguments); + } + + if (isset($event['objects'][0]['STATUS']) && $event['objects'][0]['STATUS'][0] === 'CANCELLED') { + return false; + } + + return new UpcomingEvent( + $event['uri'], + ($event['objects'][0]['RECURRENCE-ID'][0] ?? null)?->getTimeStamp(), + $event['calendar-uri'], + $event['objects'][0]['DTSTART'][0]?->getTimestamp(), + $event['objects'][0]['SUMMARY'][0] ?? null, + $event['objects'][0]['LOCATION'][0] ?? null, + $calendarAppUrl, + ); + }, $events)); + } + +} diff --git a/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php b/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php new file mode 100644 index 00000000000..b647e63e67b --- /dev/null +++ b/apps/dav/lib/CalDAV/Validation/CalDavValidatePlugin.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +/* + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\Validation; + +use OCA\DAV\AppInfo\Application; +use OCP\IAppConfig; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class CalDavValidatePlugin extends ServerPlugin { + + public function __construct( + private IAppConfig $config, + ) { + } + + public function initialize(Server $server): void { + $server->on('beforeMethod:PUT', [$this, 'beforePut']); + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): bool { + // evaluate if card size exceeds defined limit + $eventSizeLimit = $this->config->getValueInt(Application::APP_ID, 'event_size_limit', 10485760); + if ((int)$request->getRawServerValue('CONTENT_LENGTH') > $eventSizeLimit) { + throw new Forbidden("VEvent or VTodo object exceeds $eventSizeLimit bytes"); + } + // all tests passed return true + return true; + } + +} diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php new file mode 100644 index 00000000000..3d12c92c49a --- /dev/null +++ b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php @@ -0,0 +1,143 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\DAV\CalDAV\WebcalCaching; + +use Exception; +use GuzzleHttp\RequestOptions; +use OCP\Http\Client\IClientService; +use OCP\Http\Client\LocalServerException; +use OCP\IAppConfig; +use Psr\Log\LoggerInterface; +use Sabre\VObject\Reader; + +class Connection { + public function __construct( + private IClientService $clientService, + private IAppConfig $config, + private LoggerInterface $logger, + ) { + } + + /** + * gets webcal feed from remote server + */ + public function queryWebcalFeed(array $subscription): ?string { + $subscriptionId = $subscription['id']; + $url = $this->cleanURL($subscription['source']); + if ($url === null) { + return null; + } + + $allowLocalAccess = $this->config->getValueString('dav', 'webcalAllowLocalAccess', 'no'); + + $params = [ + 'nextcloud' => [ + 'allow_local_address' => $allowLocalAccess === 'yes', + ], + RequestOptions::HEADERS => [ + 'User-Agent' => 'Nextcloud Webcal Service', + 'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml', + ], + ]; + + $user = parse_url($subscription['source'], PHP_URL_USER); + $pass = parse_url($subscription['source'], PHP_URL_PASS); + if ($user !== null && $pass !== null) { + $params[RequestOptions::AUTH] = [$user, $pass]; + } + + try { + $client = $this->clientService->newClient(); + $response = $client->get($url, $params); + } catch (LocalServerException $ex) { + $this->logger->warning("Subscription $subscriptionId was not refreshed because it violates local access rules", [ + 'exception' => $ex, + ]); + return null; + } catch (Exception $ex) { + $this->logger->warning("Subscription $subscriptionId could not be refreshed due to a network error", [ + 'exception' => $ex, + ]); + return null; + } + + $body = $response->getBody(); + + $contentType = $response->getHeader('Content-Type'); + $contentType = explode(';', $contentType, 2)[0]; + switch ($contentType) { + case 'application/calendar+json': + try { + $jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING); + } catch (Exception $ex) { + // In case of a parsing error return null + $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); + return null; + } + return $jCalendar->serialize(); + + case 'application/calendar+xml': + try { + $xCalendar = Reader::readXML($body); + } catch (Exception $ex) { + // In case of a parsing error return null + $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); + return null; + } + return $xCalendar->serialize(); + + case 'text/calendar': + default: + try { + $vCalendar = Reader::read($body); + } catch (Exception $ex) { + // In case of a parsing error return null + $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); + return null; + } + return $vCalendar->serialize(); + } + } + + /** + * This method will strip authentication information and replace the + * 'webcal' or 'webcals' protocol scheme + * + * @param string $url + * @return string|null + */ + private function cleanURL(string $url): ?string { + $parsed = parse_url($url); + if ($parsed === false) { + return null; + } + + if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') { + $scheme = 'http'; + } else { + $scheme = 'https'; + } + + $host = $parsed['host'] ?? ''; + $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; + $path = $parsed['path'] ?? ''; + $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; + $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; + + $cleanURL = "$scheme://$host$port$path$query$fragment"; + // parse_url is giving some weird results if no url and no :// is given, + // so let's test the url again + $parsedClean = parse_url($cleanURL); + if ($parsedClean === false || !isset($parsedClean['host'])) { + return null; + } + + return $cleanURL; + } +} diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php b/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php index 3dd8a7c81e5..e07be39c7b4 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/Plugin.php @@ -3,30 +3,12 @@ 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> - * - * @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 OCA\DAV\CalDAV\WebcalCaching; -use OCA\DAV\CalDAV\CalendarHome; +use OCA\DAV\CalDAV\CalendarRoot; use OCP\IRequest; use Sabre\DAV\Exception\NotFound; use Sabre\DAV\Server; @@ -41,10 +23,14 @@ class Plugin extends ServerPlugin { * that do not support subscriptions on their own * * /^MSFT-WIN-3/ - Windows 10 Calendar + * /Evolution/ - Gnome Calendar/Evolution + * /KIO/ - KDE PIM/Akonadi * @var string[] */ public const ENABLE_FOR_CLIENTS = [ - "/^MSFT-WIN-3/" + '/^MSFT-WIN-3/', + '/Evolution/', + '/KIO/' ]; /** @@ -71,6 +57,11 @@ class Plugin extends ServerPlugin { if ($magicHeader === 'On') { $this->enabled = true; } + + $isExportRequest = $request->getMethod() === 'GET' && array_key_exists('export', $request->getParams()); + if ($isExportRequest) { + $this->enabled = true; + } } /** @@ -85,7 +76,7 @@ class Plugin extends ServerPlugin { */ public function initialize(Server $server) { $this->server = $server; - $server->on('beforeMethod:*', [$this, 'beforeMethod']); + $server->on('beforeMethod:*', [$this, 'beforeMethod'], 15); } /** @@ -98,21 +89,20 @@ class Plugin extends ServerPlugin { } $path = $request->getPath(); + if (!str_starts_with($path, 'calendars/')) { + return; + } + $pathParts = explode('/', ltrim($path, '/')); if (\count($pathParts) < 2) { return; } - // $calendarHomePath will look like: calendars/username - $calendarHomePath = $pathParts[0] . '/' . $pathParts[1]; try { - $calendarHome = $this->server->tree->getNodeForPath($calendarHomePath); - if (!($calendarHome instanceof CalendarHome)) { - //how did we end up here? - return; + $calendarRoot = $this->server->tree->getNodeForPath($pathParts[0]); + if ($calendarRoot instanceof CalendarRoot) { + $calendarRoot->enableReturnCachedSubscriptions($pathParts[1]); } - - $calendarHome->enableCachedSubscriptionsForThisRequest(); } catch (NotFound $ex) { return; } diff --git a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php index 543d15e0179..a0981e6dec1 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php @@ -3,94 +3,42 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2020, Thomas Citharel <nextcloud@tcit.fr> - * @copyright Copyright (c) 2020, leith abdulla (<online-nextcloud@eleith.com>) - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author eleith <online+github@eleith.com> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\DAV\CalDAV\WebcalCaching; -use Exception; -use GuzzleHttp\HandlerStack; -use GuzzleHttp\Middleware; use OCA\DAV\CalDAV\CalDavBackend; -use OCP\Http\Client\IClientService; -use OCP\Http\Client\LocalServerException; -use OCP\IConfig; -use OCP\ILogger; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; +use OCP\AppFramework\Utility\ITimeFactory; +use Psr\Log\LoggerInterface; use Sabre\DAV\Exception\BadRequest; +use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\PropPatch; -use Sabre\DAV\Xml\Property\Href; use Sabre\VObject\Component; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; -use Sabre\VObject\Recur\NoInstancesException; use Sabre\VObject\ParseException; use Sabre\VObject\Reader; +use Sabre\VObject\Recur\NoInstancesException; use Sabre\VObject\Splitter\ICalendar; use Sabre\VObject\UUIDUtil; use function count; class RefreshWebcalService { - /** @var CalDavBackend */ - private $calDavBackend; - - /** @var IClientService */ - private $clientService; - - /** @var IConfig */ - private $config; - - /** @var ILogger */ - private $logger; - public const REFRESH_RATE = '{http://apple.com/ns/ical/}refreshrate'; public const STRIP_ALARMS = '{http://calendarserver.org/ns/}subscribed-strip-alarms'; public const STRIP_ATTACHMENTS = '{http://calendarserver.org/ns/}subscribed-strip-attachments'; public const STRIP_TODOS = '{http://calendarserver.org/ns/}subscribed-strip-todos'; - /** - * RefreshWebcalJob constructor. - * - * @param CalDavBackend $calDavBackend - * @param IClientService $clientService - * @param IConfig $config - * @param ILogger $logger - */ - public function __construct(CalDavBackend $calDavBackend, IClientService $clientService, IConfig $config, ILogger $logger) { - $this->calDavBackend = $calDavBackend; - $this->clientService = $clientService; - $this->config = $config; - $this->logger = $logger; + public function __construct( + private CalDavBackend $calDavBackend, + private LoggerInterface $logger, + private Connection $connection, + private ITimeFactory $time, + ) { } - /** - * @param string $principalUri - * @param string $uri - */ public function refreshSubscription(string $principalUri, string $uri) { $subscription = $this->getSubscription($principalUri, $uri); $mutations = []; @@ -98,11 +46,25 @@ class RefreshWebcalService { return; } - $webcalData = $this->queryWebcalFeed($subscription, $mutations); + // Check the refresh rate if there is any + if (!empty($subscription['{http://apple.com/ns/ical/}refreshrate'])) { + // add the refresh interval to the lastmodified timestamp + $refreshInterval = new \DateInterval($subscription['{http://apple.com/ns/ical/}refreshrate']); + $updateTime = $this->time->getDateTime(); + $updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval); + if ($updateTime->getTimestamp() > $this->time->getTime()) { + return; + } + } + + + $webcalData = $this->connection->queryWebcalFeed($subscription); if (!$webcalData) { return; } + $localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + $stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1; $stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1; $stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1; @@ -110,14 +72,10 @@ class RefreshWebcalService { try { $splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING); - // we wait with deleting all outdated events till we parsed the new ones - // in case the new calendar is broken and `new ICalendar` throws a ParseException - // the user will still see the old data - $this->calDavBackend->purgeAllCachedEventsForSubscription($subscription['id']); - while ($vObject = $splitter->getNext()) { /** @var Component $vObject */ $compName = null; + $uid = null; foreach ($vObject->getComponents() as $component) { if ($component->name === 'VTIMEZONE') { @@ -132,19 +90,66 @@ class RefreshWebcalService { if ($stripAttachments) { unset($component->{'ATTACH'}); } + + $uid = $component->{ 'UID' }->getValue(); } if ($stripTodos && $compName === 'VTODO') { continue; } - $uri = $this->getRandomCalendarObjectUri(); - $calendarData = $vObject->serialize(); + if (!isset($uid)) { + continue; + } + try { - $this->calDavBackend->createCalendarObject($subscription['id'], $uri, $calendarData, CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); - } catch (NoInstancesException | BadRequest $ex) { - $this->logger->logException($ex); + $denormalized = $this->calDavBackend->getDenormalizedData($vObject->serialize()); + } catch (InvalidDataException|Forbidden $ex) { + $this->logger->warning('Unable to denormalize calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + continue; + } + + // Find all identical sets and remove them from the update + if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) { + unset($localData[$uid]); + continue; + } + + $vObjectCopy = clone $vObject; + $identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]); + if ($identical) { + unset($localData[$uid]); + continue; + } + + // Find all modified sets and update them + if (isset($localData[$uid]) && $denormalized['etag'] !== $localData[$uid]['etag']) { + $this->calDavBackend->updateCalendarObject($subscription['id'], $localData[$uid]['uri'], $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + unset($localData[$uid]); + continue; } + + // Only entirely new events get created here + try { + $objectUri = $this->getRandomCalendarObjectUri(); + $this->calDavBackend->createCalendarObject($subscription['id'], $objectUri, $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + } catch (NoInstancesException|BadRequest $ex) { + $this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + } + } + + $ids = array_map(static function ($dataSet): int { + return (int)$dataSet['id']; + }, $localData); + $uris = array_map(static function ($dataSet): string { + return $dataSet['uri']; + }, $localData); + + if (!empty($ids) && !empty($uris)) { + // Clean up on aisle 5 + // The only events left over in the $localData array should be those that don't exist upstream + // All deleted VObjects from upstream are removed + $this->calDavBackend->purgeCachedEventsForSubscription($subscription['id'], $ids, $uris); } $newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData); @@ -154,21 +159,14 @@ class RefreshWebcalService { $this->updateSubscription($subscription, $mutations); } catch (ParseException $ex) { - $subscriptionId = $subscription['id']; - - $this->logger->logException($ex); - $this->logger->warning("Subscription $subscriptionId could not be refreshed due to a parsing error"); + $this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]); } } /** * loads subscription from backend - * - * @param string $principalUri - * @param string $uri - * @return array|null */ - public function getSubscription(string $principalUri, string $uri) { + public function getSubscription(string $principalUri, string $uri): ?array { $subscriptions = array_values(array_filter( $this->calDavBackend->getSubscriptionsForUser($principalUri), function ($sub) use ($uri) { @@ -183,117 +181,6 @@ class RefreshWebcalService { return $subscriptions[0]; } - /** - * gets webcal feed from remote server - * - * @param array $subscription - * @param array &$mutations - * @return null|string - */ - private function queryWebcalFeed(array $subscription, array &$mutations) { - $client = $this->clientService->newClient(); - - $didBreak301Chain = false; - $latestLocation = null; - - $handlerStack = HandlerStack::create(); - $handlerStack->push(Middleware::mapRequest(function (RequestInterface $request) { - return $request - ->withHeader('Accept', 'text/calendar, application/calendar+json, application/calendar+xml') - ->withHeader('User-Agent', 'Nextcloud Webcal Crawler'); - })); - $handlerStack->push(Middleware::mapResponse(function (ResponseInterface $response) use (&$didBreak301Chain, &$latestLocation) { - if (!$didBreak301Chain) { - if ($response->getStatusCode() !== 301) { - $didBreak301Chain = true; - } else { - $latestLocation = $response->getHeader('Location'); - } - } - return $response; - })); - - $allowLocalAccess = $this->config->getAppValue('dav', 'webcalAllowLocalAccess', 'no'); - $subscriptionId = $subscription['id']; - $url = $this->cleanURL($subscription['source']); - if ($url === null) { - return null; - } - - try { - $params = [ - 'allow_redirects' => [ - 'redirects' => 10 - ], - 'handler' => $handlerStack, - 'nextcloud' => [ - 'allow_local_address' => $allowLocalAccess === 'yes', - ] - ]; - - $user = parse_url($subscription['source'], PHP_URL_USER); - $pass = parse_url($subscription['source'], PHP_URL_PASS); - if ($user !== null && $pass !== null) { - $params['auth'] = [$user, $pass]; - } - - $response = $client->get($url, $params); - $body = $response->getBody(); - - if ($latestLocation) { - $mutations['{http://calendarserver.org/ns/}source'] = new Href($latestLocation); - } - - $contentType = $response->getHeader('Content-Type'); - $contentType = explode(';', $contentType, 2)[0]; - switch ($contentType) { - case 'application/calendar+json': - try { - $jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->debug("Subscription $subscriptionId could not be parsed"); - return null; - } - return $jCalendar->serialize(); - - case 'application/calendar+xml': - try { - $xCalendar = Reader::readXML($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->debug("Subscription $subscriptionId could not be parsed"); - return null; - } - return $xCalendar->serialize(); - - case 'text/calendar': - default: - try { - $vCalendar = Reader::read($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->debug("Subscription $subscriptionId could not be parsed"); - return null; - } - return $vCalendar->serialize(); - } - } catch (LocalServerException $ex) { - $this->logger->logException($ex, [ - 'message' => "Subscription $subscriptionId was not refreshed because it violates local access rules", - 'level' => ILogger::WARN, - ]); - - return null; - } catch (Exception $ex) { - $this->logger->logException($ex, [ - 'message' => "Subscription $subscriptionId could not be refreshed due to a network error", - 'level' => ILogger::WARN, - ]); - - return null; - } - } /** * check if: @@ -301,11 +188,8 @@ class RefreshWebcalService { * - the webcal feed suggests a refreshrate * - return suggested refreshrate if user didn't set a custom one * - * @param array $subscription - * @param string $webcalData - * @return string|null */ - private function checkWebcalDataForRefreshRate($subscription, $webcalData) { + private function checkWebcalDataForRefreshRate(array $subscription, string $webcalData): ?string { // if there is no refreshrate stored in the database, check the webcal feed // whether it suggests any refresh rate and store that in the database if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) { @@ -357,47 +241,24 @@ class RefreshWebcalService { } /** - * This method will strip authentication information and replace the - * 'webcal' or 'webcals' protocol scheme + * Returns a random uri for a calendar-object * - * @param string $url - * @return string|null + * @return string */ - private function cleanURL(string $url) { - $parsed = parse_url($url); - if ($parsed === false) { - return null; - } + public function getRandomCalendarObjectUri():string { + return UUIDUtil::getUUID() . '.ics'; + } - if (isset($parsed['scheme']) && $parsed['scheme'] === 'http') { - $scheme = 'http'; - } else { - $scheme = 'https'; + private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool { + foreach ($vObject->getComponents() as $component) { + unset($component->{'DTSTAMP'}); } - $host = $parsed['host'] ?? ''; - $port = isset($parsed['port']) ? ':' . $parsed['port'] : ''; - $path = $parsed['path'] ?? ''; - $query = isset($parsed['query']) ? '?' . $parsed['query'] : ''; - $fragment = isset($parsed['fragment']) ? '#' . $parsed['fragment'] : ''; - - $cleanURL = "$scheme://$host$port$path$query$fragment"; - // parse_url is giving some weird results if no url and no :// is given, - // so let's test the url again - $parsedClean = parse_url($cleanURL); - if ($parsedClean === false || !isset($parsedClean['host'])) { - return null; + $localVobject = Reader::read($calendarObject['calendardata']); + foreach ($localVobject->getComponents() as $component) { + unset($component->{'DTSTAMP'}); } - return $cleanURL; - } - - /** - * Returns a random uri for a calendar-object - * - * @return string - */ - public function getRandomCalendarObjectUri():string { - return UUIDUtil::getUUID() . '.ics'; + return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0; } } |